cli-jwt.ts50 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 if (typeof payload.sub !== 'string' || !payload.sub.startsWith('u_')) {
47 throw new Error('cli-jwt: missing or invalid subject');
48 }
49 return payload as CliTokenPayload;
50}