mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Fix OAuth sign-in
This commit is contained in:
parent
c7a5c7be1f
commit
7ea807124c
@ -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,
|
||||
}
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user