Skip to content

Commit 22ab8d2

Browse files
committed
Add BetterAuth tutorial
1 parent f4a9819 commit 22ab8d2

1 file changed

Lines changed: 372 additions & 0 deletions

File tree

Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
---
2+
title: Better Auth
3+
---
4+
5+
import {
6+
StepByStep,
7+
Step,
8+
StepText,
9+
StepCode,
10+
} from '@site/src/components/Steps';
11+
import Tabs from '@theme/Tabs';
12+
import TabItem from '@theme/TabItem';
13+
14+
[Better Auth](https://www.better-auth.com/) is a TypeScript authentication
15+
framework that can act as an OAuth 2.1/OIDC provider. SpacetimeDB can authenticate
16+
Better Auth users when Better Auth issues a JWT with:
17+
18+
- a stable `iss` issuer,
19+
- a stable `sub` subject,
20+
- an `aud` audience you check in your module,
21+
- and a JWKS endpoint SpacetimeDB can use to verify the token signature.
22+
23+
This guide shows the OAuth/OIDC provider pattern. Your application signs users in
24+
with Better Auth, obtains an OIDC token, and passes that token to the SpacetimeDB
25+
client connection.
26+
27+
::::warning
28+
SpacetimeDB verifies JWTs through OIDC discovery and JWKS metadata. Opaque access
29+
tokens cannot be validated this way. Make sure the token you pass to SpacetimeDB
30+
is a JWT issued by Better Auth and signed by a key published in Better Auth's
31+
JWKS.
32+
::::
33+
34+
## Prerequisites
35+
36+
We assume you have the following prerequisites in place:
37+
38+
- A working SpacetimeDB project.
39+
- A Better Auth application with a working sign-in flow.
40+
- A public URL for your Better Auth server.
41+
- An OAuth/OIDC client library for your frontend, backend, CLI, or native app.
42+
43+
SpacetimeDB validates the token by fetching OIDC metadata from the token issuer,
44+
so the issuer URL must be reachable by the SpacetimeDB server.
45+
46+
## OAuth/OIDC flow overview
47+
48+
The integration has four parts:
49+
50+
1. Configure Better Auth as an OAuth/OIDC provider.
51+
2. Publish Better Auth OIDC metadata and JWKS.
52+
3. Create an OAuth client for the application that will connect to SpacetimeDB.
53+
4. Obtain a Better Auth token and pass it to SpacetimeDB with `.withToken(...)`.
54+
55+
The examples below use placeholder URLs:
56+
57+
```txt
58+
Better Auth issuer: https://app.example.com/api/auth
59+
OAuth client ID: <YOUR_BETTER_AUTH_CLIENT_ID>
60+
SpacetimeDB URL: <YOUR_SPACETIMEDB_URL>
61+
Module name: <YOUR_MODULE_NAME>
62+
```
63+
64+
Use the exact same issuer value everywhere. The issuer must match the token's
65+
`iss` claim and the OIDC discovery document's `issuer` field.
66+
67+
<StepByStep>
68+
69+
<Step title="Install Better Auth OIDC packages">
70+
<StepText>
71+
Install Better Auth and the OAuth Provider plugin on your auth server.
72+
73+
Your client application may use any OAuth/OIDC client library. For browser apps,
74+
choose a library that supports Authorization Code with PKCE.
75+
76+
</StepText>
77+
<StepCode>
78+
<Tabs groupId="package-manager" defaultValue="NPM"> <TabItem value="NPM" label="NPM">
79+
80+
```bash
81+
npm add better-auth @better-auth/oauth-provider
82+
```
83+
84+
</TabItem>
85+
<TabItem value="Yarn" label="Yarn">
86+
```bash
87+
yarn add better-auth @better-auth/oauth-provider
88+
```
89+
</TabItem>
90+
<TabItem value="PNPM" label="PNPM">
91+
92+
```bash
93+
pnpm add better-auth @better-auth/oauth-provider
94+
```
95+
96+
</TabItem>
97+
<TabItem value="Bun" label="Bun">
98+
99+
```bash
100+
bun add better-auth @better-auth/oauth-provider
101+
```
102+
103+
</TabItem>
104+
</Tabs>
105+
</StepCode>
106+
</Step>
107+
108+
<Step title="Configure Better Auth as an OIDC provider">
109+
<StepText>
110+
Add the Better Auth JWT and OAuth Provider plugins.
111+
112+
The OAuth Provider plugin exposes the OAuth/OIDC authorization flow. The JWT
113+
plugin signs the token that SpacetimeDB will validate.
114+
115+
For new integrations, prefer the OAuth Provider plugin over the older OIDC
116+
Provider plugin.
117+
118+
</StepText>
119+
<StepCode>
120+
121+
```typescript title="auth.ts"
122+
import { betterAuth } from 'better-auth';
123+
import { jwt } from 'better-auth/plugins';
124+
import { oauthProvider } from '@better-auth/oauth-provider';
125+
126+
export const auth = betterAuth({
127+
// ... your existing Better Auth configuration
128+
129+
// OAuth Provider mode uses its own token endpoint.
130+
disabledPaths: ['/token'],
131+
132+
plugins: [
133+
jwt({
134+
jwks: {
135+
keyPairConfig: {
136+
// Prefer an asymmetric algorithm whose public keys can be published
137+
// through JWKS.
138+
alg: 'ES256',
139+
},
140+
},
141+
}),
142+
143+
oauthProvider({
144+
loginPage: '/sign-in',
145+
consentPage: '/consent',
146+
147+
scopes: ['openid', 'profile', 'email'],
148+
}),
149+
],
150+
});
151+
```
152+
153+
</StepCode>
154+
</Step>
155+
156+
<Step title="Expose Better Auth OIDC metadata">
157+
<StepText>
158+
SpacetimeDB validates external JWTs by reading:
159+
160+
```txt
161+
<issuer>/.well-known/openid-configuration
162+
```
163+
164+
It then follows the discovery document's `jwks_uri` to fetch the public signing
165+
keys.
166+
167+
Expose the Better Auth metadata routes using your framework's routing mechanism.
168+
The example below uses Next.js route handlers, but the same endpoints can be
169+
served from any framework.
170+
171+
</StepText>
172+
<StepCode>
173+
174+
```typescript title="app/api/auth/.well-known/openid-configuration/route.ts"
175+
import { oauthProviderOpenIdConfigMetadata } from '@better-auth/oauth-provider';
176+
import { auth } from '@/lib/auth';
177+
178+
export const GET = oauthProviderOpenIdConfigMetadata(auth);
179+
```
180+
181+
```typescript title="app/.well-known/oauth-authorization-server/api/auth/route.ts"
182+
import { oauthProviderAuthServerMetadata } from '@better-auth/oauth-provider';
183+
import { auth } from '@/lib/auth';
184+
185+
export const GET = oauthProviderAuthServerMetadata(auth);
186+
```
187+
188+
</StepCode>
189+
</Step>
190+
191+
<Step title="Create an OAuth client">
192+
<StepText>
193+
Create an OAuth client for the application that will request the token.
194+
195+
For browser and native applications, use a public client with
196+
`token_endpoint_auth_method: "none"` and Authorization Code with PKCE.
197+
198+
Run this from trusted server-side code, such as an admin script or admin route.
199+
Do not create OAuth clients from browser code.
200+
201+
</StepText>
202+
<StepCode>
203+
204+
```typescript
205+
const client = await auth.api.adminCreateOAuthClient({
206+
headers,
207+
body: {
208+
client_name: 'SpacetimeDB App',
209+
redirect_uris: [
210+
'http://localhost:5173',
211+
'https://app.example.com/callback',
212+
],
213+
token_endpoint_auth_method: 'none',
214+
skip_consent: true,
215+
},
216+
});
217+
218+
console.log(client.client_id);
219+
```
220+
221+
</StepCode>
222+
</Step>
223+
224+
<Step title="Request an OIDC token">
225+
<StepText>
226+
Use your OAuth/OIDC client library to perform the Authorization Code with PKCE
227+
flow.
228+
229+
The authorization request should use your Better Auth issuer and client ID, and
230+
should request at least the `openid` scope.
231+
232+
The exact code depends on your framework and OAuth client library, but the
233+
configuration usually looks like this:
234+
235+
</StepText>
236+
<StepCode>
237+
238+
```typescript
239+
const oidcConfig = {
240+
authority: 'https://app.example.com/api/auth',
241+
client_id: '<YOUR_BETTER_AUTH_CLIENT_ID>',
242+
redirect_uri: 'https://app.example.com/callback',
243+
244+
response_type: 'code',
245+
scope: 'openid profile email',
246+
247+
// Browser and native clients should use Authorization Code with PKCE.
248+
// Most OIDC client libraries enable PKCE automatically for public clients.
249+
};
250+
```
251+
252+
</StepCode>
253+
</Step>
254+
255+
<Step title="Pass the Better Auth token to SpacetimeDB">
256+
<StepText>
257+
After the OAuth/OIDC flow completes, get the JWT from your OIDC client library
258+
and pass it to SpacetimeDB with `.withToken(...)`.
259+
260+
For many OIDC clients, this token is exposed as `id_token`. Some OAuth Provider
261+
flows may instead return a JWT access token. The important requirement is that
262+
the token is a signed JWT whose `iss` matches your Better Auth issuer and whose
263+
signing key is available through Better Auth's JWKS.
264+
265+
</StepText>
266+
<StepCode>
267+
268+
```typescript
269+
import { DbConnection } from './module_bindings';
270+
271+
const token = await getBetterAuthOidcToken();
272+
273+
const conn = DbConnection.builder()
274+
.withUri('<YOUR_SPACETIMEDB_URL>')
275+
.withDatabaseName('<YOUR_MODULE_NAME>')
276+
.withToken(token)
277+
.onConnect((_conn, identity) => {
278+
console.log(
279+
'Connected to SpacetimeDB with identity:',
280+
identity.toHexString()
281+
);
282+
})
283+
.onDisconnect(() => {
284+
console.log('Disconnected from SpacetimeDB');
285+
})
286+
.onConnectError((_ctx, err) => {
287+
console.error('Error connecting to SpacetimeDB:', err);
288+
})
289+
.build();
290+
```
291+
292+
</StepCode>
293+
</Step>
294+
295+
<Step title="Validate Better Auth claims in your module">
296+
<StepText>
297+
SpacetimeDB verifies the token signature before your reducers run. Your module
298+
should still validate the claims that define your trust boundary.
299+
300+
At minimum, check:
301+
302+
- `iss`, to ensure the token came from your Better Auth issuer;
303+
- `aud`, to ensure the token was meant for the expected client or resource;
304+
- any custom claim your app uses for authorization, such as a tenant,
305+
organization, role, scope, or token type.
306+
307+
Do not treat a valid signature as the entire authorization decision.
308+
</StepText>
309+
<StepCode>
310+
311+
```typescript title="server/auth.ts"
312+
import { SenderError } from 'spacetimedb/server';
313+
314+
const BETTER_AUTH_ISSUER = 'https://app.example.com/api/auth';
315+
const BETTER_AUTH_CLIENT_ID = '<YOUR_BETTER_AUTH_CLIENT_ID>';
316+
317+
function stringClaim(
318+
payload: Record<string, unknown>,
319+
name: string
320+
): string | undefined {
321+
const value = payload[name];
322+
return typeof value === 'string' ? value : undefined;
323+
}
324+
325+
export const onConnect = spacetimedb.clientConnected(ctx => {
326+
const jwt = ctx.senderAuth.jwt;
327+
328+
if (jwt == null) {
329+
throw new SenderError('Unauthorized: JWT is required to connect');
330+
}
331+
332+
if (jwt.issuer !== BETTER_AUTH_ISSUER) {
333+
throw new SenderError('Unauthorized: invalid issuer');
334+
}
335+
336+
if (!jwt.audience.includes(BETTER_AUTH_CLIENT_ID)) {
337+
throw new SenderError('Unauthorized: invalid audience');
338+
}
339+
340+
// Optional: validate custom claims if your Better Auth token includes them.
341+
const tokenType = stringClaim(jwt.fullPayload, 'token_type');
342+
if (tokenType != null && tokenType !== 'spacetime-access') {
343+
throw new SenderError('Unauthorized: invalid token type');
344+
}
345+
346+
// Store or refresh any connection/session state your reducers need for
347+
// module-local authorization decisions.
348+
});
349+
```
350+
351+
</StepCode>
352+
</Step>
353+
354+
</StepByStep>
355+
356+
## Checklist
357+
358+
Before deploying, verify the following:
359+
360+
- The OIDC discovery document is available at
361+
`<issuer>/.well-known/openid-configuration`.
362+
- The discovery document's `issuer` exactly matches the JWT `iss` claim.
363+
- The discovery document's `jwks_uri` points to the JWKS containing the token
364+
signing key.
365+
- The token you pass to SpacetimeDB is a JWT, not an opaque access token.
366+
- The module checks `iss` and `aud` on connect.
367+
- Any tenant, organization, role, scope, or permission claims are treated as
368+
authorization input, not as a replacement for reducer-level authorization.
369+
370+
You are now set up to use Better Auth authentication with SpacetimeDB. Your app
371+
signs users in through Better Auth, receives an OIDC-compatible JWT, and connects
372+
to SpacetimeDB using that token.

0 commit comments

Comments
 (0)