mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Add scoped JWT tokens with endpoint-level scope enforcement
Co-Authored-By: mantra <mantra@stack-auth.com>
This commit is contained in:
parent
ce98c44fcf
commit
2fd0801e0a
@ -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;
|
||||
@ -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');
|
||||
};
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -276,6 +276,8 @@ export function createCrudHandlers<
|
||||
branchId: branchId,
|
||||
tenancy: tenancy,
|
||||
type: accessType,
|
||||
// Programmatic (server-side) invocation is full-trust; no scope restriction.
|
||||
scopes: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
@ -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>)
|
||||
|
||||
@ -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"],
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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' } });
|
||||
|
||||
77
packages/stack-shared/src/scopes.test.ts
Normal file
77
packages/stack-shared/src/scopes.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
74
packages/stack-shared/src/scopes.ts
Normal file
74
packages/stack-shared/src/scopes.ts
Normal 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));
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user