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 | |
| 14 | import { createHash, randomBytes } from 'node:crypto'; |
| 15 | import { SignJWT, jwtVerify, type JWTPayload } from 'jose'; |
| 16 | |
| 17 | import { env } from '../env.js'; |
| 18 | import { runInProjectDatabase } from '../db/data-plane.js'; |
| 19 | import { log } from '../lib/logger.js'; |
| 20 | |
| 21 | const ISSUER = 'briven-auth'; |
| 22 | const AUDIENCE = 'briven-auth-signin'; |
| 23 | const DEFAULT_TTL_MINUTES = 60; |
| 24 | |
| 25 | function 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 | |
| 35 | function hashToken(token: string): string { |
| 36 | return createHash('sha256').update(token).digest('hex'); |
| 37 | } |
| 38 | |
| 39 | export interface SigninTokenPayload extends JWTPayload { |
| 40 | sub: string; |
| 41 | scope: 'signin-token'; |
| 42 | jti: string; |
| 43 | } |
| 44 | |
| 45 | export 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 | */ |
| 54 | export 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 | */ |
| 91 | export 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 | |
| 166 | export class SigninTokenError extends Error { |
| 167 | constructor( |
| 168 | public readonly code: string, |
| 169 | message: string, |
| 170 | ) { |
| 171 | super(message); |
| 172 | this.name = 'SigninTokenError'; |
| 173 | } |
| 174 | } |