mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-15 09:12:56 +02:00
Fall back to JWT exp claim when OAuth token response has no expires_in (#506)
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user