Skip to content

Commit a5046ab

Browse files
committed
Require explicit JWT key pair
1 parent 0b21500 commit a5046ab

12 files changed

Lines changed: 161 additions & 104 deletions

File tree

demos/example-nextjs/.env.local.template

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ POWERSYNC_URL=http://localhost:8080
99
# Used by Next.js API routes (localhost, since it runs outside Docker)
1010
DATABASE_URL=postgresql://postgres:changeme@localhost:5432/postgres
1111

12-
# ── Optional ──────────────────────────────────────────────────────────────────
13-
# JWT_ISSUER=powersync-nextjs-demo
14-
# POWERSYNC_PRIVATE_KEY=<base64-encoded JWK>
15-
# POWERSYNC_PUBLIC_KEY=<base64-encoded JWK>
12+
# ── JWT key pair (required) ──────────────────────────────────────────────────
13+
# Generate with: pnpm generate-keys
14+
# Then paste the output below.
15+
POWERSYNC_PRIVATE_KEY=
16+
POWERSYNC_PUBLIC_KEY=

demos/example-nextjs/README.md

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
PowerSync demo using [Next.js](https://nextjs.org/) and the [PowerSync JS web SDK](https://docs.powersync.com/client-sdk-references/js-web).
44

5-
Syncs data from a local Postgres through a self-hosted PowerSync service. No login required; the Next.js server hands out anonymous JWTs.
5+
Syncs data from a local Postgres through a self-hosted PowerSync service. No login required; the Next.js server hands out anonymous JWTs signed with a local key pair.
66

77
## Architecture
88

@@ -27,21 +27,37 @@ PowerSync -> GET /api/auth/keys -> Next.js API route -> JWKS (public key)
2727
## Setup
2828

2929
```bash
30-
# Install deps
30+
# 1. Install deps
3131
pnpm install
3232

33-
# Create env file (defaults work out of the box)
33+
# 2. Create env file
3434
cp .env.local.template .env.local
3535

36-
# Start local Postgres + PowerSync
36+
# 3. Generate a JWT key pair and paste the output into .env.local
37+
pnpm generate-keys
38+
39+
# 4. Start local Postgres + PowerSync
3740
pnpm local:up
3841

39-
# Start Next.js
42+
# 5. Start Next.js
4043
pnpm dev
4144
```
4245

4346
Open [http://localhost:3000](http://localhost:3000).
4447

48+
### About the key pair
49+
50+
PowerSync validates JWTs the Next.js app issues by fetching a JWKS at [/api/auth/keys](src/app/api/auth/keys/route.ts). The private key signs tokens; the public key is what PowerSync fetches. Both must be set in `.env.local` — the app will refuse to start token issuance without them.
51+
52+
`pnpm generate-keys` prints base64-encoded JWKs to stdout. Copy the two lines into `.env.local`:
53+
54+
```
55+
POWERSYNC_PRIVATE_KEY=<base64>
56+
POWERSYNC_PUBLIC_KEY=<base64>
57+
```
58+
59+
Restart `pnpm dev` after changing these values.
60+
4561
## Project structure
4662

4763
```
@@ -54,14 +70,13 @@ src/
5470
│ │ └── data/route.ts CRUD writes to Postgres
5571
│ ├── globals.css
5672
│ ├── layout.tsx
57-
│ ├── page.tsx
58-
│ └── providers.tsx PowerSync provider
73+
│ └── page.tsx
5974
├── components/
6075
│ ├── CustomerList.tsx
6176
│ ├── StatusPanel.tsx
62-
│ └── SyncedContent.tsx Client component for sync state
77+
│ └── SyncedContent.tsx
6378
└── library/
64-
├── auth-keys.ts RSA key pair (server only)
79+
├── auth-keys.ts Loads RSA key pair from env (server only)
6580
├── db.ts Postgres pool (server only)
6681
└── powersync/
6782
├── connector.ts Fetch token + upload mutations
@@ -71,25 +86,26 @@ src/
7186

7287
## Environment variables
7388

74-
All config lives in `.env.local`. Docker Compose also reads from this file (via a symlink at `powersync/docker/.env`). The template has working defaults.
89+
All config lives in `.env.local`. Docker Compose also reads from this file (via a symlink at `powersync/docker/.env`).
7590

76-
| Variable | What it does |
77-
|---|---|
78-
| `POWERSYNC_URL` | PowerSync service URL, also used as the JWT audience |
79-
| `DATABASE_URL` | Postgres connection for Next.js API routes (uses `localhost`) |
80-
| `PS_DATABASE_*` | Postgres credentials used by Docker |
81-
| `PS_STORAGE_*` | Separate Postgres for PowerSync internal storage |
82-
| `PS_DATA_SOURCE_URI` | Postgres URI inside Docker (uses `pg-db` hostname) |
83-
| `PS_STORAGE_SOURCE_URI` | Storage Postgres URI inside Docker (uses `pg-storage` hostname) |
84-
| `POWERSYNC_PRIVATE_KEY` | (Optional) Base64-encoded JWK private key. Auto-generated if not set |
85-
| `POWERSYNC_PUBLIC_KEY` | (Optional) Base64-encoded JWK public key. Auto-generated if not set |
91+
| Variable | Required | What it does |
92+
|---|---|---|
93+
| `POWERSYNC_URL` | yes | PowerSync service URL, also used as the JWT audience |
94+
| `DATABASE_URL` | yes | Postgres connection for Next.js API routes (uses `localhost`) |
95+
| `POWERSYNC_PRIVATE_KEY` | yes | Base64-encoded JWK private key — generate with `pnpm generate-keys` |
96+
| `POWERSYNC_PUBLIC_KEY` | yes | Base64-encoded JWK public key — generate with `pnpm generate-keys` |
97+
| `PS_DATABASE_*` | yes | Postgres credentials used by Docker |
98+
| `PS_STORAGE_*` | yes | Separate Postgres for PowerSync internal storage |
99+
| `PS_DATA_SOURCE_URI` | yes | Postgres URI inside Docker (uses `pg-db` hostname) |
100+
| `PS_STORAGE_SOURCE_URI` | yes | Storage Postgres URI inside Docker (uses `pg-storage` hostname) |
86101

87102
## Scripts
88103

89104
| Command | What it does |
90105
|---|---|
91106
| `pnpm dev` | Start Next.js dev server |
92107
| `pnpm build` | Production build |
108+
| `pnpm generate-keys` | Print a fresh JWT key pair for `.env.local` |
93109
| `pnpm local:up` | Start PowerSync + Postgres via Docker |
94110
| `pnpm local:down` | Stop Docker stack |
95111

demos/example-nextjs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"start": "next start",
99
"lint": "next lint",
1010
"clean": "rm -rf .next",
11+
"generate-keys": "node --experimental-strip-types scripts/generate-keys.mts",
1112
"copy-assets": "powersync-web copy-assets -o public",
1213
"postinstall": "pnpm copy-assets",
1314
"local:up": "powersync docker start --directory powersync",
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/usr/bin/env node
2+
import * as jose from 'jose';
3+
import crypto from 'node:crypto';
4+
5+
async function main() {
6+
const alg = 'RS256';
7+
const kid = `powersync-${crypto.randomBytes(5).toString('hex')}`;
8+
9+
const { publicKey, privateKey } = await jose.generateKeyPair(alg, { extractable: true });
10+
11+
const privateJwk: jose.JWK = { ...(await jose.exportJWK(privateKey)), alg, kid };
12+
const publicJwk: jose.JWK = { ...(await jose.exportJWK(publicKey)), alg, kid };
13+
14+
const privateBase64 = Buffer.from(JSON.stringify(privateJwk)).toString('base64');
15+
const publicBase64 = Buffer.from(JSON.stringify(publicJwk)).toString('base64');
16+
17+
console.log(`Public Key:
18+
${JSON.stringify(publicJwk, null, 2)}
19+
20+
---
21+
22+
Add the following to your .env.local file:
23+
24+
POWERSYNC_PUBLIC_KEY=${publicBase64}
25+
26+
POWERSYNC_PRIVATE_KEY=${privateBase64}
27+
`);
28+
}
29+
30+
main().catch((err) => {
31+
console.error(err);
32+
process.exit(1);
33+
});

demos/example-nextjs/src/app/api/auth/keys/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import { NextResponse } from 'next/server';
44
// JWKS endpoint. PowerSync hits this to verify client tokens.
55
// See powersync/service.yaml -> client_auth.jwks_uri
66
export async function GET() {
7-
const { publicJwk } = await getKeyPair();
8-
return NextResponse.json({ keys: [publicJwk] });
7+
try {
8+
const { publicJwk } = await getKeyPair();
9+
return NextResponse.json({ keys: [publicJwk] });
10+
} catch (err) {
11+
const message = err instanceof Error ? err.message : 'Unknown error';
12+
console.error('[api/auth/keys]', message);
13+
return NextResponse.json({ error: message }, { status: 500 });
14+
}
915
}
Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,28 @@
11
import { getKeyPair } from '@/library/auth-keys';
22
import { SignJWT } from 'jose';
3-
import { NextResponse } from 'next/server';
3+
import { NextRequest, NextResponse } from 'next/server';
44

55
const POWERSYNC_URL = process.env.POWERSYNC_URL ?? 'http://localhost:8080';
6+
const JWT_ISSUER = 'powersync-nextjs-demo';
67

7-
/** Returns a signed JWT for PowerSync. No login required. */
8-
export async function GET() {
9-
const { privateKey, publicJwk } = await getKeyPair();
8+
export async function GET(req: NextRequest) {
9+
try {
10+
const userId = req.nextUrl.searchParams.get('user_id') ?? 'anonymous';
11+
const { privateKey, alg, kid } = await getKeyPair();
1012

11-
const token = await new SignJWT({})
12-
.setProtectedHeader({ alg: 'RS256', kid: publicJwk.kid })
13-
.setSubject('anonymous')
14-
.setIssuedAt()
15-
.setIssuer(process.env.JWT_ISSUER ?? 'powersync-nextjs-demo')
16-
.setAudience(POWERSYNC_URL)
17-
.setExpirationTime('5m')
18-
.sign(privateKey);
13+
const token = await new SignJWT({})
14+
.setProtectedHeader({ alg, kid })
15+
.setSubject(userId)
16+
.setIssuedAt()
17+
.setIssuer(JWT_ISSUER)
18+
.setAudience(POWERSYNC_URL)
19+
.setExpirationTime('5m')
20+
.sign(privateKey);
1921

20-
return NextResponse.json({ token, powersync_url: POWERSYNC_URL });
22+
return NextResponse.json({ token, powersync_url: POWERSYNC_URL });
23+
} catch (err) {
24+
const message = err instanceof Error ? err.message : 'Unknown error';
25+
console.error('[api/auth/token]', message);
26+
return NextResponse.json({ error: message }, { status: 500 });
27+
}
2128
}

demos/example-nextjs/src/app/layout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Metadata } from 'next';
2-
import { Providers } from './providers';
2+
import { PowerSyncProvider } from '@/library/powersync/powersync-provider';
33
import './globals.css';
44

55
export const metadata: Metadata = {
@@ -10,7 +10,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
1010
return (
1111
<html lang="en" className="dark">
1212
<body>
13-
<Providers>{children}</Providers>
13+
<PowerSyncProvider>{children}</PowerSyncProvider>
1414
</body>
1515
</html>
1616
);

demos/example-nextjs/src/app/providers.tsx

Lines changed: 0 additions & 7 deletions
This file was deleted.

demos/example-nextjs/src/components/SyncedContent.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,14 @@
33
import { CustomerList } from '@/components/CustomerList';
44
import { StatusPanel } from '@/components/StatusPanel';
55
import { useStatus } from '@powersync/react';
6-
import Image from 'next/image';
76

87
export function SyncedContent() {
98
const status = useStatus();
109

1110
if (!status.hasSynced) {
1211
return (
13-
<div className="flex flex-col items-center gap-2 py-24">
14-
<Image src="/powersync-logo.svg" alt="PowerSync" width={220} height={34} priority />
15-
<div className="mt-4 size-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
12+
<div className="flex flex-col items-center gap-2 py-12">
13+
<div className="size-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
1614
<p className="mt-2 text-sm text-text-muted">Connecting…</p>
1715
</div>
1816
);
Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,47 @@
1-
import { exportJWK, generateKeyPair as joseGenerateKeyPair, importJWK, KeyLike } from 'jose';
1+
import { importJWK, type JWK, type KeyLike } from 'jose';
22

3-
interface KeyPair {
3+
// For production, consider caching the imported key to avoid re-parsing on every request.
4+
export async function getKeyPair(): Promise<{
45
privateKey: KeyLike;
5-
publicJwk: JsonWebKey & { kid: string; alg: string };
6-
}
7-
8-
/**
9-
* Generates a key pair if none is available on the env.
10-
* Uses globalThis to survive Next.js HMR — without this, dev-mode module
11-
* reloads regenerate the keys while the PowerSync service still holds the
12-
* old public key, causing "signature verification failed" errors.
13-
*/
14-
async function ensureKeyPair(): Promise<KeyPair> {
15-
const g = globalThis as Record<string, unknown>;
16-
if (g.__powersync_keypair) return g.__powersync_keypair as KeyPair;
17-
6+
publicJwk: JWK & { kid: string; alg: string };
7+
alg: string;
8+
kid: string;
9+
}> {
1810
const envPrivate = process.env.POWERSYNC_PRIVATE_KEY;
1911
const envPublic = process.env.POWERSYNC_PUBLIC_KEY;
2012

21-
let privateKey: KeyLike;
22-
let publicJwk: JsonWebKey & { kid: string; alg: string };
13+
if (!envPrivate || !envPublic) {
14+
throw new Error(
15+
'POWERSYNC_PRIVATE_KEY and POWERSYNC_PUBLIC_KEY are not set in .env.local. Run `pnpm generate-keys` and paste the output into .env.local, then restart the dev server.'
16+
);
17+
}
18+
19+
const privateJwk = parseJwk('POWERSYNC_PRIVATE_KEY', envPrivate);
20+
const publicJwk = parseJwk('POWERSYNC_PUBLIC_KEY', envPublic);
2321

24-
if (envPrivate && envPublic) {
25-
const privateJwk = JSON.parse(Buffer.from(envPrivate, 'base64').toString());
26-
privateKey = (await importJWK(privateJwk)) as KeyLike;
27-
publicJwk = JSON.parse(Buffer.from(envPublic, 'base64').toString());
28-
} else {
29-
console.warn('POWERSYNC_PRIVATE_KEY not set. Generating a temporary key pair (will not survive restarts).');
30-
const generated = await joseGenerateKeyPair('RS256', { extractable: true });
31-
privateKey = generated.privateKey;
32-
publicJwk = (await exportJWK(generated.publicKey)) as JsonWebKey & { kid: string; alg: string };
33-
publicJwk.alg = 'RS256';
34-
publicJwk.kid = 'powersync-anon-key';
22+
if (privateJwk.kid !== publicJwk.kid) {
23+
throw new Error(
24+
`POWERSYNC_PRIVATE_KEY and POWERSYNC_PUBLIC_KEY have mismatched kids (${privateJwk.kid} vs ${publicJwk.kid}). Run \`pnpm generate-keys\` to create a matching pair.`
25+
);
3526
}
3627

37-
const pair: KeyPair = { privateKey, publicJwk };
38-
g.__powersync_keypair = pair;
39-
return pair;
28+
const privateKey = (await importJWK(privateJwk)) as KeyLike;
29+
return { privateKey, publicJwk, alg: privateJwk.alg, kid: privateJwk.kid };
4030
}
4131

42-
export { ensureKeyPair as getKeyPair };
32+
function parseJwk(name: string, base64: string): JWK & { kid: string; alg: string } {
33+
let parsed: unknown;
34+
try {
35+
parsed = JSON.parse(Buffer.from(base64, 'base64').toString());
36+
} catch {
37+
throw new Error(`${name} could not be decoded. Run \`pnpm generate-keys\` and paste the output into .env.local.`);
38+
}
39+
40+
const jwk = parsed as Partial<JWK> & { kid?: string; alg?: string };
41+
if (!jwk || typeof jwk !== 'object' || !jwk.kty || !jwk.alg || !jwk.kid) {
42+
throw new Error(
43+
`${name} is missing required JWK fields (kty, alg, kid). Run \`pnpm generate-keys\` to create a fresh pair.`
44+
);
45+
}
46+
return jwk as JWK & { kid: string; alg: string };
47+
}

0 commit comments

Comments
 (0)