import { usersCrudHandlers } from '@/app/api/latest/users/crud'; import { withExternalDbSyncUpdate } from '@/lib/external-db-sync'; import { getPrismaClientForTenancy, globalPrismaClient } from '@/prisma-client'; import { KnownErrors } from '@stackframe/stack-shared'; import type { RestrictedReason } from "@stackframe/stack-shared/dist/schema-fields"; import { restrictedReasonSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { AccessTokenPayload } from '@stackframe/stack-shared/dist/sessions'; import { generateSecureRandomString } from '@stackframe/stack-shared/dist/utils/crypto'; import { getEnvVariable } from '@stackframe/stack-shared/dist/utils/env'; import { captureError, StackAssertionError, throwErr } from '@stackframe/stack-shared/dist/utils/errors'; import { getPrivateJwks, getPublicJwkSet, signJWT, verifyJWT } from '@stackframe/stack-shared/dist/utils/jwt'; import { Result } from '@stackframe/stack-shared/dist/utils/results'; import { traceSpan } from '@stackframe/stack-shared/dist/utils/telemetry'; import { turnstileResultValues } from '@stackframe/stack-shared/dist/utils/turnstile'; import * as jose from 'jose'; import { JOSEError, JWTExpired } from 'jose/errors'; import { getEndUserIpInfoForEvent, logEvent, SystemEventTypes } from './events'; import { Tenancy } from './tenancies'; export const authorizationHeaderSchema = yupString().matches(/^StackSession [^ ]+$/); const accessTokenSchema = yupObject({ projectId: yupString().defined(), userId: yupString().defined(), branchId: yupString().defined(), refreshTokenId: yupString().optional(), exp: yupNumber().defined(), isAnonymous: yupBoolean().defined(), isRestricted: yupBoolean().defined(), restrictedReason: restrictedReasonSchema.nullable().defined(), }).defined(); export const oauthCookieSchema = yupObject({ tenancyId: yupString().defined(), publishableClientKey: yupString().defined(), innerCodeVerifier: yupString().defined(), redirectUri: yupString().defined(), scope: yupString().defined(), state: yupString().defined(), grantType: yupString().defined(), codeChallenge: yupString().defined(), codeChallengeMethod: yupString().defined(), responseType: yupString().defined(), type: yupString().oneOf(['authenticate', 'link']).defined(), projectUserId: yupString().optional(), providerScope: yupString().optional(), errorRedirectUrl: yupString().optional(), afterCallbackRedirectUrl: yupString().optional(), // TODO next-release: make these .defined() once all deployments write these fields into the cookie turnstileResult: yupString().oneOf(turnstileResultValues).optional(), turnstileVisibleChallengeResult: yupString().oneOf(turnstileResultValues).optional(), responseMode: yupString().oneOf(['json', 'redirect']).optional(), }); type UserType = 'normal' | 'restricted' | 'anonymous'; const getIssuer = (projectId: string, userType: UserType) => { const suffix = userType === 'anonymous' ? '-anonymous-users' : userType === 'restricted' ? '-restricted-users' : ''; const url = new URL(`/api/v1/projects${suffix}/${projectId}`, getEnvVariable("NEXT_PUBLIC_STACK_API_URL")); return url.toString(); }; const getAudience = (projectId: string, userType: UserType) => { // TODO: make the audience a URL, and encode the user type in a better way return userType === 'anonymous' ? `${projectId}:anon` : userType === 'restricted' ? `${projectId}:restricted` : projectId; }; const getUserType = (isAnonymous: boolean, isRestricted: boolean): UserType => { if (isAnonymous) return 'anonymous'; if (isRestricted) return 'restricted'; return 'normal'; }; export async function getPublicProjectJwkSet(projectId: string, options: { allowRestricted: boolean, allowAnonymous: boolean }) { const privateJwks = [ ...await getPrivateJwks({ audience: getAudience(projectId, 'normal') }), ...options.allowRestricted ? await getPrivateJwks({ audience: getAudience(projectId, 'restricted') }) : [], ...options.allowAnonymous ? await getPrivateJwks({ audience: getAudience(projectId, 'anonymous') }) : [], ]; return await getPublicJwkSet(privateJwks); } export async function decodeAccessToken(accessToken: string, { allowAnonymous, allowRestricted }: { allowAnonymous: boolean, allowRestricted: boolean }) { return await traceSpan("decoding access token", async (span) => { if (allowAnonymous && !allowRestricted) { throw new StackAssertionError("If allowAnonymous is true, allowRestricted must also be true"); } let payload: jose.JWTPayload; let decoded: jose.JWTPayload | undefined; let aud; try { decoded = jose.decodeJwt(accessToken); aud = decoded.aud?.toString() ?? ""; // Determine allowed issuers based on what types of tokens we accept const projectId = aud.split(":")[0]; const allowedIssuers = [ getIssuer(projectId, 'normal'), ...(allowRestricted ? [getIssuer(projectId, 'restricted')] : []), ...(allowAnonymous ? [getIssuer(projectId, 'anonymous')] : []), ]; payload = await verifyJWT({ allowedIssuers, jwt: accessToken, }); } catch (error) { if (error instanceof JWTExpired) { const error = new KnownErrors.AccessTokenExpired( decoded?.exp ? new Date(decoded.exp * 1000) : undefined, decoded?.aud?.toString().split(":")[0], decoded?.sub ?? undefined, (decoded?.refresh_token_id ?? decoded?.refreshTokenId) as string | undefined, ); console.log(`[Token decode] Access token expired for project ${decoded?.aud?.toString().split(":")[0]}, user ${decoded?.sub}. This is most likely not an issue, but if it happens frequently, it may be a sign of a misconfiguration.`, error); return Result.error(error); } else if (error instanceof JOSEError) { console.warn("Unparsable access token. This might be a user error, but if it happens frequently, it's a sign of a misconfiguration.", { accessToken, error }); return Result.error(new KnownErrors.UnparsableAccessToken()); } throw error; } // TODO next-release: Delete the legacy behavior from here const isAnonymous = payload.is_anonymous as boolean; // Legacy tokens default to non-restricted; also, anonymous users are always restricted const isRestricted = (payload.is_restricted as boolean | undefined) ?? isAnonymous; // For legacy anonymous tokens, infer restrictedReason as { type: "anonymous" } const restrictedReason = (payload.restricted_reason as RestrictedReason | null | undefined) ?? (isAnonymous ? { type: "anonymous" as const } : null); // Anonymous users must be restricted if (isAnonymous && !isRestricted) { throw new StackAssertionError("Unparsable access token. User is anonymous but not restricted.", { accessToken, payload }); } // Enforce consistency between isRestricted and restrictedReason if (isRestricted && !restrictedReason) { throw new StackAssertionError("Unparsable access token. User is restricted but restrictedReason is missing.", { accessToken, payload }); } if (!isRestricted && restrictedReason) { throw new StackAssertionError("Unparsable access token. User is not restricted but restrictedReason is present.", { accessToken, payload }); } // Validate audience matches the user type if (aud.endsWith(":anon") && !isAnonymous) { throw new StackAssertionError("Unparsable access token. Audience is an anonymous audience, but user is not anonymous.", { accessToken, payload }); } else if (!aud.endsWith(":anon") && isAnonymous) { throw new StackAssertionError("Unparsable access token. Audience is not an anonymous audience, but user is anonymous.", { accessToken, payload }); } if (aud.endsWith(":restricted") && !isRestricted) { throw new StackAssertionError("Unparsable access token. User is not restricted, but audience is a restricted audience.", { accessToken, payload }); } else if (!aud.endsWith(":restricted") && isRestricted && !isAnonymous) { throw new StackAssertionError("Unparsable access token. Audience is not a restricted audience, but user is restricted.", { accessToken, payload }); } const branchId = payload.branch_id ?? payload.branchId; if (branchId !== "main") { // TODO instead, we should check here that the aud is `projectId#branch` instead throw new StackAssertionError("Branch ID !== main not currently supported."); } const result = await accessTokenSchema.validate({ projectId: aud.split(":")[0], userId: payload.sub, branchId: branchId, refreshTokenId: payload.refresh_token_id ?? payload.refreshTokenId, exp: payload.exp, isAnonymous, isRestricted, restrictedReason, }); return Result.ok(result); }); } type RefreshTokenOptions = { tenancy: Tenancy, refreshTokenObj: null | { projectUserId: string, id: string, expiresAt: Date | null, }, }; /** * Validates a refresh token and returns the user if valid. * This function has NO side effects - it doesn't log events or update timestamps. * Use this when you just need to check validity without triggering analytics. * * @returns The user object if the token is valid, null otherwise. */ async function validateRefreshTokenAndGetUser(options: RefreshTokenOptions) { if (!options.refreshTokenObj) { return null; } if (options.refreshTokenObj.expiresAt && options.refreshTokenObj.expiresAt < new Date()) { return null; } try { const user = await usersCrudHandlers.adminRead({ tenancy: options.tenancy, user_id: options.refreshTokenObj.projectUserId, allowedErrorTypes: [KnownErrors.UserNotFound], }); return user; } catch (error) { if (error instanceof KnownErrors.UserNotFound) { // The user was deleted — their refresh token still exists because we don't cascade deletes across source-of-truth/global tables. // => refresh token is invalid return null; } throw error; } } /** * Checks if a refresh token is valid. */ export async function isRefreshTokenValid(options: RefreshTokenOptions) { return !!(await validateRefreshTokenAndGetUser(options)); } /** * Generates an access token from a refresh token if the token is valid. * * This function has side effects: * - Updates last active timestamps on the user and session * - Logs session activity and token refresh events for analytics * * @returns The access token string if valid, null otherwise. */ export async function generateAccessTokenFromRefreshTokenIfValid(options: RefreshTokenOptions) { const user = await validateRefreshTokenAndGetUser(options); if (!user || !options.refreshTokenObj) { return null; } // Update last active at on user and session const now = new Date(); const prisma = await getPrismaClientForTenancy(options.tenancy); // Get end user IP info for session tracking and event logging const ipInfo = await getEndUserIpInfoForEvent(); await Promise.all([ prisma.projectUser.update({ where: { tenancyId_projectUserId: { tenancyId: options.tenancy.id, projectUserId: options.refreshTokenObj.projectUserId, }, }, data: withExternalDbSyncUpdate({ lastActiveAt: now, }), }), globalPrismaClient.projectUserRefreshToken.update({ where: { tenancyId_id: { tenancyId: options.tenancy.id, id: options.refreshTokenObj.id, }, }, data: withExternalDbSyncUpdate({ lastActiveAt: now, lastActiveAtIpInfo: ipInfo ?? undefined, }), }), ]); // Log session activity event (used for metrics, geo info, etc.) await logEvent( [SystemEventTypes.SessionActivity], { projectId: options.tenancy.project.id, branchId: options.tenancy.branchId, userId: options.refreshTokenObj.projectUserId, sessionId: options.refreshTokenObj.id, isAnonymous: user.is_anonymous, teamId: undefined, } ); // Log token refresh event for ClickHouse analytics await logEvent( [SystemEventTypes.TokenRefresh], { projectId: options.tenancy.project.id, branchId: options.tenancy.branchId, userId: options.refreshTokenObj.projectUserId, refreshTokenId: options.refreshTokenObj.id, isAnonymous: user.is_anonymous, teamId: undefined, ipInfo, }, { refreshTokenId: options.refreshTokenObj.id, } ); const payload: Omit = { sub: options.refreshTokenObj.projectUserId, project_id: options.tenancy.project.id, branch_id: options.tenancy.branchId, refresh_token_id: options.refreshTokenObj.id, role: 'authenticated', name: user.display_name, email: user.primary_email, email_verified: user.primary_email_verified, selected_team_id: user.selected_team_id, signed_up_at: Math.floor(user.signed_up_at_millis / 1000), is_anonymous: user.is_anonymous, is_restricted: user.is_restricted, restricted_reason: user.restricted_reason, requires_totp_mfa: user.requires_totp_mfa, }; // Validate the payload matches the accessTokenSchema before signing, to catch inconsistencies early try { await accessTokenSchema.validate({ projectId: options.tenancy.project.id, userId: options.refreshTokenObj.projectUserId, branchId: options.tenancy.branchId, refreshTokenId: options.refreshTokenObj.id, exp: 0, // placeholder, actual exp is set by signJWT isAnonymous: user.is_anonymous, isRestricted: user.is_restricted, restrictedReason: user.restricted_reason, }); } catch (error) { captureError("generated-access-token-payload-does-not-fit-the-access-token-schema", new StackAssertionError("Generated access token payload does not fit the accessTokenSchema. This is a bug — the token data is inconsistent.", { cause: error, payload })); } const userType = getUserType(user.is_anonymous, user.is_restricted); return await signJWT({ issuer: getIssuer(options.tenancy.project.id, userType), audience: getAudience(options.tenancy.project.id, userType), expirationTime: getEnvVariable("STACK_ACCESS_TOKEN_EXPIRATION_TIME", "10min"), payload, }); } type CreateRefreshTokenOptions = { tenancy: Tenancy, projectUserId: string, expiresAt?: Date, isImpersonation?: boolean, } export async function createRefreshTokenObj(options: CreateRefreshTokenOptions) { options.expiresAt ??= new Date(Date.now() + 1000 * 60 * 60 * 24 * 365); options.isImpersonation ??= false; const refreshToken = generateSecureRandomString(); const refreshTokenObj = await globalPrismaClient.projectUserRefreshToken.create({ data: { tenancyId: options.tenancy.id, projectUserId: options.projectUserId, refreshToken: refreshToken, expiresAt: options.expiresAt, isImpersonation: options.isImpersonation, }, }); return refreshTokenObj; } export async function createAuthTokens(options: CreateRefreshTokenOptions) { const refreshTokenObj = await createRefreshTokenObj(options); const accessToken = await generateAccessTokenFromRefreshTokenIfValid({ tenancy: options.tenancy, refreshTokenObj: refreshTokenObj, }) ?? throwErr("Newly generated refresh token is not valid; this should never happen!", { refreshTokenObj }); return { refreshToken: refreshTokenObj.refreshToken, accessToken }; }