Fix OAuth sign-in

This commit is contained in:
Konstantin Wohlwend 2026-05-26 13:13:59 -07:00 committed by Madison
parent c7a5c7be1f
commit 7ea807124c
3 changed files with 90 additions and 12 deletions

View File

@ -13,7 +13,7 @@ import { InvalidClientError, InvalidScopeError, Request as OAuthRequest, Respons
import { KnownError, KnownErrors } from "@stackframe/stack-shared";
import { yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { HexclaveAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
import { deindent, extractScopes } from "@stackframe/stack-shared/dist/utils/strings";
import { deindent, extractScopes, mergeScopeStrings } from "@stackframe/stack-shared/dist/utils/strings";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { oauthResponseToSmartResponse } from "../../oauth-helpers";
@ -224,12 +224,13 @@ const handler = createSmartRouteHandler({
});
const storeTokens = async (oauthAccountId: string) => {
const tokenScopes = extractScopes(mergeScopeStrings(providerObj.scope, providerScope ?? ""));
if (tokenSet.refreshToken) {
await prisma.oAuthToken.create({
data: {
tenancyId: outerInfo.tenancyId,
refreshToken: tokenSet.refreshToken,
scopes: extractScopes(providerObj.scope + " " + providerScope),
scopes: tokenScopes,
oauthAccountId,
}
});
@ -239,7 +240,7 @@ const handler = createSmartRouteHandler({
data: {
tenancyId: outerInfo.tenancyId,
accessToken: tokenSet.accessToken,
scopes: extractScopes(providerObj.scope + " " + providerScope),
scopes: tokenScopes,
expiresAt: tokenSet.accessTokenExpiredAt,
oauthAccountId,
}

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { getOAuthAccessTokenRefreshError, getOAuthAccessTokenRefreshErrorDisposition, isRetryableOAuthUserInfoError } from "./base";
import { getOAuthAccessTokenRefreshError, getOAuthAccessTokenRefreshErrorDisposition, isRetryableOAuthUserInfoError, resolveOAuthAccessTokenExpiredAt } from "./base";
describe("isRetryableOAuthUserInfoError", () => {
it("returns true for openid-client timeout errors", () => {
@ -101,3 +101,32 @@ describe("getOAuthAccessTokenRefreshError", () => {
});
});
});
describe("resolveOAuthAccessTokenExpiredAt", () => {
it("uses finite provider expires_in values", () => {
expect(resolveOAuthAccessTokenExpiredAt({
expiresInSeconds: 120,
expiresAtSeconds: undefined,
defaultExpiresInMillis: null,
nowMillis: 1000,
})?.toISOString()).toBe("1970-01-01T00:02:01.000Z");
});
it("ignores non-finite provider expires_at values and uses explicit null defaults", () => {
expect(resolveOAuthAccessTokenExpiredAt({
expiresInSeconds: undefined,
expiresAtSeconds: Number.NaN,
defaultExpiresInMillis: null,
nowMillis: 1000,
})).toBeNull();
});
it("ignores non-finite provider expiry values and falls back to one hour", () => {
expect(resolveOAuthAccessTokenExpiredAt({
expiresInSeconds: Number.NaN,
expiresAtSeconds: Number.NaN,
defaultExpiresInMillis: undefined,
nowMillis: 1000,
})?.toISOString()).toBe("1970-01-01T01:00:01.000Z");
});
});

View File

@ -219,6 +219,48 @@ export function getOAuthAccessTokenRefreshError(error: unknown, options: {
type DefaultAccessTokenExpiresInMillis = number | null | ((tokenSet: OIDCTokenSet) => number | null | undefined);
function getFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function dateFromMillis(millis: number, context: string): Date {
const date = new Date(millis);
if (!Number.isFinite(date.getTime())) {
throw new HexclaveAssertionError(`Invalid OAuth access token expiry computed from ${context}`, { millis });
}
return date;
}
export function resolveOAuthAccessTokenExpiredAt(options: {
expiresInSeconds: unknown,
expiresAtSeconds: unknown,
defaultExpiresInMillis: number | null | undefined,
nowMillis: number,
}): Date | null {
const expiresInSeconds = getFiniteNumber(options.expiresInSeconds);
if (expiresInSeconds !== undefined) {
return dateFromMillis(options.nowMillis + expiresInSeconds * 1000, "expires_in");
}
const expiresAtSeconds = getFiniteNumber(options.expiresAtSeconds);
if (expiresAtSeconds !== undefined) {
return dateFromMillis(expiresAtSeconds * 1000, "expires_at");
}
if (options.defaultExpiresInMillis === null) {
return null;
}
if (options.defaultExpiresInMillis !== undefined) {
if (!Number.isFinite(options.defaultExpiresInMillis)) {
throw new HexclaveAssertionError("Invalid default OAuth access token expiry", { defaultExpiresInMillis: options.defaultExpiresInMillis });
}
return dateFromMillis(options.nowMillis + options.defaultExpiresInMillis, "provider default");
}
return dateFromMillis(options.nowMillis + 3600 * 1000, "generic fallback");
}
function processTokenSet(providerName: string, tokenSet: OIDCTokenSet, defaultAccessTokenExpiresInMillis?: DefaultAccessTokenExpiresInMillis): TokenSet {
if (!tokenSet.access_token) {
throw new HexclaveAssertionError(`No access token received from ${providerName}.`, { tokenSet, providerName });
@ -230,7 +272,14 @@ function processTokenSet(providerName: string, tokenSet: OIDCTokenSet, defaultAc
// one-hour fallback and capture telemetry.
const defaultExpiresInMillis = typeof defaultAccessTokenExpiresInMillis === "function" ? defaultAccessTokenExpiresInMillis(tokenSet) : defaultAccessTokenExpiresInMillis;
if (tokenSet.expires_in == null && tokenSet.expires_at == null && defaultExpiresInMillis === undefined) {
const hasInvalidProviderExpiry =
(tokenSet.expires_in != null && getFiniteNumber(tokenSet.expires_in) === undefined)
|| (tokenSet.expires_at != null && getFiniteNumber(tokenSet.expires_at) === undefined);
if (hasInvalidProviderExpiry) {
captureError("processTokenSet", new HexclaveAssertionError(`Invalid expires_in or expires_at received from OAuth provider ${providerName}. Falling back to provider/default expiry handling`, { tokenSetKeys: Object.keys(tokenSet) }));
}
if (getFiniteNumber(tokenSet.expires_in) === undefined && getFiniteNumber(tokenSet.expires_at) === undefined && defaultExpiresInMillis === undefined) {
captureError("processTokenSet", new HexclaveAssertionError(`No expires_in or expires_at received from OAuth provider ${providerName}. Falling back to 1h`, { tokenSetKeys: Object.keys(tokenSet) }));
}
@ -238,13 +287,12 @@ function processTokenSet(providerName: string, tokenSet: OIDCTokenSet, defaultAc
idToken: tokenSet.id_token,
accessToken: tokenSet.access_token,
refreshToken: tokenSet.refresh_token,
accessTokenExpiredAt: tokenSet.expires_in != null ?
new Date(Date.now() + tokenSet.expires_in * 1000) :
tokenSet.expires_at != null ? new Date(tokenSet.expires_at * 1000) :
defaultExpiresInMillis === null ? null :
defaultExpiresInMillis !== undefined ?
new Date(Date.now() + defaultExpiresInMillis) :
new Date(Date.now() + 3600 * 1000),
accessTokenExpiredAt: resolveOAuthAccessTokenExpiredAt({
expiresInSeconds: tokenSet.expires_in,
expiresAtSeconds: tokenSet.expires_at,
defaultExpiresInMillis,
nowMillis: Date.now(),
}),
};
}