From 42b22d4c0735fbd2c90fdf27b48d30ce8106ad2f Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 14 Jul 2026 08:24:07 -0700 Subject: [PATCH] Fall back to JWT exp claim when OAuth token response has no expires_in (#506) --- .../src/getOrRefreshAccessToken.ts | 6 +- .../src/grants/authorizationCode.ts | 1 + plugins/auth-oauth2/src/grants/implicit.ts | 6 +- plugins/auth-oauth2/src/store.ts | 6 +- plugins/auth-oauth2/src/util.ts | 31 ++++++++++- plugins/auth-oauth2/tests/util.test.ts | 55 ++++++++++++++++++- 6 files changed, 96 insertions(+), 9 deletions(-) diff --git a/plugins/auth-oauth2/src/getOrRefreshAccessToken.ts b/plugins/auth-oauth2/src/getOrRefreshAccessToken.ts index e63ce5f8..eb2843f3 100644 --- a/plugins/auth-oauth2/src/getOrRefreshAccessToken.ts +++ b/plugins/auth-oauth2/src/getOrRefreshAccessToken.ts @@ -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 { @@ -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); } diff --git a/plugins/auth-oauth2/src/grants/authorizationCode.ts b/plugins/auth-oauth2/src/grants/authorizationCode.ts index 220a1002..a015b01b 100644 --- a/plugins/auth-oauth2/src/grants/authorizationCode.ts +++ b/plugins/auth-oauth2/src/grants/authorizationCode.ts @@ -67,6 +67,7 @@ export async function getAuthorizationCode( clientId, clientSecret, credentialsInBody, + tokenName, }); if (token != null) { return token; diff --git a/plugins/auth-oauth2/src/grants/implicit.ts b/plugins/auth-oauth2/src/grants/implicit.ts index af0cd06a..d9b69766 100644 --- a/plugins/auth-oauth2/src/grants/implicit.ts +++ b/plugins/auth-oauth2/src/grants/implicit.ts @@ -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); } diff --git a/plugins/auth-oauth2/src/store.ts b/plugins/auth-oauth2/src/store.ts index 44bf3fd2..49752253 100644 --- a/plugins/auth-oauth2/src/store.ts +++ b/plugins/auth-oauth2/src/store.ts @@ -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, diff --git a/plugins/auth-oauth2/src/util.ts b/plugins/auth-oauth2/src/util.ts index dfafffaf..0dd55290 100644 --- a/plugins/auth-oauth2/src/util.ts +++ b/plugins/auth-oauth2/src/util.ts @@ -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 { diff --git a/plugins/auth-oauth2/tests/util.test.ts b/plugins/auth-oauth2/tests/util.test.ts index a5f0d8ec..7e9de0d3 100644 --- a/plugins/auth-oauth2/tests/util.test.ts +++ b/plugins/auth-oauth2/tests/util.test.ts @@ -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(); + }); +});