Skip to content

Commit 284d852

Browse files
authored
Improved anonymous users (stack-auth#857)
1 parent 393170c commit 284d852

File tree

40 files changed

+1820
-255
lines changed

40 files changed

+1820
-255
lines changed

.claude/CLAUDE-KNOWLEDGE.md

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,131 @@
11
# CLAUDE-KNOWLEDGE.md
22

3-
This file documents key learnings from implementing wildcard domain support in Stack Auth, organized in Q&A format.
3+
This file contains knowledge learned while working on the codebase in Q&A format.
4+
5+
## Q: How do anonymous users work in Stack Auth?
6+
A: Anonymous users are a special type of user that can be created without any authentication. They have `isAnonymous: true` in the database and use different JWT signing keys with a `role: 'anon'` claim. Anonymous JWTs use a prefixed secret ("anon-" + audience) for signing and verification.
7+
8+
## Q: How are anonymous user JWTs different from regular user JWTs?
9+
A: Anonymous JWTs have:
10+
1. Different kid (key ID) - prefixed with "anon-" in the generation
11+
2. Different signing secret - uses `getPerAudienceSecret` with `isAnonymous: true`
12+
3. Contains `role: 'anon'` in the payload
13+
4. Must pass `isAnonymous` flag to both `getPrivateJwk` and `getPublicJwkSet` functions for proper verification
14+
15+
## Q: What is the X-Stack-Allow-Anonymous-User header?
16+
A: This header controls whether anonymous users are allowed to access an endpoint. When set to "true" (which is the default for client SDK calls), anonymous JWTs are accepted. When false or missing, anonymous users get an `AnonymousAuthenticationNotAllowed` error.
17+
18+
## Q: How do you upgrade an anonymous user to a regular user?
19+
A: When an anonymous user (identified by `is_anonymous: true`) signs up or signs in through any auth method (password, OTP, OAuth), instead of creating a new user, the system upgrades the existing anonymous user by:
20+
1. Setting `is_anonymous: false`
21+
2. Adding the authentication method (email, password, OAuth provider, etc.)
22+
3. Keeping the same user ID so old JWTs remain valid
23+
24+
## Q: How do you access the current user in smart route handlers?
25+
A: In smart route handlers, the user is accessed through `fullReq.auth?.user` not through the destructured `auth` parameter. The auth parameter only guarantees `tenancy`, while `user` is optional and needs to be accessed from the full request.
26+
27+
## Q: How do user CRUD handlers work with parameters?
28+
A: The `adminUpdate` and similar methods take parameters directly, not wrapped in a `params` object:
29+
- Correct: `adminUpdate({ tenancy, user_id: "...", data: {...} })`
30+
- Wrong: `adminUpdate({ tenancy, params: { user_id: "..." }, data: {...} })`
31+
32+
## Q: What query parameter filters anonymous users in user endpoints?
33+
A: The `include_anonymous` query parameter controls whether anonymous users are included in results:
34+
- Without parameter or `include_anonymous=false`: Anonymous users are filtered out
35+
- With `include_anonymous=true`: Anonymous users are included in results
36+
This applies to user list, get by ID, search, and team member endpoints.
37+
38+
## Q: How does the JWKS endpoint handle anonymous keys?
39+
A: The JWKS (JSON Web Key Set) endpoint at `/.well-known/jwks.json`:
40+
- By default: Returns only regular user signing keys
41+
- With `?include_anonymous=true`: Returns both regular and anonymous user signing keys
42+
This allows systems that need to verify anonymous JWTs to fetch the appropriate public keys.
43+
44+
## Q: What is the typical test command flow for Stack Auth?
45+
A:
46+
1. `pnpm typecheck` - Check TypeScript compilation
47+
2. `pnpm lint --fix` - Fix linting issues
48+
3. `pnpm test run <path>` - Run specific tests (the `run` is important to avoid watch mode)
49+
4. Use `-t "test name"` to run specific tests by name
50+
51+
## Q: How do E2E tests handle authentication in Stack Auth?
52+
A: E2E tests use `niceBackendFetch` which automatically:
53+
- Sets `x-stack-allow-anonymous-user: "true"` for client access type
54+
- Includes project keys and tokens from `backendContext.value`
55+
- Handles auth tokens through the context rather than manual header setting
56+
57+
## Q: What is the signature of a verification code handler?
58+
A: The handler function in `createVerificationCodeHandler` receives 5 parameters:
59+
```typescript
60+
async handler(tenancy, validatedMethod, validatedData, requestBody, currentUser)
61+
```
62+
Where:
63+
- `tenancy` - The tenancy object
64+
- `validatedMethod` - The validated method data (e.g., `{ email: "..." }`)
65+
- `validatedData` - The validated data object
66+
- `requestBody` - The raw request body
67+
- `currentUser` - The current authenticated user (if any)
68+
69+
## Q: How does JWT key derivation work for anonymous users?
70+
A: The JWT signing/verification uses a multi-step key derivation process:
71+
1. **Secret Derivation**: `getPerAudienceSecret()` creates a derived secret from:
72+
- Base secret (STACK_SERVER_SECRET)
73+
- Audience (usually project ID)
74+
- Optional "anon-" prefix for anonymous users
75+
2. **Kid Generation**: `getKid()` creates a key ID from:
76+
- Base secret (STACK_SERVER_SECRET)
77+
- "kid" string with optional "anon-" prefix
78+
- Takes only first 12 characters of hash
79+
3. **Key Generation**: Private/public keys are generated from the derived secret
80+
81+
## Q: What is the JWT signing and verification flow?
82+
A:
83+
**Signing (signJWT)**:
84+
1. Derive secret: `getPerAudienceSecret(audience, STACK_SERVER_SECRET, isAnonymous)`
85+
2. Generate kid: `getKid(STACK_SERVER_SECRET, isAnonymous)`
86+
3. Create private key from derived secret
87+
4. Sign JWT with kid in header and role in payload
88+
89+
**Verification (verifyJWT)**:
90+
1. Decode JWT without verification to read the role
91+
2. Check if role === 'anon' to determine if it's anonymous
92+
3. Derive secret with same parameters as signing
93+
4. Generate kid with same parameters as signing
94+
5. Create public key set and verify JWT
95+
96+
## Q: What makes anonymous JWTs different from regular JWTs?
97+
A: Anonymous JWTs have:
98+
1. **Different derived secret**: Uses "anon-" prefix in secret derivation
99+
2. **Different kid**: Uses "anon-" prefix resulting in different key ID
100+
3. **Role field**: Contains `role: 'anon'` in the payload
101+
4. **Verification requirements**: Requires `allowAnonymous: true` flag to be verified
102+
103+
## Q: How do you debug JWT verification issues?
104+
A: Common debugging steps:
105+
1. Check that the `X-Stack-Allow-Anonymous-User` header is set to "true"
106+
2. Verify the JWT has `role: 'anon'` in its payload
107+
3. Ensure the same secret derivation parameters are used for signing and verification
108+
4. Check that the kid in the JWT header matches the expected kid
109+
5. Verify that `allowAnonymous` flag is passed through the entire call chain
110+
111+
## Q: What is the difference between getPrivateJwk and getPrivateJwkFromDerivedSecret?
112+
A:
113+
- `getPrivateJwk(secret, isAnonymous)`: Takes a base secret, may derive it internally, generates kid
114+
- `getPrivateJwkFromDerivedSecret(derivedSecret, kid)`: Takes an already-derived secret and pre-calculated kid
115+
The second is used internally for the actual JWT signing flow, while the first is for backward compatibility and special cases like IDP.
116+
117+
## Q: How does the JWT verification process work with jose?
118+
A: The `jose.jwtVerify` function:
119+
1. Extracts the kid from the JWT header
120+
2. Looks for a key with matching kid in the provided JWK set
121+
3. Uses that key to verify the JWT signature
122+
4. If no matching kid is found, verification fails with an error
123+
124+
## Q: What causes UNPARSABLE_ACCESS_TOKEN errors?
125+
A: This error occurs when JWT verification fails in `decodeAccessToken`. Common causes:
126+
1. Kid mismatch - the kid in the JWT header doesn't match any key in the JWK set
127+
2. Wrong secret derivation - using different parameters for signing vs verification
128+
3. JOSEError thrown during `jose.jwtVerify` due to invalid signature or key mismatch
4129

5130
## OAuth Flow and Validation
6131

@@ -240,4 +365,4 @@ A: This happens when packages haven't been built yet. Run these commands in orde
240365
```bash
241366
pnpm clean && pnpm i && pnpm codegen && pnpm build:packages
242367
```
243-
Then restart the dev server. This rebuilds all packages and generates the necessary TypeScript declarations.
368+
Then restart the dev server. This rebuilds all packages and generates the necessary TypeScript declarations.

CLAUDE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,11 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
7272
- Environment variables are pre-configured in `.env.development` files
7373
- Always run typecheck, lint, and test to make sure your changes are working as expected. You can save time by only linting and testing the files you've changed (and/or related E2E tests).
7474
- The project uses a custom route handler system in the backend for consistent API responses
75-
- Sometimes, the typecheck will give errors along the line of "Cannot assign Buffer to Uint8Array" or similar, on changes that are completely unrelated to your own changes. If that happens, tell the user to run `pnpm clean && pnpm i && pnpm run codegen && pnpm build:packages`, and restart the dev server (you cannot run this yourself). After that's done, the typecheck should pass.
76-
- When writing tests, prefer .toMatchInlineSnapshot over other selectors, if possible. You can check (and modify) the snapshot-serializer.ts file to see how the snapshots are formatted and how non-deterministic values are handled.
77-
- Whenever you learn something new, or at the latest right before you call the `Stop` tool, write whatever you learned into the ./claude/CLAUDE-KNOWLEDGE.md file, in the Q&A format in there. You will later be able to look up knowledge from there (based on the question you asked).
75+
- Sometimes, the typecheck will give errors along the line of "Cannot assign Buffer to Uint8Array" or similar, on changes that are completely unrelated to your own changes. If that happens, stop and tell the user to run `pnpm clean && pnpm i && pnpm run codegen && pnpm build:packages`, and restart the dev server (you cannot run this yourself). After that's done, the typecheck should pass.
76+
- Whenever you learn something new, or at the latest right before you call the `Stop` tool, write whatever you learned into the .claude/CLAUDE-KNOWLEDGE.md file, in the Q&A format in there. You will later be able to look up knowledge from there (based on the question you asked). Note that it's not 100% accurate and you may have to update it later if you find that something is wrong.
7877

7978
### Code-related
8079
- Use ES6 maps instead of records wherever you can.
80+
81+
### Testing-related
82+
- When writing tests, prefer .toMatchInlineSnapshot over other matchers, if possible. You can check (and modify) the snapshot-serializer.ts file to see how the snapshots are formatted and how non-deterministic values are handled.

apps/backend/src/app/api/latest/auth/anonymous/sign-up/route.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,8 @@
11
import { createAuthTokens } from "@/lib/tokens";
22
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
3-
import { KnownErrors } from "@stackframe/stack-shared";
43
import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
54
import { usersCrudHandlers } from "../../../users/crud";
65

7-
// Define the allowed project IDs for anonymous sign-up
8-
const ALLOWED_PROJECT_IDS = [
9-
"9bee8100-8d83-4ad7-aaad-d6607e386a28",
10-
"71bd203a-14d9-4ccc-b704-32bfac0e2542",
11-
"internal",
12-
];
13-
146
export const POST = createSmartRouteHandler({
157
metadata: {
168
summary: "Sign up anonymously",
@@ -34,14 +26,9 @@ export const POST = createSmartRouteHandler({
3426
}).defined(),
3527
}),
3628
async handler({ auth: { project, type, tenancy } }) {
37-
if (!ALLOWED_PROJECT_IDS.includes(project.id)) {
38-
throw new KnownErrors.AnonymousAccountsNotEnabled();
39-
}
40-
4129
const createdUser = await usersCrudHandlers.adminCreate({
4230
tenancy,
4331
data: {
44-
display_name: "Anonymous user",
4532
is_anonymous: true,
4633
},
4734
allowedErrorTypes: [],

apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,14 @@ export const GET = createSmartRouteHandler({
7171

7272
const provider = { id: providerRaw[0], ...providerRaw[1] };
7373

74-
// If the authorization token is present, we are adding new scopes to the user instead of sign-in/sign-up
74+
if (query.type === "link" && !query.token) {
75+
throw new StatusError(StatusError.BadRequest, "?token= query parameter is required for link type");
76+
}
77+
78+
// If a token is provided, store it in the outer info so we can use it to link another user to the account, or to upgrade an anonymous user
7579
let projectUserId: string | undefined;
76-
if (query.type === "link") {
77-
const result = await decodeAccessToken(query.token);
80+
if (query.token) {
81+
const result = await decodeAccessToken(query.token, { allowAnonymous: true });
7882
if (result.status === "error") {
7983
throw result.error;
8084
}

apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getAuthContactChannel } from "@/lib/contact-channel";
33
import { validateRedirectUrl } from "@/lib/redirect-urls";
44
import { Tenancy, getTenancy } from "@/lib/tenancies";
55
import { oauthCookieSchema } from "@/lib/tokens";
6+
import { createOrUpgradeAnonymousUser } from "@/lib/users";
67
import { getProvider, oauthServer } from "@/oauth";
78
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
89
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
@@ -348,45 +349,51 @@ const handler = createSmartRouteHandler({
348349
}
349350
}
350351

352+
351353
if (!tenancy.config.auth.allowSignUp) {
352354
throw new KnownErrors.SignUpNotEnabled();
353355
}
354356

355-
const newAccount = await usersCrudHandlers.adminCreate({
357+
const currentUser = projectUserId ? await usersCrudHandlers.adminRead({ tenancy, user_id: projectUserId }) : null;
358+
const newAccountBeforeAuthMethod = await createOrUpgradeAnonymousUser(
356359
tenancy,
357-
data: {
360+
currentUser,
361+
{
358362
display_name: userInfo.displayName,
359363
profile_image_url: userInfo.profileImageUrl || undefined,
360364
primary_email: userInfo.email,
361365
primary_email_verified: userInfo.emailVerified,
362366
primary_email_auth_enabled: primaryEmailAuthEnabled,
363-
oauth_providers: [{
364-
id: provider.id,
365-
account_id: userInfo.accountId,
366-
email: userInfo.email,
367-
}],
368367
},
368+
[],
369+
);
370+
const authMethod = await prisma.authMethod.create({
371+
data: {
372+
tenancyId: tenancy.id,
373+
projectUserId: newAccountBeforeAuthMethod.id,
374+
}
369375
});
370-
371-
const oauthAccount = await prisma.projectUserOAuthAccount.findUnique({
372-
where: {
373-
tenancyId_configOAuthProviderId_projectUserId_providerAccountId: {
374-
tenancyId: outerInfo.tenancyId,
375-
configOAuthProviderId: provider.id,
376-
providerAccountId: userInfo.accountId,
377-
projectUserId: newAccount.id,
376+
const oauthAccount = await prisma.projectUserOAuthAccount.create({
377+
data: {
378+
tenancyId: tenancy.id,
379+
projectUserId: newAccountBeforeAuthMethod.id,
380+
configOAuthProviderId: provider.id,
381+
providerAccountId: userInfo.accountId,
382+
email: userInfo.email,
383+
oauthAuthMethod: {
384+
create: {
385+
authMethodId: authMethod.id,
386+
}
378387
},
379-
},
388+
allowConnectedAccounts: true,
389+
allowSignIn: true,
390+
}
380391
});
381392

382-
if (!oauthAccount) {
383-
throw new StackAssertionError("OAuth account not found");
384-
}
385-
386393
await storeTokens(oauthAccount.id);
387394

388395
return {
389-
id: newAccount.id,
396+
id: newAccountBeforeAuthMethod.id,
390397
newUser: true,
391398
afterCallbackRedirectUrl,
392399
};

apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getAuthContactChannel } from "@/lib/contact-channel";
22
import { sendEmailFromTemplate } from "@/lib/emails";
33
import { getSoleTenancyFromProjectBranch, Tenancy } from "@/lib/tenancies";
44
import { createAuthTokens } from "@/lib/tokens";
5+
import { createOrUpgradeAnonymousUser } from "@/lib/users";
56
import { getPrismaClientForTenancy } from "@/prisma-client";
67
import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler";
78
import { VerificationCodeType } from "@prisma/client";
@@ -100,20 +101,23 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({
100101
nonce: codeObj.code.slice(6),
101102
};
102103
},
103-
async handler(tenancy, { email }) {
104+
async handler(tenancy, { email }, data, requestBody, currentUser) {
104105
let user = await ensureUserForEmailAllowsOtp(tenancy, email);
105106
let isNewUser = false;
107+
106108
if (!user) {
107-
isNewUser = true;
108-
user = await usersCrudHandlers.adminCreate({
109+
user = await createOrUpgradeAnonymousUser(
109110
tenancy,
110-
data: {
111+
currentUser ?? null,
112+
{
111113
primary_email: email,
112114
primary_email_verified: true,
113115
primary_email_auth_enabled: true,
114116
otp_auth_enabled: true,
115117
},
116-
});
118+
[]
119+
);
120+
isNewUser = true;
117121
}
118122

119123
if (user.requires_totp_mfa) {

apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { validateRedirectUrl } from "@/lib/redirect-urls";
22
import { createAuthTokens } from "@/lib/tokens";
3+
import { createOrUpgradeAnonymousUser } from "@/lib/users";
34
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
45
import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel";
56
import { KnownErrors } from "@stackframe/stack-shared";
67
import { getPasswordError } from "@stackframe/stack-shared/dist/helpers/password";
78
import { adaptSchema, clientOrHigherAuthTypeSchema, emailVerificationCallbackUrlSchema, passwordSchema, signInEmailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
89
import { contactChannelVerificationCodeHandler } from "../../../contact-channels/verify/verification-code-handler";
9-
import { usersCrudHandlers } from "../../../users/crud";
1010
import { createMfaRequiredError } from "../../mfa/sign-in/verification-code-handler";
1111

1212
export const POST = createSmartRouteHandler({
@@ -19,6 +19,7 @@ export const POST = createSmartRouteHandler({
1919
auth: yupObject({
2020
type: clientOrHigherAuthTypeSchema,
2121
tenancy: adaptSchema,
22+
user: adaptSchema.optional()
2223
}).defined(),
2324
body: yupObject({
2425
email: signInEmailSchema.defined(),
@@ -35,7 +36,7 @@ export const POST = createSmartRouteHandler({
3536
user_id: yupString().defined(),
3637
}).defined(),
3738
}),
38-
async handler({ auth: { tenancy }, body: { email, password, verification_callback_url: verificationCallbackUrl } }, fullReq) {
39+
async handler({ auth: { tenancy, user: currentUser }, body: { email, password, verification_callback_url: verificationCallbackUrl } }, fullReq) {
3940
if (!tenancy.config.auth.password.allowSignIn) {
4041
throw new KnownErrors.PasswordAuthenticationNotEnabled();
4142
}
@@ -44,25 +45,26 @@ export const POST = createSmartRouteHandler({
4445
throw new KnownErrors.RedirectUrlNotWhitelisted();
4546
}
4647

48+
if (!tenancy.config.auth.allowSignUp) {
49+
throw new KnownErrors.SignUpNotEnabled();
50+
}
51+
4752
const passwordError = getPasswordError(password);
4853
if (passwordError) {
4954
throw passwordError;
5055
}
5156

52-
if (!tenancy.config.auth.allowSignUp) {
53-
throw new KnownErrors.SignUpNotEnabled();
54-
}
55-
56-
const createdUser = await usersCrudHandlers.adminCreate({
57+
const createdUser = await createOrUpgradeAnonymousUser(
5758
tenancy,
58-
data: {
59+
currentUser ?? null,
60+
{
5961
primary_email: email,
6062
primary_email_verified: false,
6163
primary_email_auth_enabled: true,
6264
password,
6365
},
64-
allowedErrorTypes: [KnownErrors.UserWithEmailAlreadyExists],
65-
});
66+
[KnownErrors.UserWithEmailAlreadyExists]
67+
);
6668

6769
runAsynchronouslyAndWaitUntil((async () => {
6870
await contactChannelVerificationCodeHandler.sendCode({

0 commit comments

Comments
 (0)