diff --git a/apps/backend/prisma/migrations/20260601000000_add_scope_to_refresh_tokens/migration.sql b/apps/backend/prisma/migrations/20260601000000_add_scope_to_refresh_tokens/migration.sql new file mode 100644 index 000000000..4bd639375 --- /dev/null +++ b/apps/backend/prisma/migrations/20260601000000_add_scope_to_refresh_tokens/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +-- Adding a nullable column with no default is a metadata-only change in Postgres (no table +-- rewrite), so this is safe even on a ProjectUserRefreshToken table with many millions of rows. +ALTER TABLE "ProjectUserRefreshToken" ADD COLUMN "scope" TEXT; diff --git a/apps/backend/prisma/migrations/20260601000000_add_scope_to_refresh_tokens/tests/nullable-scope.ts b/apps/backend/prisma/migrations/20260601000000_add_scope_to_refresh_tokens/tests/nullable-scope.ts new file mode 100644 index 000000000..e1bd2eb63 --- /dev/null +++ b/apps/backend/prisma/migrations/20260601000000_add_scope_to_refresh_tokens/tests/nullable-scope.ts @@ -0,0 +1,50 @@ +import { randomUUID } from 'crypto'; +import type { Sql } from 'postgres'; +import { expect } from 'vitest'; + +export const preMigration = async (sql: Sql) => { + const projectId = `test-${randomUUID()}`; + const tenancyId = randomUUID(); + + await sql`INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") VALUES (${projectId}, NOW(), NOW(), 'Test', '', false)`; + await sql`INSERT INTO "Tenancy" ("id", "createdAt", "updatedAt", "projectId", "branchId", "hasNoOrganization") VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue")`; + + const projectUserId = randomUUID(); + await sql` + INSERT INTO "ProjectUser" ("tenancyId", "projectUserId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt") + VALUES (${tenancyId}::uuid, ${projectUserId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW()) + `; + + const refreshTokenId = randomUUID(); + const refreshToken = `rt-${randomUUID()}`; + await sql` + INSERT INTO "ProjectUserRefreshToken" ("id", "tenancyId", "projectUserId", "createdAt", "updatedAt", "lastActiveAt", "refreshToken") + VALUES (${refreshTokenId}::uuid, ${tenancyId}::uuid, ${projectUserId}::uuid, NOW(), NOW(), NOW(), ${refreshToken}) + `; + + return { tenancyId, refreshTokenId }; +}; + +export const postMigration = async (sql: Sql, ctx: Awaited>) => { + // Existing rows get a NULL scope (= unrestricted session) + const rows = await sql` + SELECT "scope" + FROM "ProjectUserRefreshToken" + WHERE "id" = ${ctx.refreshTokenId}::uuid AND "tenancyId" = ${ctx.tenancyId}::uuid + `; + expect(rows).toHaveLength(1); + expect(rows[0].scope).toBeNull(); + + // The column accepts a space-separated scope string + await sql` + UPDATE "ProjectUserRefreshToken" + SET "scope" = ${'users:read teams:read'} + WHERE "id" = ${ctx.refreshTokenId}::uuid AND "tenancyId" = ${ctx.tenancyId}::uuid + `; + const updated = await sql` + SELECT "scope" + FROM "ProjectUserRefreshToken" + WHERE "id" = ${ctx.refreshTokenId}::uuid AND "tenancyId" = ${ctx.tenancyId}::uuid + `; + expect(updated[0].scope).toBe('users:read teams:read'); +}; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 1b4d8544f..47fa5f8de 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -650,6 +650,11 @@ model ProjectUserRefreshToken { expiresAt DateTime? isImpersonation Boolean @default(false) + // Space-separated list of scopes granted to this session (OAuth `scope` convention). Null + // means the session is unrestricted (the default), so access tokens minted from it omit the + // `scope` claim and pass every scope check. See `packages/stack-shared/src/scopes.ts`. + scope String? + sequenceId BigInt? @unique shouldUpdateSequenceId Boolean @default(true) diff --git a/apps/backend/src/app/api/latest/auth/sessions/route.tsx b/apps/backend/src/app/api/latest/auth/sessions/route.tsx index 91c2c3f87..490f55b42 100644 --- a/apps/backend/src/app/api/latest/auth/sessions/route.tsx +++ b/apps/backend/src/app/api/latest/auth/sessions/route.tsx @@ -3,6 +3,7 @@ import { createAuthTokens } from "@/lib/tokens"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@hexclave/shared"; import { adaptSchema, serverOrHigherAuthTypeSchema, userIdOrMeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; +import { isScope, parseScopeString } from "@hexclave/shared/dist/scopes"; import { usersCrudHandlers } from "../../users/crud"; import { sessionsCrudHandlers } from "./crud"; @@ -21,6 +22,10 @@ export const POST = createSmartRouteHandler({ user_id: userIdOrMeSchema.defined(), expires_in_millis: yupNumber().max(1000 * 60 * 60 * 24 * 367).default(1000 * 60 * 60 * 24 * 365), is_impersonation: yupBoolean().optional(), + // Space-separated list of scopes to restrict the created session to (OAuth `scope` + // convention). Omitted = unrestricted session. Access tokens minted from this session + // carry these scopes and are gated by each endpoint's `requiredScopes`. See `scopes.ts`. + scope: yupString().optional(), }).defined(), }), response: yupObject({ @@ -31,7 +36,15 @@ export const POST = createSmartRouteHandler({ access_token: yupString().defined(), }).defined(), }), - async handler({ auth: { tenancy }, body: { user_id: userId, expires_in_millis: expiresInMillis, is_impersonation: isImpersonation } }, fullReq) { + async handler({ auth: { tenancy }, body: { user_id: userId, expires_in_millis: expiresInMillis, is_impersonation: isImpersonation, scope } }, fullReq) { + // Validate requested scopes against the registry up front so callers get a clear error + // instead of silently minting a token with a bogus scope that can never satisfy any endpoint. + const requestedScopes = parseScopeString(scope); + const unknownScopes = requestedScopes.filter((s) => !isScope(s)); + if (unknownScopes.length > 0) { + throw new KnownErrors.SchemaError(`Unknown scope(s): ${unknownScopes.map((s) => `'${s}'`).join(", ")}.`); + } + let user; try { user = await usersCrudHandlers.adminRead({ @@ -54,6 +67,7 @@ export const POST = createSmartRouteHandler({ expiresAt: new Date(Date.now() + expiresInMillis), isImpersonation: isImpersonation, apiUrl: getApiUrlForRequest(fullReq), + scopes: requestedScopes, }); return { diff --git a/apps/backend/src/lib/openapi.tsx b/apps/backend/src/lib/openapi.tsx index 95582b32e..b16e5adef 100644 --- a/apps/backend/src/lib/openapi.tsx +++ b/apps/backend/src/lib/openapi.tsx @@ -475,6 +475,8 @@ export function parseOverload(options: { } } + const requiredScopes = endpointDocumentation.requiredScopes ?? []; + return { summary: endpointDocumentation.summary, description: endpointDocumentation.description, @@ -482,6 +484,9 @@ export function parseOverload(options: { requestBody, tags: endpointDocumentation.tags ?? ["Others"], 'x-full-url': `https://api.hexclave.com/api/v1${options.path}`, + // Vendor extension: scopes a client access token must hold to call this endpoint. Emitted + // as a generated catalog so the scope requirements stay in sync with the code declarations. + ...requiredScopes.length > 0 ? { 'x-required-scopes': requiredScopes } : {}, responses: allResponses, }; } diff --git a/apps/backend/src/lib/tokens.tsx b/apps/backend/src/lib/tokens.tsx index 01fa4f5ad..64bf5c15b 100644 --- a/apps/backend/src/lib/tokens.tsx +++ b/apps/backend/src/lib/tokens.tsx @@ -3,13 +3,14 @@ import { withExternalDbSyncUpdate } from '@/lib/external-db-sync'; import { getPrismaClientForTenancy, globalPrismaClient } from '@/prisma-client'; import { KnownErrors } from '@hexclave/shared'; import type { RestrictedReason } from "@hexclave/shared/dist/schema-fields"; -import { restrictedReasonSchema, yupBoolean, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; +import { restrictedReasonSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; import { AccessTokenPayload } from '@hexclave/shared/dist/sessions'; import { generateSecureRandomString } from '@hexclave/shared/dist/utils/crypto'; import { getEnvVariable } from '@hexclave/shared/dist/utils/env'; import { captureError, HexclaveAssertionError, throwErr } from '@hexclave/shared/dist/utils/errors'; import { getPrivateJwks, getPublicJwkSet, signJWT, verifyJWT } from '@hexclave/shared/dist/utils/jwt'; import { Result } from '@hexclave/shared/dist/utils/results'; +import { parseScopeString, scopesToString } from '@hexclave/shared/dist/scopes'; import { traceSpan } from '@hexclave/shared/dist/utils/telemetry'; import { turnstileResultValues } from '@hexclave/shared/dist/utils/turnstile'; import * as jose from 'jose'; @@ -30,6 +31,8 @@ const accessTokenSchema = yupObject({ isAnonymous: yupBoolean().defined(), isRestricted: yupBoolean().defined(), restrictedReason: restrictedReasonSchema.nullable().defined(), + // Scopes granted to this token. Empty array = unrestricted (token carried no `scope` claim). + scopes: yupArray(yupString().defined()).defined(), }).defined(); export const oauthCookieSchema = yupObject({ @@ -212,6 +215,7 @@ export async function decodeAccessToken(accessToken: string, { allowAnonymous, a isAnonymous, isRestricted, restrictedReason, + scopes: parseScopeString(payload.scope as string | undefined), }); return Result.ok(result); @@ -224,6 +228,9 @@ type RefreshTokenOptions = { projectUserId: string, id: string, expiresAt: Date | null, + // Space-separated scope string stored on the session; null = unrestricted. Mirrored into + // the access token's `scope` claim on every refresh so scopes survive token rolls. + scope?: string | null, }, }; @@ -364,6 +371,10 @@ export async function generateAccessTokenFromRefreshTokenIfValid(options: Genera } ); + // Normalize the session's stored scopes; a null/empty scope column means an unrestricted + // session, in which case we omit the `scope` claim entirely so the token is unrestricted. + const sessionScopes = parseScopeString(options.refreshTokenObj.scope); + const payload: Omit = { sub: options.refreshTokenObj.projectUserId, project_id: options.tenancy.project.id, @@ -379,6 +390,7 @@ export async function generateAccessTokenFromRefreshTokenIfValid(options: Genera is_restricted: user.is_restricted, restricted_reason: user.restricted_reason, requires_totp_mfa: user.requires_totp_mfa, + ...sessionScopes.length > 0 ? { scope: scopesToString(sessionScopes) } : {}, }; // Validate the payload matches the accessTokenSchema before signing, to catch inconsistencies early @@ -392,6 +404,7 @@ export async function generateAccessTokenFromRefreshTokenIfValid(options: Genera isAnonymous: user.is_anonymous, isRestricted: user.is_restricted, restrictedReason: user.restricted_reason, + scopes: sessionScopes, }); } catch (error) { captureError("generated-access-token-payload-does-not-fit-the-access-token-schema", new HexclaveAssertionError("Generated access token payload does not fit the accessTokenSchema. This is a bug — the token data is inconsistent.", { cause: error, payload })); @@ -411,6 +424,11 @@ type CreateRefreshTokenOptions = { projectUserId: string, expiresAt?: Date, isImpersonation?: boolean, + // Scopes to grant this session. Omitted/empty = unrestricted (stored as null). The granted + // scopes are persisted on the refresh-token row and minted into every access token derived + // from it. Callers wanting a downscoped token should intersect their requested scopes with + // whatever the caller is actually allowed to grant (see `intersectScopes` in `scopes.ts`). + scopes?: string[], } type CreateAuthTokensOptions = CreateRefreshTokenOptions & { @@ -425,6 +443,8 @@ export async function createRefreshTokenObj(options: CreateRefreshTokenOptions) const refreshToken = generateSecureRandomString(); + const scopes = options.scopes ? parseScopeString(scopesToString(options.scopes)) : []; + const refreshTokenObj = await globalPrismaClient.projectUserRefreshToken.create({ data: { tenancyId: options.tenancy.id, @@ -432,6 +452,7 @@ export async function createRefreshTokenObj(options: CreateRefreshTokenOptions) refreshToken: refreshToken, expiresAt: options.expiresAt, isImpersonation: options.isImpersonation, + scope: scopes.length > 0 ? scopesToString(scopes) : null, }, }); diff --git a/apps/backend/src/route-handlers/crud-handler.tsx b/apps/backend/src/route-handlers/crud-handler.tsx index f38f7f543..28147de1f 100644 --- a/apps/backend/src/route-handlers/crud-handler.tsx +++ b/apps/backend/src/route-handlers/crud-handler.tsx @@ -276,6 +276,8 @@ export function createCrudHandlers< branchId: branchId, tenancy: tenancy, type: accessType, + // Programmatic (server-side) invocation is full-trust; no scope restriction. + scopes: [], }, }); }); diff --git a/apps/backend/src/route-handlers/cud-handler.tsx b/apps/backend/src/route-handlers/cud-handler.tsx index 742009ec2..5f29550bc 100644 --- a/apps/backend/src/route-handlers/cud-handler.tsx +++ b/apps/backend/src/route-handlers/cud-handler.tsx @@ -344,6 +344,8 @@ export function createCudHandlers< branchId: resolved.branchId, tenancy: resolved.tenancy, type: accessType, + // Programmatic (server-side) invocation is full-trust; no scope restriction. + scopes: [], }; if (directOperation === "read") { diff --git a/apps/backend/src/route-handlers/smart-request.tsx b/apps/backend/src/route-handlers/smart-request.tsx index f656f4c7c..59c352c48 100644 --- a/apps/backend/src/route-handlers/smart-request.tsx +++ b/apps/backend/src/route-handlers/smart-request.tsx @@ -27,6 +27,10 @@ export type SmartRequestAuth = { user?: UsersCrud["Admin"]["Read"] | undefined, type: "client" | "server" | "admin", refreshTokenId?: string, + // Scopes carried by the client access token (parsed from its `scope` claim). Empty array = + // unrestricted (legacy tokens, or sessions with no scope restriction). Always empty for + // server/admin key auth, which is full-trust and bypasses scope checks. See `scopes.ts`. + scopes: string[], }; export type DeepPartialSmartRequestWithSentinel = (T extends object ? { @@ -203,6 +207,7 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque return { userId: result.data.userId, refreshTokenId: result.data.refreshTokenId, + scopes: result.data.scopes, }; }; @@ -246,7 +251,7 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque return user; }; - const { userId, refreshTokenId } = projectId && accessToken ? await extractUserIdAndRefreshTokenIdFromAccessToken({ token: accessToken, projectId, allowAnonymous: allowAnonymousUser, allowRestricted: allowRestrictedUser }) : { userId: null, refreshTokenId: null }; + const { userId, refreshTokenId, scopes } = projectId && accessToken ? await extractUserIdAndRefreshTokenIdFromAccessToken({ token: accessToken, projectId, allowAnonymous: allowAnonymousUser, allowRestricted: allowRestrictedUser }) : { userId: null, refreshTokenId: null, scopes: [] as string[] }; // Prisma does a query for every function call by default, even if we batch them with transactions // Because smart route handlers are always called, we instead send over a single raw query that fetches all the @@ -334,6 +339,7 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque tenancy, user: user ?? undefined, type: requestType, + scopes, }; }); diff --git a/apps/backend/src/route-handlers/smart-route-handler.tsx b/apps/backend/src/route-handlers/smart-route-handler.tsx index 33eace284..3585d777b 100644 --- a/apps/backend/src/route-handlers/smart-route-handler.tsx +++ b/apps/backend/src/route-handlers/smart-route-handler.tsx @@ -4,6 +4,7 @@ import { recordRequestStats } from "@/lib/dev-request-stats"; import * as Sentry from "@sentry/nextjs"; import { EndpointDocumentation } from "@hexclave/shared/dist/crud"; import { KnownError, KnownErrors } from "@hexclave/shared/dist/known-errors"; +import { getMissingScopes } from "@hexclave/shared/dist/scopes"; import { generateSecureRandomString } from "@hexclave/shared/dist/utils/crypto"; import { getNodeEnvironment } from "@hexclave/shared/dist/utils/env"; import { HexclaveAssertionError, StatusError, captureError, errorToNiceString } from "@hexclave/shared/dist/utils/errors"; @@ -270,6 +271,18 @@ export function createSmartRouteHandler< const fullReq = reqsParsed[0][0][1]; const handler = reqsParsed[0][1]; + // Central scope enforcement. Scopes only constrain *client* access tokens; server/admin + // secret keys are full-trust and bypass. A token that carries no scopes (an empty list) is + // treated as unrestricted — this keeps every token issued before scopes existed working. + // Only tokens that explicitly carry scopes are restricted to the scopes they list. + const requiredScopes = handler.metadata?.requiredScopes ?? []; + if (requiredScopes.length > 0 && fullReq.auth?.type === "client" && fullReq.auth.scopes.length > 0) { + const missingScopes = getMissingScopes(requiredScopes, fullReq.auth.scopes); + if (missingScopes.length > 0) { + throw new KnownErrors.InsufficientScope(missingScopes); + } + } + if (shouldSetContext) { Sentry.setContext("stack-parsed-smart-request", smartReq as any); } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/scopes.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/scopes.test.ts new file mode 100644 index 000000000..55357a31d --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/scopes.test.ts @@ -0,0 +1,131 @@ +import { it } from "../../../../../../helpers"; +import { Auth, niceBackendFetch } from "../../../../../backend-helpers"; + +async function createScopedSession(userId: string, scope: string | undefined) { + const res = await niceBackendFetch("/api/v1/auth/sessions", { + accessType: "server", + method: "POST", + body: { + user_id: userId, + ...scope !== undefined ? { scope } : {}, + }, + }); + return res; +} + +it("mints a scoped access token and enforces it on annotated endpoints", async ({ expect }) => { + const { userId } = await Auth.Password.signUpWithEmail(); + + // Server mints a session restricted to `teams:read` only. + const sessionRes = await createScopedSession(userId, "teams:read"); + expect(sessionRes.status).toBe(200); + const accessToken = sessionRes.body.access_token; + + // The token holds `teams:read`, so listing teams (requiredScopes: ["teams:read"]) succeeds. + const listRes = await niceBackendFetch("/api/v1/teams", { + accessType: "client", + method: "GET", + query: { user_id: "me" }, + userAuth: { accessToken }, + }); + expect(listRes.status).toBe(200); + + // The token does NOT hold `teams:write`, so creating a team (requiredScopes: ["teams:write"]) + // is rejected centrally with INSUFFICIENT_SCOPE before the handler ever runs. + const createRes = await niceBackendFetch("/api/v1/teams", { + accessType: "client", + method: "POST", + body: { display_name: "Scoped Test Team" }, + userAuth: { accessToken }, + }); + expect(createRes).toMatchInlineSnapshot(` + NiceResponse { + "status": 403, + "body": { + "code": "INSUFFICIENT_SCOPE", + "details": { "missing_scopes": ["teams:write"] }, + "error": "The access token is missing the following required scope(s): 'teams:write'. Mint a token that includes these scopes and try again.", + }, + "headers": Headers { + "x-stack-known-error": "INSUFFICIENT_SCOPE", +