Add scoped JWT tokens with endpoint-level scope enforcement

Co-Authored-By: mantra <mantra@stack-auth.com>
This commit is contained in:
Devin AI 2026-06-01 20:36:05 +00:00
parent ce98c44fcf
commit 2fd0801e0a
18 changed files with 441 additions and 3 deletions

View File

@ -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;

View File

@ -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<ReturnType<typeof preMigration>>) => {
// 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');
};

View File

@ -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)

View File

@ -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 {

View File

@ -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,
};
}

View File

@ -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<AccessTokenPayload, "iss" | "aud" | "iat"> = {
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,
},
});

View File

@ -276,6 +276,8 @@ export function createCrudHandlers<
branchId: branchId,
tenancy: tenancy,
type: accessType,
// Programmatic (server-side) invocation is full-trust; no scope restriction.
scopes: [],
},
});
});

View File

@ -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") {

View File

@ -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 = SmartRequest> = (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,
};
});

View File

@ -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);
}

View File

@ -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",
<some fields may have been hidden>,
},
}
`);
});
it("treats unrestricted (no-scope) sessions as unrestricted for scoped endpoints", async ({ expect }) => {
const { userId } = await Auth.Password.signUpWithEmail();
// A session minted without any scope is unrestricted (fail-open / backwards-compatible).
const sessionRes = await createScopedSession(userId, undefined);
expect(sessionRes.status).toBe(200);
const accessToken = sessionRes.body.access_token;
// Reading is allowed.
const listRes = await niceBackendFetch("/api/v1/teams", {
accessType: "client",
method: "GET",
query: { user_id: "me" },
userAuth: { accessToken },
});
expect(listRes.status).toBe(200);
// Writing is NOT blocked by scope enforcement (the unrestricted token carries no scope claim),
// so the create request reaches the handler and is not an INSUFFICIENT_SCOPE error.
const createRes = await niceBackendFetch("/api/v1/teams", {
accessType: "client",
method: "POST",
body: { display_name: "Unrestricted Test Team" },
userAuth: { accessToken },
});
expect(createRes.body.code).not.toBe("INSUFFICIENT_SCOPE");
});
it("persists scopes across a token refresh", async ({ expect }) => {
const { userId } = await Auth.Password.signUpWithEmail();
const sessionRes = await createScopedSession(userId, "teams:read");
expect(sessionRes.status).toBe(200);
const refreshToken = sessionRes.body.refresh_token;
// Roll the access token via the refresh endpoint; the scope must survive the refresh because it
// is stored on the refresh-token row, not just baked into the original access token.
const refreshRes = await niceBackendFetch("/api/v1/auth/sessions/current/refresh", {
accessType: "client",
method: "POST",
headers: { "x-stack-refresh-token": refreshToken },
});
expect(refreshRes.status).toBe(200);
const refreshedAccessToken = refreshRes.body.access_token;
// The freshly minted token is still restricted to `teams:read`.
const createRes = await niceBackendFetch("/api/v1/teams", {
accessType: "client",
method: "POST",
body: { display_name: "Refreshed Scoped Team" },
userAuth: { accessToken: refreshedAccessToken },
});
expect(createRes.status).toBe(403);
expect(createRes.body.code).toBe("INSUFFICIENT_SCOPE");
});
it("rejects creating a session with an unknown scope", async ({ expect }) => {
const { userId } = await Auth.Password.signUpWithEmail();
const sessionRes = await createScopedSession(userId, "teams:read not_a_real:scope");
expect(sessionRes).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "SCHEMA_ERROR",
"details": { "message": "Unknown scope(s): 'not_a_real:scope'." },
"error": "Unknown scope(s): 'not_a_real:scope'.",
},
"headers": Headers {
"x-stack-known-error": "SCHEMA_ERROR",
<some fields may have been hidden>,
},
}
`);
});

View File

@ -1,5 +1,6 @@
import * as yup from 'yup';
import { yupObject, yupString } from './schema-fields';
import { Scope } from './scopes';
import { filterUndefined } from './utils/objects';
import { NullishCoalesce } from './utils/types';
@ -26,6 +27,11 @@ type ShownEndpointDocumentation = {
description: string,
tags?: string[],
crudOperation?: Capitalize<CrudlOperation>,
// 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`.
// 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[],
};
export type EndpointDocumentation =
| ({ hidden: true } & Partial<ShownEndpointDocumentation>)

View File

@ -48,26 +48,31 @@ export const contactChannelsCrud = createCrud({
summary: "Get a contact channel",
description: "Retrieves a specific contact channel by the user ID and the contact channel ID.",
tags: ["Contact Channels"],
requiredScopes: ["contact_channels:read"],
},
clientCreate: {
summary: "Create a contact channel",
description: "Add a new contact channel for a user.",
tags: ["Contact Channels"],
requiredScopes: ["contact_channels:write"],
},
clientUpdate: {
summary: "Update a contact channel",
description: "Updates an existing contact channel. Only the values provided will be updated.",
tags: ["Contact Channels"],
requiredScopes: ["contact_channels:write"],
},
clientDelete: {
summary: "Delete a contact channel",
description: "Removes a contact channel for a given user.",
tags: ["Contact Channels"],
requiredScopes: ["contact_channels:write"],
},
clientList: {
summary: "List contact channels",
description: "Retrieves a list of all contact channels for a user.",
tags: ["Contact Channels"],
requiredScopes: ["contact_channels:read"],
}
}
});

View File

@ -57,26 +57,31 @@ export const teamsCrud = createCrud({
summary: "List teams",
description: "List all the teams that the current user is a member of. `user_id=me` must be passed in the query parameters.",
tags: ["Teams"],
requiredScopes: ["teams:read"],
},
clientCreate: {
summary: "Create a team",
description: "Create a new team and optionally add the current user as a member.",
tags: ["Teams"],
requiredScopes: ["teams:write"],
},
clientRead: {
summary: "Get a team",
description: "Get a team that the current user is a member of.",
tags: ["Teams"],
requiredScopes: ["teams:read"],
},
clientUpdate: {
summary: "Update a team",
description: "Update the team information. Only allowed if the current user is a member of the team and has the `$update_team` permission.",
tags: ["Teams"],
requiredScopes: ["teams:write"],
},
clientDelete: {
summary: "Delete a team",
description: "Delete a team. Only allowed if the current user is a member of the team and has the `$delete_team` permission.",
tags: ["Teams"],
requiredScopes: ["teams:write"],
},
serverCreate: {
summary: "Create a team",

View File

@ -1379,6 +1379,19 @@ const TeamPermissionNotFound = createKnownErrorConstructor(
(json) => [json.team_id, json.user_id, json.permission_id] as const,
);
const InsufficientScope = createKnownErrorConstructor(
KnownError,
"INSUFFICIENT_SCOPE",
(missingScopes: string[]) => [
403,
`The access token is missing the following required scope(s): ${missingScopes.map(s => `'${s}'`).join(", ")}. Mint a token that includes these scopes and try again.`,
{
missing_scopes: missingScopes,
},
] as const,
(json: any) => [json.missing_scopes] as const,
);
const InvalidSharedOAuthProviderId = createKnownErrorConstructor(
KnownError,
"INVALID_SHARED_OAUTH_PROVIDER_ID",
@ -1980,6 +1993,7 @@ export const KnownErrors = {
TeamMembershipAlreadyExists,
ProjectPermissionRequired,
TeamPermissionRequired,
InsufficientScope,
InvalidSharedOAuthProviderId,
InvalidStandardOAuthProviderId,
InvalidAuthorizationCode,

View File

@ -794,6 +794,10 @@ export const accessTokenPayloadSchema = yupObject({
is_restricted: yupBoolean().defined(),
restricted_reason: restrictedReasonSchema.defined().nullable(),
requires_totp_mfa: yupBoolean().defined(),
// Space-separated list of scopes this token was granted (OAuth `scope` convention). Optional:
// tokens minted before scopes existed (and tokens for sessions with no scope restriction) omit
// it, in which case the token is treated as unrestricted. See `scopes.ts`.
scope: yupString().optional(),
});
export const signInEmailSchema = strictEmailSchema(undefined).meta({ openapiField: { description: 'The email to sign in with.', exampleValue: 'johndoe@example.com' } });
export const emailOtpSignInCallbackUrlSchema = urlSchema.meta({ openapiField: { description: 'The base callback URL to construct the magic link from. A query parameter `code` with the verification code will be appended to it. The page should then make a request to the `/auth/otp/sign-in` endpoint.', exampleValue: 'https://example.com/handler/magic-link-callback' } });

View File

@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { ALL_SCOPES, getMissingScopes, intersectScopes, isScope, parseScopeString, SCOPES, scopesToString } from "./scopes";
describe("scopes registry", () => {
it("ALL_SCOPES matches the keys of SCOPES", () => {
expect([...ALL_SCOPES].sort()).toEqual(Object.keys(SCOPES).sort());
});
it("isScope recognizes registered scopes and rejects others", () => {
expect(isScope("users:read")).toBe(true);
expect(isScope("teams:write")).toBe(true);
expect(isScope("not:a:scope")).toBe(false);
expect(isScope("")).toBe(false);
});
});
describe("parseScopeString", () => {
it("returns an empty array for null/undefined/empty", () => {
expect(parseScopeString(null)).toEqual([]);
expect(parseScopeString(undefined)).toEqual([]);
expect(parseScopeString("")).toEqual([]);
expect(parseScopeString(" ")).toEqual([]);
});
it("splits on spaces and drops empty segments", () => {
expect(parseScopeString("users:read teams:read")).toEqual(["users:read", "teams:read"]);
expect(parseScopeString(" users:read teams:read ")).toEqual(["users:read", "teams:read"]);
});
it("deduplicates", () => {
expect(parseScopeString("users:read users:read teams:read")).toEqual(["users:read", "teams:read"]);
});
it("preserves unknown scope strings (never silently widens or drops)", () => {
expect(parseScopeString("users:read legacy:scope")).toEqual(["users:read", "legacy:scope"]);
});
});
describe("scopesToString", () => {
it("joins with spaces and deduplicates", () => {
expect(scopesToString(["users:read", "teams:read"])).toBe("users:read teams:read");
expect(scopesToString(["users:read", "users:read"])).toBe("users:read");
expect(scopesToString([])).toBe("");
});
it("round-trips with parseScopeString", () => {
const scopes = ["users:read", "teams:write", "contact_channels:read"];
expect(parseScopeString(scopesToString(scopes))).toEqual(scopes);
});
});
describe("intersectScopes", () => {
it("keeps only requested scopes that are allowed", () => {
expect(intersectScopes(["users:read", "teams:write"], ["users:read", "teams:read"])).toEqual(["users:read"]);
});
it("returns empty when nothing overlaps", () => {
expect(intersectScopes(["users:write"], ["teams:read"])).toEqual([]);
});
it("can never grant a scope beyond the allowed set", () => {
expect(intersectScopes(["users:read", "users:write", "teams:write"], ["users:read"])).toEqual(["users:read"]);
});
});
describe("getMissingScopes", () => {
it("returns empty when all required scopes are present", () => {
expect(getMissingScopes(["users:read"], ["users:read", "teams:read"])).toEqual([]);
expect(getMissingScopes([], ["users:read"])).toEqual([]);
expect(getMissingScopes([], [])).toEqual([]);
});
it("returns the required scopes that are absent", () => {
expect(getMissingScopes(["users:read", "teams:write"], ["users:read"])).toEqual(["teams:write"]);
expect(getMissingScopes(["users:read", "teams:write"], [])).toEqual(["users:read", "teams:write"]);
});
});

View File

@ -0,0 +1,74 @@
/**
* Central registry of all API scopes.
*
* Scopes are a coarse, OAuth-style axis of access control that gate *which slice of the API
* surface a token is allowed to touch*. They are orthogonal to:
* - the access type (`client` / `server` / `admin`), which says *who* is calling, and
* - the permission system (`ensureUserTeamPermissionExists`, ...), which says *what a user
* may do to a specific resource*.
*
* A token can be a valid client token for a user who has the right permissions, yet still be
* downscoped so it can only call a subset of endpoints. Server/admin secret keys are
* full-trust and bypass scope checks entirely.
*
* This object is the single source of truth: endpoint declarations and token contents are
* type-checked against it, so you cannot reference a scope that does not exist here.
*/
export const SCOPES = {
"users:read": { description: "Read user profiles" },
"users:write": { description: "Create, update, or delete users" },
"teams:read": { description: "Read teams" },
"teams:write": { description: "Create, update, or delete teams" },
"team_memberships:read": { description: "Read team memberships" },
"team_memberships:write": { description: "Add or remove team members" },
"sessions:read": { description: "Read sessions" },
"sessions:write": { description: "Create or revoke sessions" },
"contact_channels:read": { description: "Read contact channels (emails, phone numbers)" },
"contact_channels:write": { description: "Create, update, or delete contact channels" },
"permissions:read": { description: "Read permissions and permission definitions" },
} as const;
export type Scope = keyof typeof SCOPES;
export const ALL_SCOPES = Object.keys(SCOPES) as Scope[];
export function isScope(value: string): value is Scope {
return Object.prototype.hasOwnProperty.call(SCOPES, value);
}
/**
* Parses a space-separated scope string (the OAuth `scope` convention, as stored in the JWT
* `scope` claim and the refresh-token row) into a deduplicated list of scope strings.
*
* Unknown scope strings are intentionally preserved rather than dropped: a token may have been
* minted before a scope was removed from the registry, and silently dropping it could widen
* (never narrow) what the token can do. Enforcement only ever checks subset membership, so an
* unknown scope simply never satisfies any current `requiredScopes` entry.
*/
export function parseScopeString(scopeString: string | null | undefined): string[] {
if (scopeString == null) return [];
return [...new Set(scopeString.split(" ").filter((s) => s.length > 0))];
}
export function scopesToString(scopes: readonly string[]): string {
return [...new Set(scopes)].join(" ");
}
/**
* Returns the subset of `requestedScopes` that are also present in `allowedScopes`. Used when
* minting a downscoped token: a caller can never be granted a scope beyond what the underlying
* session/grant already allows.
*/
export function intersectScopes(requestedScopes: readonly string[], allowedScopes: readonly string[]): string[] {
const allowedSet = new Set(allowedScopes);
return [...new Set(requestedScopes)].filter((s) => allowedSet.has(s));
}
/**
* Returns the scopes in `requiredScopes` that are missing from `tokenScopes`. An empty result
* means the token satisfies all required scopes.
*/
export function getMissingScopes(requiredScopes: readonly string[], tokenScopes: readonly string[]): string[] {
const tokenSet = new Set(tokenScopes);
return [...new Set(requiredScopes)].filter((s) => !tokenSet.has(s));
}