From 7ea807124ce9fe78dc5760fe6ffaf18a68b4f547 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 26 May 2026 13:13:59 -0700 Subject: [PATCH] Fix OAuth sign-in --- .../oauth/callback/[provider_id]/route.tsx | 7 +- apps/backend/src/oauth/providers/base.test.ts | 31 ++++++++- apps/backend/src/oauth/providers/base.tsx | 64 ++++++++++++++++--- 3 files changed, 90 insertions(+), 12 deletions(-) diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx index d6195fada..3e7613c3b 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx @@ -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, } diff --git a/apps/backend/src/oauth/providers/base.test.ts b/apps/backend/src/oauth/providers/base.test.ts index e17514d01..f732f6d46 100644 --- a/apps/backend/src/oauth/providers/base.test.ts +++ b/apps/backend/src/oauth/providers/base.test.ts @@ -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"); + }); +}); diff --git a/apps/backend/src/oauth/providers/base.tsx b/apps/backend/src/oauth/providers/base.tsx index e50f32835..53e077880 100644 --- a/apps/backend/src/oauth/providers/base.tsx +++ b/apps/backend/src/oauth/providers/base.tsx @@ -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(), + }), }; }