Skip to content

Commit 3fe82b6

Browse files
BilalG1N2D4
andauthored
Convex implementation (#913)
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- ELLIPSIS_HIDDEN --> ---- > [!IMPORTANT] > Add Convex integration with new auth helpers, update access token handling, and include documentation, examples, and tests for the new features. > > - **Features**: > - Add Convex integration with new auth helpers for Convex clients and HTTP in `client-app-impl.ts` and `server-app-impl.ts`. > - Support for Convex context in user APIs and partial user retrieval. > - Access tokens now include `is_anonymous` for better anonymous handling in `tokens.tsx`. > - **Documentation**: > - Add Convex integration guide in `docs/templates/others/convex.mdx`. > - Update docs navigation in `docs/docs-platform.yml` and `docs/templates/meta.json`. > - **Examples**: > - Add Convex + Next.js example app in `examples/convex` with auth wiring, functions, schema, and UI. > - **Tests**: > - Add E2E tests for Convex auth flows in `convex.test.ts`. > - Update JWT payload checks in `backend-helpers.ts` and `anonymous-comprehensive.test.ts`. > - **Chores**: > - Add Convex dependencies in `package.json` files. > - Update CI steps for example environments in `e2e-api-tests.yaml` and `e2e-source-of-truth-api-tests.yaml`. > > <sup>This description was created by </sup>[<img alt="Ellipsis" src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup> for aa0983a. You can [customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this summary. It will automatically update as commits are pushed.</sup> ---- <!-- ELLIPSIS_HIDDEN --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Convex integration: auth helpers for Convex clients/HTTP, Convex-aware user APIs, and partial-user retrieval (token/convex). - Access tokens now surface is_anonymous for clearer anonymous handling. - **Documentation** - Added Convex integration guide and nav entries. - **Examples** - New Convex + Next.js example app with auth wiring, backend functions, schema, and UI. - **Tests** - Added E2E tests covering Convex auth flows and JWT payload checks. - **Chores** - Added Convex deps, CI env steps, and workspace/test config updates. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Konstantin Wohlwend <n2d4xc@gmail.com>
1 parent 7a0bf86 commit 3fe82b6

62 files changed

Lines changed: 14342 additions & 238 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/e2e-api-tests.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ jobs:
8484
- name: Create .env.test.local file for examples/supabase
8585
run: cp examples/supabase/.env.development examples/supabase/.env.test.local
8686

87+
- name: Create .env.test.local file for examples/convex
88+
run: cp examples/convex/.env.development examples/convex/.env.test.local
89+
8790
- name: Build
8891
run: pnpm build
8992

.github/workflows/e2e-source-of-truth-api-tests.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ jobs:
8585

8686
- name: Create .env.test.local file for examples/supabase
8787
run: cp examples/supabase/.env.development examples/supabase/.env.test.local
88+
89+
- name: Create .env.test.local file for examples/convex
90+
run: cp examples/convex/.env.development examples/convex/.env.test.local
8891

8992
- name: Build
9093
run: pnpm build

.github/workflows/lint-and-build.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ jobs:
6868
- name: Create .env.production.local file for examples/supabase
6969
run: cp examples/supabase/.env.development examples/supabase/.env.production.local
7070

71+
- name: Create .env.production.local file for examples/convex
72+
run: cp examples/convex/.env.development examples/convex/.env.production.local
73+
7174
- name: Build
7275
run: pnpm build
7376

apps/backend/src/lib/tokens.tsx

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import * as jose from 'jose';
1313
import { JOSEError, JWTExpired } from 'jose/errors';
1414
import { SystemEventTypes, logEvent } from './events';
1515
import { Tenancy } from './tenancies';
16+
import { AccessTokenPayload } from '@stackframe/stack-shared/dist/sessions';
1617

1718
export const authorizationHeaderSchema = yupString().matches(/^StackSession [^ ]+$/);
1819

@@ -87,7 +88,7 @@ export async function decodeAccessToken(accessToken: string, { allowAnonymous }:
8788
throw error;
8889
}
8990

90-
const isAnonymous = payload.role === 'anon';
91+
const isAnonymous = payload.is_anonymous as boolean | undefined ?? /* legacy, now we always set role to authenticated, TODO next-release remove */ payload.role === 'anon';
9192
if (aud.endsWith(":anon") && !isAnonymous) {
9293
console.warn("Unparsable access token. Role is set to anon, but audience is not an anonymous audience.", { accessToken, payload });
9394
return Result.error(new KnownErrors.UnparsableAccessToken());
@@ -108,7 +109,7 @@ export async function decodeAccessToken(accessToken: string, { allowAnonymous }:
108109
branchId: branchId,
109110
refreshTokenId: payload.refresh_token_id ?? payload.refreshTokenId,
110111
exp: payload.exp,
111-
isAnonymous: payload.role === 'anon',
112+
isAnonymous: payload.is_anonymous ?? /* legacy, now we always set role to authenticated, TODO next-release remove */ payload.role === 'anon',
112113
});
113114

114115
return Result.ok(result);
@@ -149,20 +150,24 @@ export async function generateAccessToken(options: {
149150
}
150151
);
151152

153+
const payload: Omit<AccessTokenPayload, "iss" | "aud"> = {
154+
sub: options.userId,
155+
project_id: options.tenancy.project.id,
156+
branch_id: options.tenancy.branchId,
157+
refresh_token_id: options.refreshTokenId,
158+
role: 'authenticated',
159+
name: user.display_name,
160+
email: user.primary_email,
161+
email_verified: user.primary_email_verified,
162+
selected_team_id: user.selected_team_id,
163+
is_anonymous: user.is_anonymous,
164+
};
165+
152166
return await signJWT({
153167
issuer: getIssuer(options.tenancy.project.id, user.is_anonymous),
154168
audience: getAudience(options.tenancy.project.id, user.is_anonymous),
155-
payload: {
156-
sub: options.userId,
157-
branch_id: options.tenancy.branchId,
158-
refresh_token_id: options.refreshTokenId,
159-
role: user.is_anonymous ? 'anon' : 'authenticated',
160-
name: user.display_name,
161-
email: user.primary_email,
162-
email_verified: user.primary_email_verified,
163-
selected_team_id: user.selected_team_id,
164-
},
165169
expirationTime: getEnvVariable("STACK_ACCESS_TOKEN_EXPIRATION_TIME", "10min"),
170+
payload,
166171
});
167172
}
168173

apps/backend/src/lib/types.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
11
import { PrismaClient } from "@prisma/client";
22

33
export type PrismaTransaction = Parameters<Parameters<PrismaClient['$transaction']>[0]>[0];
4-
5-
export type Prettify<T> = {
6-
[K in keyof T]: T[K];
7-
} & {};

apps/e2e/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"@oslojs/otp": "^1.1.0",
1515
"@stackframe/js": "workspace:*",
1616
"@stackframe/stack-shared": "workspace:*",
17+
"convex": "^1.27.0",
1718
"dotenv": "^16.4.5"
1819
},
1920
"devDependencies": {

apps/e2e/tests/backend/backend-helpers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,8 @@ export namespace Auth {
208208
"email": expect.toSatisfy(() => true),
209209
"email_verified": expect.any(Boolean),
210210
"selected_team_id": expect.toSatisfy(() => true),
211+
"is_anonymous": expect.any(Boolean),
212+
"project_id": payload.aud
211213
});
212214
}
213215
}

apps/e2e/tests/backend/endpoints/api/v1/auth/anonymous/anonymous-comprehensive.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ it("anonymous JWT has different kid and role", async ({ expect }) => {
3434
JSON.parse(Buffer.from(part, 'base64url').toString())
3535
);
3636

37-
expect(payload.role).toBe('anon');
37+
expect(payload.role).toBe('authenticated');
38+
expect(payload.is_anonymous).toBe(true);
3839
expect(header.kid).toBeTruthy();
3940

4041
// The kid should be different from regular users

apps/e2e/tests/js/convex.test.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { ConvexHttpClient } from "convex/browser";
2+
import { ConvexReactClient } from "convex/react";
3+
import { decodeJwt } from "jose";
4+
import { it } from "../helpers";
5+
import { createApp } from "./js-helpers";
6+
7+
8+
class MockWebSocket {
9+
static last: MockWebSocket | undefined;
10+
url: string;
11+
onopen: ((ev: any) => void) | null = null;
12+
onmessage: ((ev: any) => void) | null = null;
13+
onclose: ((ev: any) => void) | null = null;
14+
sent: Array<{ raw: any, json: any }> = [];
15+
constructor(url: string) {
16+
this.url = url;
17+
MockWebSocket.last = this;
18+
}
19+
send(data: any) {
20+
let json: any;
21+
try {
22+
json = JSON.parse(String(data));
23+
} catch {
24+
json = null;
25+
}
26+
this.sent.push({ raw: data, json });
27+
}
28+
close() {
29+
if (this.onclose) this.onclose({ code: 1000, reason: "" } as any);
30+
}
31+
open() {
32+
if (this.onopen) this.onopen({} as any);
33+
}
34+
}
35+
36+
const signIn = async (clientApp: any) => {
37+
await clientApp.signUpWithCredential({
38+
email: "test@test.com",
39+
password: "password",
40+
verificationCallbackUrl: "http://localhost:3000",
41+
});
42+
await clientApp.signInWithCredential({
43+
email: "test@test.com",
44+
password: "password",
45+
});
46+
};
47+
48+
it("should provide a valid auth getter for Convex React client", async ({ expect }) => {
49+
const { clientApp } = await createApp({});
50+
await signIn(clientApp);
51+
52+
const getter = clientApp.getConvexClientAuth({ tokenStore: "memory" });
53+
const token2 = await getter({ forceRefreshToken: true });
54+
expect(typeof token2).toBe("string");
55+
expect((token2 as string).length).toBeGreaterThan(1);
56+
57+
const convex = new ConvexReactClient("http://localhost:1234", { webSocketConstructor: MockWebSocket as any, expectAuth: true });
58+
convex.setAuth(getter);
59+
MockWebSocket.last?.open();
60+
// wait up to 1s (10 x 100ms) until both Connect and Authenticate messages are seen
61+
let connectMsg: any = undefined;
62+
let authMsg: any = undefined;
63+
for (let i = 0; i < 10; i++) {
64+
const msgs = (MockWebSocket.last?.sent ?? []).map(m => m.json);
65+
connectMsg = msgs.find(m => m?.type === "Connect");
66+
authMsg = msgs.find(m => m?.type === "Authenticate" && m?.tokenType === "User");
67+
if (connectMsg && authMsg) break;
68+
await new Promise(r => setTimeout(r, 100));
69+
}
70+
expect(connectMsg).toBeDefined();
71+
expect(authMsg).toBeDefined();
72+
expect((authMsg as any).value).toBe(token2);
73+
});
74+
75+
it("should provide a valid auth token for Convex HTTP client", async ({ expect }) => {
76+
const { clientApp } = await createApp({});
77+
await signIn(clientApp);
78+
79+
const token = await clientApp.getConvexHttpClientAuth({ tokenStore: "memory" });
80+
expect(typeof token).toBe("string");
81+
expect(token.length).toBeGreaterThan(1);
82+
83+
const user = await clientApp.getUser({ or: "throw" });
84+
const payload: any = decodeJwt(token);
85+
expect(payload.sub).toBe(user.id);
86+
87+
const convex = new ConvexHttpClient("http://localhost:1234");
88+
convex.setAuth(token);
89+
90+
const originalFetch = globalThis.fetch;
91+
let capturedAuth: string | undefined;
92+
globalThis.fetch = (async (_input: any, init?: any) => {
93+
capturedAuth = init?.headers?.Authorization;
94+
return new Response(JSON.stringify({ status: "success", value: null, logLines: [] }), { status: 200, headers: { "Content-Type": "application/json" } });
95+
}) as any;
96+
try {
97+
await (convex as any).function("any");
98+
} finally {
99+
globalThis.fetch = originalFetch;
100+
}
101+
expect(capturedAuth).toBe(`Bearer ${token}`);
102+
});
103+
104+
it("should map convex ctx identity to partial user", async ({ expect }) => {
105+
const { clientApp } = await createApp({});
106+
await signIn(clientApp);
107+
108+
const user = await clientApp.getUser({ or: "throw" });
109+
const identity = {
110+
subject: user.id,
111+
name: user.displayName,
112+
email: user.primaryEmail,
113+
email_verified: user.primaryEmailVerified,
114+
is_anonymous: user.isAnonymous,
115+
} as const;
116+
117+
const ctx: any = {
118+
auth: {
119+
getUserIdentity: async () => identity,
120+
},
121+
};
122+
123+
const partialUser = await clientApp.getPartialUser({ from: "convex", ctx });
124+
expect(partialUser).toEqual({
125+
id: user.id,
126+
displayName: user.displayName,
127+
primaryEmail: user.primaryEmail,
128+
primaryEmailVerified: user.primaryEmailVerified,
129+
isAnonymous: user.isAnonymous,
130+
});
131+
});
132+
133+
it("should return null partial user when convex identity is absent", async ({ expect }) => {
134+
const { clientApp } = await createApp({});
135+
await signIn(clientApp);
136+
137+
const ctx: any = {
138+
auth: {
139+
getUserIdentity: async () => null,
140+
},
141+
};
142+
143+
const partialUser = await clientApp.getPartialUser({ from: "convex", ctx });
144+
expect(partialUser).toBeNull();
145+
});
146+
147+
148+
it("should return the server user when provided Convex ctx identity", async ({ expect }) => {
149+
const { clientApp, serverApp } = await createApp({});
150+
await signIn(clientApp);
151+
152+
const user = await clientApp.getUser({ or: "throw" });
153+
const identity = { subject: user.id } as const;
154+
155+
const ctx: any = {
156+
auth: {
157+
getUserIdentity: async () => identity,
158+
},
159+
};
160+
161+
const serverUser = await serverApp.getUser({ from: "convex", ctx });
162+
expect(serverUser).not.toBeNull();
163+
expect(serverUser!.id).toBe(user.id);
164+
expect(serverUser!.isAnonymous).toBe(false);
165+
});
166+
167+
it("should return null when Convex ctx identity is absent for server getUser", async ({ expect }) => {
168+
const { serverApp } = await createApp({});
169+
170+
const ctx: any = {
171+
auth: {
172+
getUserIdentity: async () => null,
173+
},
174+
};
175+
176+
const serverUser = await serverApp.getUser({ from: "convex", ctx });
177+
expect(serverUser).toBeNull();
178+
});
179+
180+

docs/docs-platform.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,9 @@ pages:
273273
- path: others/supabase.mdx
274274
platforms: ["next"] # Next only
275275

276+
- path: others/convex.mdx
277+
platforms: ["next", "react", "js"] # No Python
278+
276279
- path: others/cli-authentication.mdx
277280
platforms: ["python"] # Python only
278281

0 commit comments

Comments
 (0)