cli-jwt.ts58 lines · main
1import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
2
3import { env } from '../env.js';
4
5const ISSUER = 'briven-api';
6const AUDIENCE = 'briven-cli';
7const TTL_SECONDS = 60 * 60 * 24;
8
9export interface CliTokenPayload extends JWTPayload {
10 sub: string;
11 scope: 'cli';
12}
13
14function secretBytes(): Uint8Array {
15 // Fail closed (sprint S2.8): if the secret is unset, encoding `undefined`
16 // would yield a CONSTANT key — anyone could forge a CLI token. Refuse to
17 // sign or verify rather than fall back to a guessable key.
18 const secret = env.BRIVEN_BETTER_AUTH_SECRET;
19 if (!secret) {
20 throw new Error(
21 'BRIVEN_BETTER_AUTH_SECRET is not set — refusing to sign/verify CLI tokens with an empty key',
22 );
23 }
24 return new TextEncoder().encode(secret);
25}
26
27export async function signCliToken(userId: string): Promise<string> {
28 return new SignJWT({ scope: 'cli' })
29 .setProtectedHeader({ alg: 'HS256' })
30 .setSubject(userId)
31 .setIssuer(ISSUER)
32 .setAudience(AUDIENCE)
33 .setIssuedAt()
34 .setExpirationTime(`${TTL_SECONDS}s`)
35 .sign(secretBytes());
36}
37
38export async function verifyCliToken(token: string): Promise<CliTokenPayload> {
39 const { payload } = await jwtVerify(token, secretBytes(), {
40 issuer: ISSUER,
41 audience: AUDIENCE,
42 });
43 if (payload.scope !== 'cli') {
44 throw new Error('cli-jwt: wrong scope');
45 }
46 // Platform users (Better Auth) use opaque nanoid ids, not u_… engine ids.
47 // The users-table lookup in requireAuth is the real existence check.
48 // Reject empty / absurd subjects only (flndrn 2026-07-29: invalid cli token
49 // after successful Allow because sub was WUKq… without a u_ prefix).
50 if (
51 typeof payload.sub !== 'string' ||
52 payload.sub.length < 8 ||
53 payload.sub.length > 128
54 ) {
55 throw new Error('cli-jwt: missing or invalid subject');
56 }
57 return payload as CliTokenPayload;
58}