diff --git a/apps/backend/src/lib/tokens.tsx b/apps/backend/src/lib/tokens.tsx index 64bf5c15b..9322f746e 100644 --- a/apps/backend/src/lib/tokens.tsx +++ b/apps/backend/src/lib/tokens.tsx @@ -443,7 +443,7 @@ export async function createRefreshTokenObj(options: CreateRefreshTokenOptions) const refreshToken = generateSecureRandomString(); - const scopes = options.scopes ? parseScopeString(scopesToString(options.scopes)) : []; + const scopes = options.scopes ? [...new Set(options.scopes)] : []; const refreshTokenObj = await globalPrismaClient.projectUserRefreshToken.create({ data: { diff --git a/packages/stack-shared/src/crud.tsx b/packages/stack-shared/src/crud.tsx index 80d640ea8..7df0b9a2b 100644 --- a/packages/stack-shared/src/crud.tsx +++ b/packages/stack-shared/src/crud.tsx @@ -27,8 +27,16 @@ type ShownEndpointDocumentation = { description: string, tags?: string[], crudOperation?: Capitalize, - // Scopes a client access token must hold to call this endpoint. Enforced centrally in the - // smart route handler (server/admin keys bypass). See `packages/stack-shared/src/scopes.ts`. + // Scopes that a *scoped* client access token must hold to call this endpoint. Enforced centrally + // in the smart route handler. See `packages/stack-shared/src/scopes.ts`. + // + // IMPORTANT — this is an opt-in *down-scoping* restriction, NOT a security boundary. The check is + // fail-open: server/admin secret keys bypass entirely, and a client token that carries no `scope` + // claim (every token minted before scopes existed, and any session created without an explicit + // `scope`) is treated as unrestricted and reaches the handler regardless of this field. Only + // tokens that explicitly declare scopes are constrained to the scopes they list. Do NOT rely on + // `requiredScopes` to keep callers out of an endpoint — use access type / permissions for that. + // // Omitted / undefined means "no scope required". An empty array means the same, but documents // that the absence of a requirement was deliberate (useful for the scope-coverage test). requiredScopes?: Scope[], diff --git a/packages/stack-shared/src/scopes.ts b/packages/stack-shared/src/scopes.ts index 9a590411e..e8c3edfdc 100644 --- a/packages/stack-shared/src/scopes.ts +++ b/packages/stack-shared/src/scopes.ts @@ -30,7 +30,7 @@ export const SCOPES = { export type Scope = keyof typeof SCOPES; -export const ALL_SCOPES = Object.keys(SCOPES) as Scope[]; +export const ALL_SCOPES: readonly Scope[] = Object.keys(SCOPES) as Scope[]; export function isScope(value: string): value is Scope { return Object.prototype.hasOwnProperty.call(SCOPES, value);