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,
clientId,
clientSecret,
tokenName,
forceRefresh,
}: {
scope: string | null;
@@ -20,6 +21,7 @@ export async function getOrRefreshAccessToken(
credentialsInBody: boolean;
clientId: string;
clientSecret: string;
tokenName?: "access_token" | "id_token";
forceRefresh?: boolean;
},
): Promise<AccessToken | null> {
@@ -28,7 +30,7 @@ export async function getOrRefreshAccessToken(
return null;
}
const isExpired = isTokenExpired(token);
const isExpired = isTokenExpired(token, tokenName);
// Return the current access token if it's still valid
if (!isExpired && !forceRefresh) {
@@ -111,5 +113,5 @@ export async function getOrRefreshAccessToken(
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,
clientSecret,
credentialsInBody,
tokenName,
});
if (token != null) {
return token;
+3 -3
View File
@@ -37,7 +37,7 @@ export async function getImplicit(
authorizationUrl: authorizationUrlRaw,
};
const token = await getToken(ctx, tokenArgs);
if (token != null && !isTokenExpired(token)) {
if (token != null && !isTokenExpired(token, tokenName)) {
return token;
}
@@ -137,7 +137,7 @@ async function getTokenViaEmbeddedBrowser(
const response = Object.fromEntries(params) as unknown as AccessTokenRawResponse;
try {
resolve(storeToken(ctx, tokenArgs, response));
resolve(storeToken(ctx, tokenArgs, response, tokenName));
} catch (err) {
reject(err);
}
@@ -195,5 +195,5 @@ async function extractImplicitToken(
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 type { Context } from "@yaakapp/api";
import { jwtExpiresAt } from "./util";
export async function storeToken(
ctx: Context,
@@ -11,7 +12,10 @@ export async function storeToken(
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 = {
response,
expiresAt,
+29 -2
View File
@@ -1,7 +1,34 @@
import jwt from "jsonwebtoken";
import type { AccessToken } from "./store";
export function isTokenExpired(token: AccessToken) {
return token.expiresAt && Date.now() > token.expiresAt;
export function isTokenExpired(
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 {
+54 -1
View File
@@ -1,5 +1,6 @@
import jwt from "jsonwebtoken";
import { describe, expect, test } from "vite-plus/test";
import { extractCode } from "../src/util";
import { extractCode, isTokenExpired, jwtExpiresAt } from "../src/util";
describe("extractCode", () => {
test("extracts code from query when same origin + path", () => {
@@ -107,3 +108,55 @@ describe("extractCode", () => {
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();
});
});