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 {