auth-signin-tokens.ts174 lines · main
1/**
2 * Sign-in tokens — single-use JWTs for programmatic session creation.
3 *
4 * Admin/backend creates a token for a user. The token is a short-lived JWT
5 * (1 hour default) that can be exchanged exactly once for a real session.
6 * Typical use cases: support impersonation, email-less magic links, or
7 * backend-initiated sign-in flows.
8 *
9 * Security: the JWT carries the user id in `sub` and a unique `jti`. The
10 * jti is stored in `_briven_auth_signin_tokens` and marked `used_at` on
11 * exchange. Replaying a used token returns 410 Gone.
12 */
13
14import { createHash, randomBytes } from 'node:crypto';
15import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
16
17import { env } from '../env.js';
18import { runInProjectDatabase } from '../db/data-plane.js';
19import { log } from '../lib/logger.js';
20
21const ISSUER = 'briven-auth';
22const AUDIENCE = 'briven-auth-signin';
23const DEFAULT_TTL_MINUTES = 60;
24
25function secretBytes(): Uint8Array {
26 const secret = env.BRIVEN_BETTER_AUTH_SECRET;
27 if (!secret) {
28 throw new Error(
29 'BRIVEN_BETTER_AUTH_SECRET is not set — refusing to sign/verify sign-in tokens with an empty key',
30 );
31 }
32 return new TextEncoder().encode(secret);
33}
34
35function hashToken(token: string): string {
36 return createHash('sha256').update(token).digest('hex');
37}
38
39export interface SigninTokenPayload extends JWTPayload {
40 sub: string;
41 scope: 'signin-token';
42 jti: string;
43}
44
45export interface CreatedSigninToken {
46 token: string;
47 expiresAt: Date;
48}
49
50/**
51 * Create a single-use sign-in token for a user. Returns the plaintext JWT
52 * exactly once — the caller is responsible for delivering it securely.
53 */
54export async function createSigninToken(
55 projectId: string,
56 userId: string,
57 opts: { ttlMinutes?: number } = {},
58): Promise<CreatedSigninToken> {
59 const ttlMinutes = opts.ttlMinutes ?? DEFAULT_TTL_MINUTES;
60 const jti = randomBytes(16).toString('hex');
61 const expiresAt = new Date(Date.now() + ttlMinutes * 60_000);
62
63 const token = await new SignJWT({ scope: 'signin-token', jti })
64 .setProtectedHeader({ alg: 'HS256' })
65 .setSubject(userId)
66 .setIssuer(ISSUER)
67 .setAudience(AUDIENCE)
68 .setIssuedAt()
69 .setExpirationTime(`${ttlMinutes}m`)
70 .sign(secretBytes());
71
72 const tokenHash = hashToken(token);
73
74 await runInProjectDatabase(projectId, async (tx) => {
75 await tx.unsafe(
76 `INSERT INTO "_briven_auth_signin_tokens" (id, user_id, token_hash, expires_at, created_at, updated_at)
77 VALUES ($1, $2, $3, $4, now(), now())`,
78 [jti, userId, tokenHash, expiresAt.toISOString()] as never,
79 );
80 });
81
82 return { token, expiresAt };
83}
84
85/**
86 * Exchange a sign-in token for a session. Verifies the JWT, checks it has
87 * not been used, marks it used, and creates a session row.
88 *
89 * Returns the session token to be set as a cookie by the caller.
90 */
91export async function exchangeSigninToken(
92 projectId: string,
93 token: string,
94 opts: { userAgent?: string | null } = {},
95): Promise<{ sessionToken: string; expiresAt: Date }> {
96 let payload: SigninTokenPayload;
97 try {
98 const verified = await jwtVerify(token, secretBytes(), {
99 issuer: ISSUER,
100 audience: AUDIENCE,
101 });
102 payload = verified.payload as SigninTokenPayload;
103 } catch (err) {
104 log.warn('briven_auth_signin_token_verify_failed', {
105 projectId,
106 message: err instanceof Error ? err.message : String(err),
107 });
108 throw new SigninTokenError('invalid_or_expired_token', 'token is invalid or expired');
109 }
110
111 if (payload.scope !== 'signin-token') {
112 throw new SigninTokenError('invalid_or_expired_token', 'token scope mismatch');
113 }
114
115 const tokenHash = hashToken(token);
116 const userId = payload.sub;
117 const jti = payload.jti;
118
119 const result = await runInProjectDatabase<{ sessionToken: string; expiresAt: Date }>(
120 projectId,
121 async (tx) => {
122 const rows = await tx.unsafe(
123 `SELECT id, user_id, used_at
124 FROM "_briven_auth_signin_tokens"
125 WHERE token_hash = $1
126 LIMIT 1`,
127 [tokenHash] as never,
128 ) as Array<{ id: string; user_id: string; used_at: Date | null }>;
129
130 const row = rows[0];
131 if (!row) {
132 throw new SigninTokenError('invalid_or_expired_token', 'token not found');
133 }
134 if (row.used_at) {
135 throw new SigninTokenError('token_already_used', 'token has already been used');
136 }
137 if (row.user_id !== userId) {
138 throw new SigninTokenError('invalid_or_expired_token', 'token user mismatch');
139 }
140
141 // Mark as used.
142 await tx.unsafe(
143 `UPDATE "_briven_auth_signin_tokens"
144 SET used_at = now(), updated_at = now()
145 WHERE id = $1`,
146 [jti] as never,
147 );
148
149 // Create a session. Generate a random session token and insert into
150 // Better Auth's session table so the rest of the auth stack recognizes it.
151 const sessionToken = randomBytes(32).toString('hex');
152 const sessionExpiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7); // 7 days
153
154 await tx.unsafe(
155 `INSERT INTO "_briven_auth_sessions" (id, user_id, token, expires_at, user_agent, created_at, updated_at)
156 VALUES ($1, $2, $3, $4, $5, now(), now())`,
157 [randomBytes(16).toString('hex'), userId, sessionToken, sessionExpiresAt.toISOString(), opts.userAgent ?? null] as never,
158 );
159
160 return { sessionToken, expiresAt: sessionExpiresAt };
161 });
162
163 return result;
164}
165
166export class SigninTokenError extends Error {
167 constructor(
168 public readonly code: string,
169 message: string,
170 ) {
171 super(message);
172 this.name = 'SigninTokenError';
173 }
174}