Fall back to JWT exp claim when OAuth token response has no expires_in (#506)

This commit is contained in:
Gregory Schier
2026-07-14 08:24:07 -07:00
committed by GitHub
parent e05feba708
commit 42b22d4c07
6 changed files with 96 additions and 9 deletions
@@ -13,6 +13,7 @@ export async function getOrRefreshAccessToken(
credentialsInBody, credentialsInBody,
clientId, clientId,
clientSecret, clientSecret,
tokenName,
forceRefresh, forceRefresh,
}: { }: {
scope: string | null; scope: string | null;
@@ -20,6 +21,7 @@ export async function getOrRefreshAccessToken(
credentialsInBody: boolean; credentialsInBody: boolean;
clientId: string; clientId: string;
clientSecret: string; clientSecret: string;
tokenName?: "access_token" | "id_token";
forceRefresh?: boolean; forceRefresh?: boolean;
}, },
): Promise<AccessToken | null> { ): Promise<AccessToken | null> {
@@ -28,7 +30,7 @@ export async function getOrRefreshAccessToken(
return null; return null;
} }
const isExpired = isTokenExpired(token); const isExpired = isTokenExpired(token, tokenName);
// Return the current access token if it's still valid // Return the current access token if it's still valid
if (!isExpired && !forceRefresh) { if (!isExpired && !forceRefresh) {
@@ -111,5 +113,5 @@ export async function getOrRefreshAccessToken(
refresh_token: response.refresh_token ?? token.response.refresh_token, refresh_token: response.refresh_token ?? token.response.refresh_token,
}; };
return storeToken(ctx, tokenArgs, newResponse); return storeToken(ctx, tokenArgs, newResponse, tokenName);
} }
@@ -67,6 +67,7 @@ export async function getAuthorizationCode(
clientId, clientId,
clientSecret, clientSecret,
credentialsInBody, credentialsInBody,
tokenName,
}); });
if (token != null) { if (token != null) {
return token; return token;
+3 -3
View File
@@ -37,7 +37,7 @@ export async function getImplicit(
authorizationUrl: authorizationUrlRaw, authorizationUrl: authorizationUrlRaw,
}; };
const token = await getToken(ctx, tokenArgs); const token = await getToken(ctx, tokenArgs);
if (token != null && !isTokenExpired(token)) { if (token != null && !isTokenExpired(token, tokenName)) {
return token; return token;
} }
@@ -137,7 +137,7 @@ async function getTokenViaEmbeddedBrowser(
const response = Object.fromEntries(params) as unknown as AccessTokenRawResponse; const response = Object.fromEntries(params) as unknown as AccessTokenRawResponse;
try { try {
resolve(storeToken(ctx, tokenArgs, response)); resolve(storeToken(ctx, tokenArgs, response, tokenName));
} catch (err) { } catch (err) {
reject(err); reject(err);
} }
@@ -195,5 +195,5 @@ async function extractImplicitToken(
response.id_token = idToken; response.id_token = idToken;
} }
return storeToken(ctx, tokenArgs, response); return storeToken(ctx, tokenArgs, response, tokenName);
} }
+5 -1
View File
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import type { Context } from "@yaakapp/api"; import type { Context } from "@yaakapp/api";
import { jwtExpiresAt } from "./util";
export async function storeToken( export async function storeToken(
ctx: Context, ctx: Context,
@@ -11,7 +12,10 @@ export async function storeToken(
throw new Error(`${tokenName} not found in response ${Object.keys(response).join(", ")}`); throw new Error(`${tokenName} not found in response ${Object.keys(response).join(", ")}`);
} }
const expiresAt = response.expires_in ? Date.now() + response.expires_in * 1000 : null; // Prefer expires_in from the response, falling back to the JWT's own exp claim
const expiresAt = response.expires_in
? Date.now() + response.expires_in * 1000
: jwtExpiresAt(response[tokenName]);
const token: AccessToken = { const token: AccessToken = {
response, response,
expiresAt, expiresAt,
+29 -2
View File
@@ -1,7 +1,34 @@
import jwt from "jsonwebtoken";
import type { AccessToken } from "./store"; import type { AccessToken } from "./store";
export function isTokenExpired(token: AccessToken) { export function isTokenExpired(
return token.expiresAt && Date.now() > token.expiresAt; token: AccessToken,
tokenName: "access_token" | "id_token" = "access_token",
) {
// Fall back to the JWT's own exp claim for tokens stored without an expiry
// (eg. from a token response that had no expires_in). Decode the same token
// that gets sent as the credential.
const expiresAt = token.expiresAt ?? jwtExpiresAt(token.response[tokenName]);
return expiresAt != null && Date.now() > expiresAt;
}
/**
* Get the expiry timestamp (ms) from a JWT's `exp` claim, or null if the token
* is not a JWT or has no `exp`.
*/
export function jwtExpiresAt(token: string | undefined): number | null {
if (!token) return null;
try {
const payload = jwt.decode(token);
if (payload != null && typeof payload === "object" && typeof payload.exp === "number") {
return payload.exp * 1000;
}
} catch {
// Opaque (non-JWT) token
}
return null;
} }
export function extractCode(urlStr: string, redirectUri: string | null): string | null { export function extractCode(urlStr: string, redirectUri: string | null): string | null {
+54 -1
View File
@@ -1,5 +1,6 @@
import jwt from "jsonwebtoken";
import { describe, expect, test } from "vite-plus/test"; import { describe, expect, test } from "vite-plus/test";
import { extractCode } from "../src/util"; import { extractCode, isTokenExpired, jwtExpiresAt } from "../src/util";
describe("extractCode", () => { describe("extractCode", () => {
test("extracts code from query when same origin + path", () => { test("extracts code from query when same origin + path", () => {
@@ -107,3 +108,55 @@ describe("extractCode", () => {
expect(extractCode(url, redirect)).toBe("abc"); expect(extractCode(url, redirect)).toBe("abc");
}); });
}); });
describe("isTokenExpired", () => {
const jwtWithExp = (expSecondsFromNow: number) =>
jwt.sign({ exp: Math.floor(Date.now() / 1000) + expSecondsFromNow }, "test-secret");
test("uses stored expiresAt when present", () => {
expect(isTokenExpired({ response: { access_token: "x" }, expiresAt: Date.now() - 1000 })).toBe(
true,
);
expect(isTokenExpired({ response: { access_token: "x" }, expiresAt: Date.now() + 10000 })).toBe(
false,
);
});
test("falls back to JWT exp claim when expiresAt is null", () => {
expect(
isTokenExpired({ response: { access_token: jwtWithExp(-60) }, expiresAt: null }),
).toBe(true);
expect(
isTokenExpired({ response: { access_token: jwtWithExp(60) }, expiresAt: null }),
).toBe(false);
});
test("treats opaque tokens without expiresAt as non-expiring", () => {
expect(isTokenExpired({ response: { access_token: "opaque-token" }, expiresAt: null })).toBe(
false,
);
});
test("checks the token that is used as the credential", () => {
// Expired id_token credential is not masked by an opaque access_token
const token = {
response: { access_token: "opaque-token", id_token: jwtWithExp(-60) },
expiresAt: null,
};
expect(isTokenExpired(token, "id_token")).toBe(true);
expect(isTokenExpired(token, "access_token")).toBe(false);
});
});
describe("jwtExpiresAt", () => {
test("extracts exp claim in milliseconds", () => {
const exp = Math.floor(Date.now() / 1000) + 300;
expect(jwtExpiresAt(jwt.sign({ exp }, "test-secret"))).toBe(exp * 1000);
});
test("returns null for non-JWT or missing tokens", () => {
expect(jwtExpiresAt("not-a-jwt")).toBeNull();
expect(jwtExpiresAt(undefined)).toBeNull();
expect(jwtExpiresAt(jwt.sign({ foo: "bar" }, "test-secret"))).toBeNull();
});
});