Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/e2e-api-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ jobs:
- name: Create .env.test.local file for examples/supabase
run: cp examples/supabase/.env.development examples/supabase/.env.test.local

- name: Create .env.test.local file for examples/convex
run: cp examples/convex/.env.development examples/convex/.env.test.local

- name: Build
run: pnpm build

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/e2e-source-of-truth-api-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ jobs:

- name: Create .env.test.local file for examples/supabase
run: cp examples/supabase/.env.development examples/supabase/.env.test.local

- name: Create .env.test.local file for examples/convex
run: cp examples/convex/.env.development examples/convex/.env.test.local

- name: Build
run: pnpm build
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/lint-and-build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ jobs:
- name: Create .env.production.local file for examples/supabase
run: cp examples/supabase/.env.development examples/supabase/.env.production.local

- name: Create .env.production.local file for examples/convex
run: cp examples/convex/.env.development examples/convex/.env.production.local

- name: Build
run: pnpm build

Expand Down
29 changes: 17 additions & 12 deletions apps/backend/src/lib/tokens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as jose from 'jose';
import { JOSEError, JWTExpired } from 'jose/errors';
import { SystemEventTypes, logEvent } from './events';
import { Tenancy } from './tenancies';
import { AccessTokenPayload } from '@stackframe/stack-shared/dist/sessions';

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

Expand Down Expand Up @@ -87,7 +88,7 @@ export async function decodeAccessToken(accessToken: string, { allowAnonymous }:
throw error;
}

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

return Result.ok(result);
Expand Down Expand Up @@ -149,20 +150,24 @@ export async function generateAccessToken(options: {
}
);

const payload: Omit<AccessTokenPayload, "iss" | "aud"> = {
sub: options.userId,
project_id: options.tenancy.project.id,
branch_id: options.tenancy.branchId,
refresh_token_id: options.refreshTokenId,
role: 'authenticated',
name: user.display_name,
email: user.primary_email,
email_verified: user.primary_email_verified,
selected_team_id: user.selected_team_id,
is_anonymous: user.is_anonymous,
};

return await signJWT({
issuer: getIssuer(options.tenancy.project.id, user.is_anonymous),
audience: getAudience(options.tenancy.project.id, user.is_anonymous),
payload: {
sub: options.userId,
branch_id: options.tenancy.branchId,
refresh_token_id: options.refreshTokenId,
role: user.is_anonymous ? 'anon' : 'authenticated',
name: user.display_name,
email: user.primary_email,
email_verified: user.primary_email_verified,
selected_team_id: user.selected_team_id,
},
expirationTime: getEnvVariable("STACK_ACCESS_TOKEN_EXPIRATION_TIME", "10min"),
payload,
});
}

Expand Down
4 changes: 0 additions & 4 deletions apps/backend/src/lib/types.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
import { PrismaClient } from "@prisma/client";

export type PrismaTransaction = Parameters<Parameters<PrismaClient['$transaction']>[0]>[0];

export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
1 change: 1 addition & 0 deletions apps/e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@oslojs/otp": "^1.1.0",
"@stackframe/js": "workspace:*",
"@stackframe/stack-shared": "workspace:*",
"convex": "^1.27.0",
"dotenv": "^16.4.5"
},
"devDependencies": {
Expand Down
2 changes: 2 additions & 0 deletions apps/e2e/tests/backend/backend-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ export namespace Auth {
"email": expect.toSatisfy(() => true),
"email_verified": expect.any(Boolean),
"selected_team_id": expect.toSatisfy(() => true),
"is_anonymous": expect.any(Boolean),
"project_id": payload.aud
});
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ it("anonymous JWT has different kid and role", async ({ expect }) => {
JSON.parse(Buffer.from(part, 'base64url').toString())
);

expect(payload.role).toBe('anon');
expect(payload.role).toBe('authenticated');
expect(payload.is_anonymous).toBe(true);
expect(header.kid).toBeTruthy();

// The kid should be different from regular users
Expand Down
180 changes: 180 additions & 0 deletions apps/e2e/tests/js/convex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { ConvexHttpClient } from "convex/browser";
import { ConvexReactClient } from "convex/react";
import { decodeJwt } from "jose";
import { it } from "../helpers";
import { createApp } from "./js-helpers";


class MockWebSocket {
static last: MockWebSocket | undefined;
url: string;
onopen: ((ev: any) => void) | null = null;
onmessage: ((ev: any) => void) | null = null;
onclose: ((ev: any) => void) | null = null;
sent: Array<{ raw: any, json: any }> = [];
constructor(url: string) {
this.url = url;
MockWebSocket.last = this;
}
send(data: any) {
let json: any;
try {
json = JSON.parse(String(data));
} catch {
json = null;
}
this.sent.push({ raw: data, json });
}
close() {
if (this.onclose) this.onclose({ code: 1000, reason: "" } as any);
}
open() {
if (this.onopen) this.onopen({} as any);
}
}

const signIn = async (clientApp: any) => {
await clientApp.signUpWithCredential({
email: "test@test.com",
password: "password",
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({
email: "test@test.com",
password: "password",
});
};

it("should provide a valid auth getter for Convex React client", async ({ expect }) => {
const { clientApp } = await createApp({});
await signIn(clientApp);

const getter = clientApp.getConvexClientAuth({ tokenStore: "memory" });
const token2 = await getter({ forceRefreshToken: true });
expect(typeof token2).toBe("string");
expect((token2 as string).length).toBeGreaterThan(1);

const convex = new ConvexReactClient("http://localhost:1234", { webSocketConstructor: MockWebSocket as any, expectAuth: true });
convex.setAuth(getter);
MockWebSocket.last?.open();
// wait up to 1s (10 x 100ms) until both Connect and Authenticate messages are seen
let connectMsg: any = undefined;
let authMsg: any = undefined;
for (let i = 0; i < 10; i++) {
const msgs = (MockWebSocket.last?.sent ?? []).map(m => m.json);
connectMsg = msgs.find(m => m?.type === "Connect");
authMsg = msgs.find(m => m?.type === "Authenticate" && m?.tokenType === "User");
if (connectMsg && authMsg) break;
await new Promise(r => setTimeout(r, 100));
}
expect(connectMsg).toBeDefined();
expect(authMsg).toBeDefined();
expect((authMsg as any).value).toBe(token2);
});

it("should provide a valid auth token for Convex HTTP client", async ({ expect }) => {
const { clientApp } = await createApp({});
await signIn(clientApp);

const token = await clientApp.getConvexHttpClientAuth({ tokenStore: "memory" });
expect(typeof token).toBe("string");
expect(token.length).toBeGreaterThan(1);

const user = await clientApp.getUser({ or: "throw" });
const payload: any = decodeJwt(token);
expect(payload.sub).toBe(user.id);

const convex = new ConvexHttpClient("http://localhost:1234");
convex.setAuth(token);

const originalFetch = globalThis.fetch;
let capturedAuth: string | undefined;
globalThis.fetch = (async (_input: any, init?: any) => {
capturedAuth = init?.headers?.Authorization;
return new Response(JSON.stringify({ status: "success", value: null, logLines: [] }), { status: 200, headers: { "Content-Type": "application/json" } });
}) as any;
try {
await (convex as any).function("any");
} finally {
globalThis.fetch = originalFetch;
}
expect(capturedAuth).toBe(`Bearer ${token}`);
});

it("should map convex ctx identity to partial user", async ({ expect }) => {
const { clientApp } = await createApp({});
await signIn(clientApp);

const user = await clientApp.getUser({ or: "throw" });
const identity = {
subject: user.id,
name: user.displayName,
email: user.primaryEmail,
email_verified: user.primaryEmailVerified,
is_anonymous: user.isAnonymous,
} as const;

const ctx: any = {
auth: {
getUserIdentity: async () => identity,
},
};

const partialUser = await clientApp.getPartialUser({ from: "convex", ctx });
expect(partialUser).toEqual({
id: user.id,
displayName: user.displayName,
primaryEmail: user.primaryEmail,
primaryEmailVerified: user.primaryEmailVerified,
isAnonymous: user.isAnonymous,
});
});

it("should return null partial user when convex identity is absent", async ({ expect }) => {
const { clientApp } = await createApp({});
await signIn(clientApp);

const ctx: any = {
auth: {
getUserIdentity: async () => null,
},
};

const partialUser = await clientApp.getPartialUser({ from: "convex", ctx });
expect(partialUser).toBeNull();
});


it("should return the server user when provided Convex ctx identity", async ({ expect }) => {
const { clientApp, serverApp } = await createApp({});
await signIn(clientApp);

const user = await clientApp.getUser({ or: "throw" });
const identity = { subject: user.id } as const;

const ctx: any = {
auth: {
getUserIdentity: async () => identity,
},
};

const serverUser = await serverApp.getUser({ from: "convex", ctx });
expect(serverUser).not.toBeNull();
expect(serverUser!.id).toBe(user.id);
expect(serverUser!.isAnonymous).toBe(false);
});

it("should return null when Convex ctx identity is absent for server getUser", async ({ expect }) => {
const { serverApp } = await createApp({});

const ctx: any = {
auth: {
getUserIdentity: async () => null,
},
};

const serverUser = await serverApp.getUser({ from: "convex", ctx });
expect(serverUser).toBeNull();
});


3 changes: 3 additions & 0 deletions docs/docs-platform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,9 @@ pages:
- path: others/supabase.mdx
platforms: ["next"] # Next only

- path: others/convex.mdx
platforms: ["next", "react", "js"] # No Python

- path: others/cli-authentication.mdx
platforms: ["python"] # Python only

Expand Down
1 change: 1 addition & 0 deletions docs/templates/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"others/cli-authentication",
"others/self-host",
"others/supabase",
"others/convex",
"sdk",
"components"
]
Expand Down
Loading