@crombolo/auth 0.13.2 → 0.14.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/auth",
3
- "version": "0.13.2",
3
+ "version": "0.14.4",
4
4
  "description": "OAuth2 authorization server, password hashing, cookies and member tickets for crombolo.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -28,7 +28,9 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@crombolo/core": "0.13.2",
32
- "@crombolo/data": "0.13.2"
31
+ "@crombolo/core": "0.14.4",
32
+ "@crombolo/data": "0.14.4",
33
+ "jose": "^6.2.12",
34
+ "uqr": "^0.1.3"
33
35
  }
34
36
  }
package/src/cookies.ts CHANGED
@@ -17,6 +17,8 @@ export interface CookieNames {
17
17
  accessToken: string
18
18
  refreshToken: string
19
19
  session: string
20
+ /** A sign-in whose password was right, waiting for its two-factor code. */
21
+ twoFactorPending: string
20
22
  }
21
23
 
22
24
  export interface CookieOptions {
@@ -32,6 +34,7 @@ export function cookieNames(options: CookieOptions): CookieNames {
32
34
  accessToken: `${prefix}umbAccessToken${options.siteName}`,
33
35
  refreshToken: `${prefix}umbRefreshToken${options.siteName}`,
34
36
  session: `${prefix}umbSession${options.siteName}`,
37
+ twoFactorPending: `${prefix}umbTwoFactor${options.siteName}`,
35
38
  }
36
39
  }
37
40
 
package/src/device.ts ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The OAuth 2.0 device authorization grant (RFC 8628), for the `crombolo` CLI
3
+ * signing in to a remote (story 107): the CLI shows a short code, the person
4
+ * approves it in a browser where they are already signed in, and the CLI
5
+ * receives tokens for that person, with that person's permissions.
6
+ *
7
+ * The device code is a bearer credential and is stored hashed; the user code is
8
+ * what a person types, short and unambiguous, and is single-use. An approved
9
+ * authorization becomes a session of its own, so "signed-in devices" is the
10
+ * list of those sessions and revoking one ends it.
11
+ */
12
+ import { randomInt } from 'node:crypto'
13
+ import { type Db, DbDate } from '@crombolo/data'
14
+ import { generateToken, hashToken } from './pkce.ts'
15
+
16
+ /** The client id the CLI signs in as. */
17
+ export const REMOTE_CLIENT_ID = 'crombolo-cli'
18
+ export const DEVICE_CODE_SECONDS = 10 * 60
19
+ export const DEVICE_POLL_SECONDS = 5
20
+
21
+ /** No vowels or look-alikes, so a code never spells a word or reads two ways. */
22
+ const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRSTVWXZ'
23
+
24
+ export type DeviceStatus = 'pending' | 'approved' | 'denied' | 'issued'
25
+
26
+ export interface DeviceAuthorizationRow {
27
+ id: number
28
+ userCode: string
29
+ clientId: string
30
+ clientName: string | null
31
+ scope: string
32
+ status: DeviceStatus
33
+ userId: number | null
34
+ sessionId: string | null
35
+ intervalSeconds: number
36
+ lastPolledAt: Date | undefined
37
+ expiresAt: Date
38
+ decidedAt: Date | undefined
39
+ createdAt: Date
40
+ }
41
+
42
+ export interface SignedInDevice {
43
+ id: number
44
+ clientName: string | null
45
+ signedInAt: Date
46
+ lastUsedAt: Date | undefined
47
+ }
48
+
49
+ function userCode(): string {
50
+ const pick = () => USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)] as string
51
+ return `${Array.from({ length: 4 }, pick).join('')}-${Array.from({ length: 4 }, pick).join('')}`
52
+ }
53
+
54
+ /** A code as typed: case, spaces and the dash do not matter. */
55
+ export const normaliseUserCode = (code: string): string => {
56
+ const plain = code.toUpperCase().replace(/[^A-Z]/g, '')
57
+ return plain.length === 8 ? `${plain.slice(0, 4)}-${plain.slice(4)}` : plain
58
+ }
59
+
60
+ function mapRow(row: Record<string, unknown>): DeviceAuthorizationRow {
61
+ return {
62
+ id: Number(row.id),
63
+ userCode: String(row.user_code),
64
+ clientId: String(row.client_id),
65
+ clientName:
66
+ row.client_name === null || row.client_name === undefined ? null : String(row.client_name),
67
+ scope: String(row.scope),
68
+ status: String(row.status) as DeviceStatus,
69
+ userId: row.user_id === null || row.user_id === undefined ? null : Number(row.user_id),
70
+ sessionId:
71
+ row.session_id === null || row.session_id === undefined ? null : String(row.session_id),
72
+ intervalSeconds: Number(row.interval_seconds),
73
+ lastPolledAt: DbDate.fromDb(row.last_polled_at),
74
+ expiresAt: DbDate.fromDb(row.expires_at) ?? new Date(0),
75
+ decidedAt: DbDate.fromDb(row.decided_at),
76
+ createdAt: DbDate.fromDb(row.create_date) ?? new Date(0),
77
+ }
78
+ }
79
+
80
+ export class DeviceStore {
81
+ #db: Db
82
+
83
+ constructor(db: Db) {
84
+ this.#db = db
85
+ }
86
+
87
+ async create(init: {
88
+ clientId: string
89
+ clientName: string | null
90
+ scope: string
91
+ }): Promise<{ deviceCode: string; userCode: string; expiresAt: Date }> {
92
+ const deviceCode = generateToken()
93
+ const expiresAt = new Date(Date.now() + DEVICE_CODE_SECONDS * 1000)
94
+ // A clash with a live code is vanishingly rare; another draw settles it.
95
+ for (let attempt = 0; ; attempt++) {
96
+ const code = userCode()
97
+ try {
98
+ await this.#db.exec(
99
+ `INSERT INTO device_authorization
100
+ (device_code_hash, user_code, client_id, client_name, scope, status, interval_seconds, expires_at, create_date)
101
+ VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?)`,
102
+ [
103
+ hashToken(deviceCode),
104
+ code,
105
+ init.clientId,
106
+ init.clientName,
107
+ init.scope,
108
+ DEVICE_POLL_SECONDS,
109
+ DbDate.toDb(expiresAt),
110
+ DbDate.toDb(new Date()),
111
+ ],
112
+ )
113
+ return { deviceCode, userCode: code, expiresAt }
114
+ } catch (error) {
115
+ if (attempt >= 4) throw error
116
+ }
117
+ }
118
+ }
119
+
120
+ async byDeviceCode(deviceCode: string): Promise<DeviceAuthorizationRow | undefined> {
121
+ const rows = await this.#db.query(
122
+ 'SELECT * FROM device_authorization WHERE device_code_hash = ?',
123
+ [hashToken(deviceCode)],
124
+ )
125
+ return rows[0] ? mapRow(rows[0]) : undefined
126
+ }
127
+
128
+ async byUserCode(code: string): Promise<DeviceAuthorizationRow | undefined> {
129
+ const rows = await this.#db.query('SELECT * FROM device_authorization WHERE user_code = ?', [
130
+ normaliseUserCode(code),
131
+ ])
132
+ return rows[0] ? mapRow(rows[0]) : undefined
133
+ }
134
+
135
+ async polled(id: number, intervalSeconds: number): Promise<void> {
136
+ await this.#db.exec(
137
+ 'UPDATE device_authorization SET last_polled_at = ?, interval_seconds = ? WHERE id = ?',
138
+ [DbDate.toDb(new Date()), intervalSeconds, id],
139
+ )
140
+ }
141
+
142
+ /** Approves or denies a pending one; false when it was no longer pending. */
143
+ async decide(id: number, userId: number, approve: boolean): Promise<boolean> {
144
+ const rows = await this.#db.query<{ id: number }>(
145
+ `UPDATE device_authorization SET status = ?, user_id = ?, decided_at = ?
146
+ WHERE id = ? AND status = 'pending' RETURNING id`,
147
+ [approve ? 'approved' : 'denied', userId, DbDate.toDb(new Date()), id],
148
+ )
149
+ return rows.length > 0
150
+ }
151
+
152
+ /** Marks an approved one as having had its tokens issued, once; false if it already had. */
153
+ async issued(id: number, sessionId: string): Promise<boolean> {
154
+ const rows = await this.#db.query<{ id: number }>(
155
+ `UPDATE device_authorization SET status = 'issued', session_id = ?
156
+ WHERE id = ? AND status = 'approved' RETURNING id`,
157
+ [sessionId, id],
158
+ )
159
+ return rows.length > 0
160
+ }
161
+
162
+ /** The devices signed in as this user, whose sessions are still open. */
163
+ async signedIn(userId: number): Promise<SignedInDevice[]> {
164
+ const rows = await this.#db.query<Record<string, unknown>>(
165
+ `SELECT d.id, d.client_name, s.logged_in_utc, s.last_validated_utc
166
+ FROM device_authorization d JOIN user_login_session s ON s.session_id = d.session_id
167
+ WHERE d.user_id = ? AND d.status = 'issued' AND s.logged_out_utc IS NULL
168
+ ORDER BY s.logged_in_utc DESC`,
169
+ [userId],
170
+ )
171
+ return rows.map((row) => ({
172
+ id: Number(row.id),
173
+ clientName: row.client_name === null ? null : String(row.client_name),
174
+ signedInAt: DbDate.fromDb(row.logged_in_utc) ?? new Date(0),
175
+ lastUsedAt: DbDate.fromDb(row.last_validated_utc),
176
+ }))
177
+ }
178
+
179
+ async byId(id: number): Promise<DeviceAuthorizationRow | undefined> {
180
+ const rows = await this.#db.query('SELECT * FROM device_authorization WHERE id = ?', [id])
181
+ return rows[0] ? mapRow(rows[0]) : undefined
182
+ }
183
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  export * from './admin.ts'
2
2
  export * from './cookies.ts'
3
+ export * from './device.ts'
3
4
  export * from './member-session.ts'
5
+ export * from './oidc.ts'
4
6
  export * from './password.ts'
5
7
  export * from './pkce.ts'
6
8
  export * from './routes.ts'
7
9
  export * from './service.ts'
10
+ export * from './short-lived.ts'
8
11
  export * from './store.ts'
12
+ export * from './totp.ts'
9
13
  export * from './umbraco-passwords.ts'
package/src/oidc.ts ADDED
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Signing in through an external identity provider (story 031): OpenID Connect,
3
+ * authorization code with PKCE. Discovery, the redirect and the code exchange
4
+ * are plain `fetch`; the ID token is verified with `jose` against the
5
+ * provider's published keys — signature, issuer, audience, expiry and nonce.
6
+ *
7
+ * What the browser carries between leaving for the provider and coming back —
8
+ * `state`, `nonce`, the PKCE verifier and where to return — is a short-lived
9
+ * token signed with the site's own key, so nothing is stored server side.
10
+ */
11
+ import { createRemoteJWKSet, type JWTPayload, jwtVerify, SignJWT } from 'jose'
12
+ import { deriveChallenge, generateToken } from './pkce.ts'
13
+
14
+ /** How a provider's claims are read. Each names a claim in the ID token. */
15
+ export interface OidcClaims {
16
+ email: string
17
+ name: string
18
+ /** Where group or role values come from, for mapping onto groups. */
19
+ groups?: string
20
+ }
21
+
22
+ /** One provider, as a preset describes it. */
23
+ export interface OidcProviderOptions {
24
+ /** The provider's id: what `external_login` records, and part of its URLs. */
25
+ id: string
26
+ /** What a sign-in button says: "Sign in with {name}". */
27
+ name: string
28
+ issuer: string
29
+ clientId: string
30
+ clientSecret?: string
31
+ scopes: readonly string[]
32
+ claims: OidcClaims
33
+ }
34
+
35
+ /** Who the provider says signed in. */
36
+ export interface OidcIdentity {
37
+ /** The provider's stable id for the account (`sub`). */
38
+ subject: string
39
+ email: string | undefined
40
+ emailVerified: boolean
41
+ name: string | undefined
42
+ /** The values of the provider's groups claim, when it names one. */
43
+ groups: string[]
44
+ claims: JWTPayload
45
+ }
46
+
47
+ interface Discovery {
48
+ issuer: string
49
+ authorization_endpoint: string
50
+ token_endpoint: string
51
+ jwks_uri: string
52
+ }
53
+
54
+ /** What a sign-in carries while the browser is away at the provider. */
55
+ export interface OidcFlowState {
56
+ provider: string
57
+ state: string
58
+ nonce: string
59
+ verifier: string
60
+ returnUrl: string
61
+ }
62
+
63
+ const DISCOVERY_TTL_MS = 60 * 60 * 1000
64
+ const FLOW_SECONDS = 10 * 60
65
+
66
+ export class OidcError extends Error {}
67
+
68
+ const stringClaim = (claims: JWTPayload, name: string): string | undefined => {
69
+ const value = claims[name]
70
+ return typeof value === 'string' && value ? value : undefined
71
+ }
72
+
73
+ const listClaim = (claims: JWTPayload, name: string | undefined): string[] => {
74
+ if (!name) return []
75
+ const value = claims[name]
76
+ if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string')
77
+ return typeof value === 'string' && value ? [value] : []
78
+ }
79
+
80
+ /** A provider's sign-in, from discovery to a verified identity. */
81
+ export class OidcClient {
82
+ readonly options: OidcProviderOptions
83
+ #discovery: { value: Discovery; at: number } | undefined
84
+ #keys: ReturnType<typeof createRemoteJWKSet> | undefined
85
+
86
+ constructor(options: OidcProviderOptions) {
87
+ this.options = options
88
+ }
89
+
90
+ /** The provider's endpoints, from its discovery document, cached for an hour. */
91
+ async discovery(): Promise<Discovery> {
92
+ if (this.#discovery && Date.now() - this.#discovery.at < DISCOVERY_TTL_MS)
93
+ return this.#discovery.value
94
+ const url = `${this.options.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`
95
+ const response = await fetch(url, { headers: { accept: 'application/json' } })
96
+ if (!response.ok)
97
+ throw new OidcError(`${this.options.name} discovery failed: ${response.status} from ${url}`)
98
+ const value = (await response.json()) as Discovery
99
+ for (const field of ['authorization_endpoint', 'token_endpoint', 'jwks_uri'] as const)
100
+ if (typeof value[field] !== 'string')
101
+ throw new OidcError(`${this.options.name} discovery has no ${field}`)
102
+ this.#discovery = { value, at: Date.now() }
103
+ this.#keys = createRemoteJWKSet(new URL(value.jwks_uri))
104
+ return value
105
+ }
106
+
107
+ /** A new flow: its state, nonce and PKCE verifier, and the URL to send the browser to. */
108
+ async start(
109
+ redirectUri: string,
110
+ returnUrl: string,
111
+ ): Promise<{ url: string; flow: OidcFlowState }> {
112
+ const { authorization_endpoint } = await this.discovery()
113
+ const flow: OidcFlowState = {
114
+ provider: this.options.id,
115
+ state: generateToken(),
116
+ nonce: generateToken(),
117
+ verifier: generateToken(),
118
+ returnUrl,
119
+ }
120
+ const url = new URL(authorization_endpoint)
121
+ url.searchParams.set('response_type', 'code')
122
+ url.searchParams.set('client_id', this.options.clientId)
123
+ url.searchParams.set('redirect_uri', redirectUri)
124
+ url.searchParams.set('scope', this.options.scopes.join(' '))
125
+ url.searchParams.set('state', flow.state)
126
+ url.searchParams.set('nonce', flow.nonce)
127
+ url.searchParams.set('code_challenge', await deriveChallenge(flow.verifier))
128
+ url.searchParams.set('code_challenge_method', 'S256')
129
+ return { url: url.href, flow }
130
+ }
131
+
132
+ /**
133
+ * The provider's answer, checked against the flow that started it, turned
134
+ * into a verified identity. Throws `OidcError` with a reason fit to log.
135
+ */
136
+ async finish(callback: URL, flow: OidcFlowState, redirectUri: string): Promise<OidcIdentity> {
137
+ const error = callback.searchParams.get('error')
138
+ if (error)
139
+ throw new OidcError(
140
+ `${this.options.name} refused the sign-in: ${callback.searchParams.get('error_description') ?? error}`,
141
+ )
142
+ if (callback.searchParams.get('state') !== flow.state)
143
+ throw new OidcError('the sign-in came back with a state it did not start with')
144
+ const code = callback.searchParams.get('code')
145
+ if (!code) throw new OidcError(`${this.options.name} sent no code`)
146
+
147
+ const { token_endpoint } = await this.discovery()
148
+ const body = new URLSearchParams({
149
+ grant_type: 'authorization_code',
150
+ code,
151
+ redirect_uri: redirectUri,
152
+ client_id: this.options.clientId,
153
+ code_verifier: flow.verifier,
154
+ })
155
+ if (this.options.clientSecret) body.set('client_secret', this.options.clientSecret)
156
+ const response = await fetch(token_endpoint, {
157
+ method: 'POST',
158
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
159
+ body,
160
+ })
161
+ if (!response.ok)
162
+ throw new OidcError(`${this.options.name} would not exchange the code: ${response.status}`)
163
+ const tokens = (await response.json()) as { id_token?: string }
164
+ if (!tokens.id_token) throw new OidcError(`${this.options.name} sent no ID token`)
165
+ return this.verify(tokens.id_token, flow.nonce)
166
+ }
167
+
168
+ /** An ID token, verified: signed by the provider's keys, for us, current, and this flow's. */
169
+ async verify(idToken: string, nonce: string): Promise<OidcIdentity> {
170
+ const discovery = await this.discovery()
171
+ const keys = this.#keys as ReturnType<typeof createRemoteJWKSet>
172
+ let claims: JWTPayload
173
+ try {
174
+ ;({ payload: claims } = await jwtVerify(idToken, keys, {
175
+ issuer: discovery.issuer ?? this.options.issuer,
176
+ audience: this.options.clientId,
177
+ }))
178
+ } catch (error) {
179
+ throw new OidcError(
180
+ `the ID token from ${this.options.name} is not valid: ${(error as Error).message}`,
181
+ )
182
+ }
183
+ if (claims.nonce !== nonce) throw new OidcError('the ID token was not issued for this sign-in')
184
+ if (!claims.sub) throw new OidcError(`the ID token from ${this.options.name} names no subject`)
185
+ const verified = claims.email_verified
186
+ return {
187
+ subject: claims.sub,
188
+ email: stringClaim(claims, this.options.claims.email)?.toLowerCase(),
189
+ emailVerified: verified === true || verified === 'true',
190
+ name: stringClaim(claims, this.options.claims.name),
191
+ groups: listClaim(claims, this.options.claims.groups),
192
+ claims,
193
+ }
194
+ }
195
+ }
196
+
197
+ const secretKey = (secret: string) => new TextEncoder().encode(secret)
198
+
199
+ /** The flow, signed, for the cookie the browser carries to the provider and back. */
200
+ export async function signFlowState(secret: string, flow: OidcFlowState): Promise<string> {
201
+ return new SignJWT({ ...flow })
202
+ .setProtectedHeader({ alg: 'HS256' })
203
+ .setIssuedAt()
204
+ .setExpirationTime(`${FLOW_SECONDS}s`)
205
+ .sign(secretKey(secret))
206
+ }
207
+
208
+ /** The flow back from its cookie, or undefined when it is missing, altered or stale. */
209
+ export async function readFlowState(
210
+ secret: string,
211
+ value: string | undefined,
212
+ ): Promise<OidcFlowState | undefined> {
213
+ if (!value) return undefined
214
+ try {
215
+ const { payload } = await jwtVerify(value, secretKey(secret), { algorithms: ['HS256'] })
216
+ const flow = payload as unknown as OidcFlowState
217
+ for (const field of ['provider', 'state', 'nonce', 'verifier', 'returnUrl'] as const)
218
+ if (typeof flow[field] !== 'string') return undefined
219
+ return flow
220
+ } catch {
221
+ return undefined
222
+ }
223
+ }
package/src/routes.ts CHANGED
@@ -15,18 +15,33 @@ import {
15
15
  resolveRedacted,
16
16
  serializeCookie,
17
17
  } from './cookies.ts'
18
+ import { REMOTE_CLIENT_ID } from './device.ts'
18
19
  import type { AuthService } from './service.ts'
19
20
  import { BACKOFFICE_CLIENT_ID, DEFAULT_SCOPE } from './service.ts'
21
+ import { readShortLived, signShortLived } from './short-lived.ts'
20
22
  import type { UserRow } from './store.ts'
21
23
 
24
+ /** How long a sign-in waits for its two-factor code. */
25
+ const TWO_FACTOR_PENDING_SECONDS = 5 * 60
26
+
22
27
  export const SECURITY_PREFIX = `${MANAGEMENT_API_PATH}/security/back-office`
23
28
 
29
+ /** RFC 8628's grant type. */
30
+ export const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
31
+
24
32
  export interface AuthRouteOptions {
25
33
  backOfficePath: string
26
34
  service: AuthService
27
35
  cookies: CookieOptions
28
36
  /** Told about every sign-in, failed sign-in and sign-out, for the audit log. */
29
37
  onAudit?: (event: AuthAuditEvent, request: Request) => Promise<void>
38
+ /** Two-factor sign-in (story 032): who has a provider on, and whether a code is theirs. */
39
+ twoFactor?: {
40
+ providersFor(userKey: string): Promise<string[]>
41
+ verify(userKey: string, provider: string, code: string): Promise<boolean>
42
+ /** The key the pending cookie is signed with. */
43
+ secret(): Promise<string>
44
+ }
30
45
  }
31
46
 
32
47
  export interface AuthAuditEvent {
@@ -175,6 +190,32 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
175
190
  })
176
191
  }
177
192
 
193
+ // The CLI (story 107): tokens in the body, as a client with no cookie jar needs.
194
+ if (clientId === REMOTE_CLIENT_ID) {
195
+ const result =
196
+ grantType === DEVICE_CODE_GRANT
197
+ ? await service.pollDevice(form.get('device_code') ?? '', clientId)
198
+ : grantType === 'refresh_token'
199
+ ? await service.refresh(form.get('refresh_token') ?? '', clientId)
200
+ : {
201
+ ok: false as const,
202
+ error: 'unsupported_grant_type' as const,
203
+ description: `Unsupported grant_type '${grantType}'.`,
204
+ }
205
+ if (!result.ok) return oauthError(result.error, result.description, 400)
206
+ const signedIn = (result as { user?: UserRow }).user
207
+ if (grantType === DEVICE_CODE_GRANT && signedIn)
208
+ await audit({ action: 'SignIn', user: signedIn, detail: 'Device sign-in' }, request)
209
+ return Response.json({
210
+ access_token: result.grant.accessToken,
211
+ refresh_token: result.grant.refreshToken,
212
+ token_type: 'Bearer',
213
+ expires_in: result.grant.expiresIn,
214
+ issued_at: result.grant.issuedAt,
215
+ scope: DEFAULT_SCOPE,
216
+ })
217
+ }
218
+
178
219
  if (clientId !== BACKOFFICE_CLIENT_ID) {
179
220
  return oauthError('invalid_client', `Unknown client '${clientId}'.`)
180
221
  }
@@ -281,6 +322,9 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
281
322
  username,
282
323
  password,
283
324
  request.headers.get('x-forwarded-for') ?? undefined,
325
+ options.twoFactor
326
+ ? async (user) => (await options.twoFactor?.providersFor(user.key)) ?? []
327
+ : undefined,
284
328
  )
285
329
 
286
330
  if (result.status === 'success') await audit({ action: 'SignIn', user: result.user }, request)
@@ -322,12 +366,31 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
322
366
  problemDetails({ title: 'Login not allowed', status: 403, operationStatus: result.reason }),
323
367
  )
324
368
  }
325
- if (result.status === 'twoFactorRequired') {
369
+ if (result.status === 'twoFactorRequired' && options.twoFactor) {
370
+ // The password was right; the session waits for the code. The pending
371
+ // cookie names who is part-way in, for five minutes.
372
+ const pending = await signShortLived(
373
+ await options.twoFactor.secret(),
374
+ { sub: result.user.key, login: result.user.login },
375
+ TWO_FACTOR_PENDING_SECONDS,
376
+ )
326
377
  return Response.json(
327
378
  { twoFactorLoginView: null, enabledTwoFactorProviderNames: result.providers },
328
- { status: 402 },
379
+ {
380
+ status: 402,
381
+ headers: {
382
+ 'set-cookie': serializeCookie({
383
+ name: names.twoFactorPending,
384
+ value: pending,
385
+ secure,
386
+ maxAgeSeconds: TWO_FACTOR_PENDING_SECONDS,
387
+ }),
388
+ },
389
+ },
329
390
  )
330
391
  }
392
+ if (result.status === 'twoFactorRequired')
393
+ return oauthError('server_error', 'Two-factor sign-in is not set up.', 500)
331
394
 
332
395
  return new Response(null, {
333
396
  status: 200,
@@ -337,6 +400,60 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
337
400
  })
338
401
  }
339
402
 
403
+ /** The second step of a sign-in: the code, against the pending cookie the password set. */
404
+ async function handleVerifyTwoFactor(request: Request): Promise<Response> {
405
+ const twoFactor = options.twoFactor
406
+ if (!twoFactor)
407
+ return problemResponse(
408
+ problemDetails({ title: 'Two-factor sign-in is not set up', status: 400 }),
409
+ )
410
+ const pending = await readShortLived(
411
+ await twoFactor.secret(),
412
+ readCookie(request, names.twoFactorPending),
413
+ )
414
+ const userKey = typeof pending?.sub === 'string' ? pending.sub : undefined
415
+ const login = typeof pending?.login === 'string' ? pending.login : undefined
416
+ if (!userKey || !login)
417
+ return problemResponse(
418
+ problemDetails({
419
+ title: 'Sign in again',
420
+ status: 401,
421
+ detail: 'The sign-in took too long. Enter your password again.',
422
+ }),
423
+ )
424
+ const body = (await request.json().catch(() => ({}))) as { provider?: unknown; code?: unknown }
425
+ const provider = typeof body.provider === 'string' ? body.provider : ''
426
+ const code = typeof body.code === 'string' ? body.code.trim() : ''
427
+ const ok = provider && code ? await twoFactor.verify(userKey, provider, code) : false
428
+ if (!ok) {
429
+ const { lockedOut } = await service.twoFactorFailed(login)
430
+ await audit({ action: 'SignInFailed', login, detail: 'Wrong two-factor code' }, request)
431
+ return problemResponse(
432
+ problemDetails({
433
+ title: lockedOut ? 'Login not allowed' : 'Invalid code',
434
+ status: lockedOut ? 403 : 400,
435
+ operationStatus: lockedOut ? 'lockedOut' : 'InvalidCode',
436
+ }),
437
+ )
438
+ }
439
+ const result = await service.signInExternally(
440
+ login,
441
+ request.headers.get('x-forwarded-for') ?? undefined,
442
+ )
443
+ if (result.status !== 'success')
444
+ return problemResponse(
445
+ problemDetails({ title: 'Login not allowed', status: 403, operationStatus: result.reason }),
446
+ )
447
+ await audit({ action: 'SignIn', user: result.user, detail: 'With two-factor sign-in' }, request)
448
+ const headers = new Headers()
449
+ headers.append(
450
+ 'set-cookie',
451
+ sessionCookie(result.sessionId, service.settings.refreshTokenSeconds),
452
+ )
453
+ headers.append('set-cookie', expireCookie(names.twoFactorPending, secure))
454
+ return new Response(null, { status: 200, headers })
455
+ }
456
+
340
457
  async function handleRevoke(request: Request): Promise<Response> {
341
458
  const form = new URLSearchParams(await request.text())
342
459
  const token = resolveRedacted(form.get('token'), readCookie(request, names.accessToken))
@@ -360,6 +477,101 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
360
477
  return response
361
478
  }
362
479
 
480
+ /** RFC 8628's device authorization endpoint: the codes the CLI shows and polls with. */
481
+ async function handleDeviceAuthorization(request: Request, url: URL): Promise<Response> {
482
+ const form = new URLSearchParams(await request.text())
483
+ if ((form.get('client_id') ?? '') !== REMOTE_CLIENT_ID)
484
+ return oauthError('invalid_client', `Unknown client '${form.get('client_id') ?? ''}'.`, 401)
485
+ const name = form.get('client_name')?.slice(0, 200) || null
486
+ const started = await service.startDeviceAuthorization(REMOTE_CLIENT_ID, name)
487
+ const verification = `${url.origin}${options.backOfficePath}/device`
488
+ return Response.json({
489
+ device_code: started.deviceCode,
490
+ user_code: started.userCode,
491
+ verification_uri: verification,
492
+ verification_uri_complete: `${verification}?user_code=${encodeURIComponent(started.userCode)}`,
493
+ expires_in: started.expiresIn,
494
+ interval: started.interval,
495
+ })
496
+ }
497
+
498
+ /** The signed-in back-office person, from the session cookie, or undefined. */
499
+ async function sessionUser(request: Request): Promise<number | undefined> {
500
+ const sessionId = readCookie(request, names.session)
501
+ return sessionId ? (await service.resolveSession(sessionId))?.userId : undefined
502
+ }
503
+
504
+ /** A cookie-authenticated write must come from this site, not a form elsewhere. */
505
+ const sameSite = (request: Request) => {
506
+ const site = request.headers.get('sec-fetch-site')
507
+ if (site) return site === 'same-origin'
508
+ const origin = request.headers.get('origin')
509
+ return !origin || origin === new URL(request.url).origin
510
+ }
511
+
512
+ const signInFirst = () => problemResponse(problemDetails({ title: 'Sign in first', status: 401 }))
513
+
514
+ /** What the approval page shows for a code, and its decision. */
515
+ async function handleDevicePage(
516
+ request: Request,
517
+ url: URL,
518
+ action: string,
519
+ ): Promise<Response | undefined> {
520
+ const userId = await sessionUser(request)
521
+ if (!userId) return signInFirst()
522
+ if (action === 'device/pending' && request.method === 'GET') {
523
+ const pending = await service.pendingDevice(url.searchParams.get('user_code') ?? '')
524
+ if (!pending)
525
+ return problemResponse(
526
+ problemDetails({
527
+ title: 'That code is not waiting',
528
+ status: 404,
529
+ detail:
530
+ 'It may have expired or been used. Start the sign-in again from the command line.',
531
+ }),
532
+ )
533
+ return Response.json({
534
+ userCode: pending.userCode,
535
+ clientName: pending.clientName,
536
+ expiresAt: pending.expiresAt.toISOString(),
537
+ })
538
+ }
539
+ if (action === 'device/decide' && request.method === 'POST') {
540
+ if (!sameSite(request)) return new Response('Forbidden', { status: 403 })
541
+ const body = (await request.json().catch(() => ({}))) as {
542
+ userCode?: unknown
543
+ approve?: unknown
544
+ }
545
+ const decided = await service.decideDevice(
546
+ typeof body.userCode === 'string' ? body.userCode : '',
547
+ userId,
548
+ body.approve === true,
549
+ )
550
+ return decided
551
+ ? new Response(null, { status: 204 })
552
+ : problemResponse(problemDetails({ title: 'That code is not waiting', status: 404 }))
553
+ }
554
+ if (action === 'devices' && request.method === 'GET') {
555
+ const devices = await service.signedInDevices(userId)
556
+ return Response.json(
557
+ devices.map((device) => ({
558
+ id: device.id,
559
+ name: device.clientName,
560
+ signedInAt: device.signedInAt.toISOString(),
561
+ lastUsedAt: device.lastUsedAt?.toISOString() ?? null,
562
+ })),
563
+ )
564
+ }
565
+ const revoking = /^devices\/(\d+)$/.exec(action)
566
+ if (revoking && request.method === 'DELETE') {
567
+ if (!sameSite(request)) return new Response('Forbidden', { status: 403 })
568
+ return (await service.revokeDevice(Number(revoking[1]), userId))
569
+ ? new Response(null, { status: 204 })
570
+ : problemResponse(problemDetails({ title: 'No such device', status: 404 }))
571
+ }
572
+ return undefined
573
+ }
574
+
363
575
  return {
364
576
  async handle(request, pathname) {
365
577
  if (!pathname.startsWith(`${prefix}/`)) return undefined
@@ -371,13 +583,12 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
371
583
  if (action === 'login' && request.method === 'POST') return handleLogin(request)
372
584
  if (action === 'revoke' && request.method === 'POST') return handleRevoke(request)
373
585
  if (action === 'signout') return handleSignOut(request, url)
374
- if (action === 'verify-2fa' && request.method === 'POST') {
375
- return oauthError(
376
- 'unsupported_grant_type',
377
- 'Two-factor authentication is not implemented.',
378
- 400,
379
- )
380
- }
586
+ if (action === 'verify-2fa' && request.method === 'POST')
587
+ return handleVerifyTwoFactor(request)
588
+ if (action === 'device' && request.method === 'POST')
589
+ return handleDeviceAuthorization(request, url)
590
+ if (action.startsWith('device/') || action === 'devices' || action.startsWith('devices/'))
591
+ return handleDevicePage(request, url, action)
381
592
  return undefined
382
593
  },
383
594
  }
package/src/service.ts CHANGED
@@ -3,6 +3,12 @@
3
3
  * actually sends. See docs/backoffice-hosting.md for the wire contract.
4
4
  */
5
5
  import { type ExtensionSectionAlias, type SectionAlias, toSectionAliases } from '@crombolo/core'
6
+ import {
7
+ DEVICE_CODE_SECONDS,
8
+ DEVICE_POLL_SECONDS,
9
+ type DeviceAuthorizationRow,
10
+ type SignedInDevice,
11
+ } from './device.ts'
6
12
  import { hashPassword, isNativeHash, passwordConfigJson, verifyPassword } from './password.ts'
7
13
  import { type CodeChallengeMethod, generateToken, verifyChallenge } from './pkce.ts'
8
14
  import type { AuthStore, UserRow } from './store.ts'
@@ -45,7 +51,7 @@ export const DEFAULT_AUTH_SETTINGS: AuthSettings = {
45
51
  export type LoginResult =
46
52
  | { status: 'success'; user: UserRow; sessionId: string }
47
53
  | { status: 'invalid'; user?: UserRow; lockedOut?: boolean }
48
- | { status: 'twoFactorRequired'; providers: string[] }
54
+ | { status: 'twoFactorRequired'; providers: string[]; user: UserRow }
49
55
  | { status: 'notAllowed'; reason: 'disabled' | 'lockedOut' | 'notApproved'; user?: UserRow }
50
56
 
51
57
  export interface AuthorizationRequest {
@@ -71,6 +77,11 @@ export type TokenError =
71
77
  | 'invalid_request'
72
78
  | 'invalid_client'
73
79
  | 'unauthorized_client'
80
+ // RFC 8628's answers while a device sign-in waits.
81
+ | 'authorization_pending'
82
+ | 'slow_down'
83
+ | 'expired_token'
84
+ | 'access_denied'
74
85
 
75
86
  export type TokenResult =
76
87
  | { ok: true; grant: TokenGrant }
@@ -114,7 +125,17 @@ export class AuthService {
114
125
  return this.#settings
115
126
  }
116
127
 
117
- async login(login: string, password: string, ipAddress?: string): Promise<LoginResult> {
128
+ /**
129
+ * `twoFactorProviders`, when given, names the two-factor providers a user has
130
+ * turned on (story 032): with any, a correct password stops short of a session
131
+ * and the caller asks for a code.
132
+ */
133
+ async login(
134
+ login: string,
135
+ password: string,
136
+ ipAddress?: string,
137
+ twoFactorProviders?: (user: UserRow) => Promise<string[]>,
138
+ ): Promise<LoginResult> {
118
139
  return withMinimumDuration(120, async () => {
119
140
  const user = await this.#store.findUserByLogin(login)
120
141
  if (!user) {
@@ -155,12 +176,50 @@ export class AuthService {
155
176
  passwordConfigJson(),
156
177
  )
157
178
 
179
+ const providers = (await twoFactorProviders?.(user)) ?? []
180
+ if (providers.length > 0) return { status: 'twoFactorRequired', providers, user } as const
181
+
158
182
  const sessionId = crypto.randomUUID()
159
183
  await this.#store.recordLoginSuccess(user.id, sessionId, ipAddress)
160
184
  return { status: 'success', user, sessionId } as const
161
185
  })
162
186
  }
163
187
 
188
+ /**
189
+ * A wrong second factor counts as a wrong password does, towards lockout, so a
190
+ * stolen password cannot be followed by guessing codes for free.
191
+ */
192
+ async twoFactorFailed(login: string): Promise<{ lockedOut: boolean }> {
193
+ const user = await this.#store.findUserByLogin(login)
194
+ if (!user) return { lockedOut: false }
195
+ const attempts = await this.#store.recordLoginFailure(user.id)
196
+ const lockedOut =
197
+ this.#settings.maxFailedLoginAttempts > 0 && attempts >= this.#settings.maxFailedLoginAttempts
198
+ if (lockedOut) await this.#store.lockOut(user.id)
199
+ return { lockedOut }
200
+ }
201
+
202
+ /**
203
+ * Signs a user in whom an external identity provider has vouched for (story
204
+ * 031): no password, but the same refusals a password sign-in has — locked out
205
+ * or not approved — and the same session afterwards.
206
+ */
207
+ async signInExternally(
208
+ login: string,
209
+ ipAddress?: string,
210
+ ): Promise<
211
+ | { status: 'success'; user: UserRow; sessionId: string }
212
+ | { status: 'notAllowed'; reason: 'lockedOut' | 'notApproved' | 'unknown' }
213
+ > {
214
+ const user = await this.#store.findUserByLogin(login)
215
+ if (!user) return { status: 'notAllowed', reason: 'unknown' }
216
+ if (user.isLockedOut) return { status: 'notAllowed', reason: 'lockedOut' }
217
+ if (!user.isApproved) return { status: 'notAllowed', reason: 'notApproved' }
218
+ const sessionId = crypto.randomUUID()
219
+ await this.#store.recordLoginSuccess(user.id, sessionId, ipAddress)
220
+ return { status: 'success', user, sessionId }
221
+ }
222
+
164
223
  /** Issues a single-use authorization code for an already-authenticated session. */
165
224
  async authorize(
166
225
  sessionId: string,
@@ -329,6 +388,83 @@ export class AuthService {
329
388
  return this.signOut(token.sessionId)
330
389
  }
331
390
 
391
+ /** Starts a device sign-in (RFC 8628): the codes, and how often the client may poll. */
392
+ async startDeviceAuthorization(
393
+ clientId: string,
394
+ clientName: string | null,
395
+ ): Promise<{ deviceCode: string; userCode: string; expiresIn: number; interval: number }> {
396
+ const created = await this.#store.devices.create({ clientId, clientName, scope: DEFAULT_SCOPE })
397
+ return {
398
+ deviceCode: created.deviceCode,
399
+ userCode: created.userCode,
400
+ expiresIn: DEVICE_CODE_SECONDS,
401
+ interval: DEVICE_POLL_SECONDS,
402
+ }
403
+ }
404
+
405
+ /** A device sign-in waiting for a person, by the code they typed; undefined once it is not. */
406
+ async pendingDevice(userCode: string): Promise<DeviceAuthorizationRow | undefined> {
407
+ const row = await this.#store.devices.byUserCode(userCode)
408
+ if (row?.status !== 'pending' || row.expiresAt.getTime() < Date.now()) return undefined
409
+ return row
410
+ }
411
+
412
+ /** Approves or denies a waiting device sign-in for the signed-in person. */
413
+ async decideDevice(userCode: string, userId: number, approve: boolean): Promise<boolean> {
414
+ const row = await this.pendingDevice(userCode)
415
+ return row ? this.#store.devices.decide(row.id, userId, approve) : false
416
+ }
417
+
418
+ /**
419
+ * The device's poll: pending until a person decides, then the tokens, once.
420
+ * Polling faster than the interval answers `slow_down` and lengthens it, as
421
+ * RFC 8628 says.
422
+ */
423
+ async pollDevice(
424
+ deviceCode: string,
425
+ clientId: string,
426
+ ): Promise<TokenResult & { user?: UserRow }> {
427
+ const row = await this.#store.devices.byDeviceCode(deviceCode)
428
+ if (!row || row.clientId !== clientId)
429
+ return { ok: false, error: 'invalid_grant', description: 'Unknown device code.' }
430
+ if (row.status === 'denied')
431
+ return { ok: false, error: 'access_denied', description: 'The sign-in was declined.' }
432
+ if (row.status === 'issued')
433
+ return { ok: false, error: 'invalid_grant', description: 'This device code has been used.' }
434
+ if (row.expiresAt.getTime() < Date.now())
435
+ return { ok: false, error: 'expired_token', description: 'The device code has expired.' }
436
+ if (row.status === 'pending') {
437
+ const tooSoon =
438
+ row.lastPolledAt !== undefined &&
439
+ Date.now() - row.lastPolledAt.getTime() < row.intervalSeconds * 1000
440
+ await this.#store.devices.polled(row.id, row.intervalSeconds + (tooSoon ? 5 : 0))
441
+ return tooSoon
442
+ ? { ok: false, error: 'slow_down', description: 'Poll less often.' }
443
+ : { ok: false, error: 'authorization_pending', description: 'Waiting for approval.' }
444
+ }
445
+ const user = row.userId === null ? undefined : await this.#store.findUserById(row.userId)
446
+ if (!user || user.isLockedOut || !user.isApproved)
447
+ return { ok: false, error: 'access_denied', description: 'This account cannot sign in.' }
448
+ const sessionId = crypto.randomUUID()
449
+ if (!(await this.#store.devices.issued(row.id, sessionId)))
450
+ return { ok: false, error: 'invalid_grant', description: 'This device code has been used.' }
451
+ await this.#store.recordLoginSuccess(user.id, sessionId)
452
+ return { ok: true, grant: await this.#issue(user.id, sessionId, clientId, row.scope), user }
453
+ }
454
+
455
+ /** The devices signed in as this user. */
456
+ signedInDevices(userId: number): Promise<SignedInDevice[]> {
457
+ return this.#store.devices.signedIn(userId)
458
+ }
459
+
460
+ /** Signs a device out, ending its session; false when it is not this user's. */
461
+ async revokeDevice(id: number, userId: number): Promise<boolean> {
462
+ const row = await this.#store.devices.byId(id)
463
+ if (!row || row.userId !== userId || !row.sessionId) return false
464
+ await this.#store.endSession(row.sessionId)
465
+ return true
466
+ }
467
+
332
468
  async #issue(
333
469
  userId: number,
334
470
  sessionId: string,
@@ -0,0 +1,33 @@
1
+ /**
2
+ * A small signed token for a step between two requests — the browser holding
3
+ * it while it fetches a two-factor code, say — so nothing is stored server
4
+ * side. HS256 with the site's own key; it expires on its own.
5
+ */
6
+ import { type JWTPayload, jwtVerify, SignJWT } from 'jose'
7
+
8
+ const keyOf = (secret: string) => new TextEncoder().encode(secret)
9
+
10
+ export async function signShortLived(
11
+ secret: string,
12
+ payload: Record<string, string | number>,
13
+ seconds: number,
14
+ ): Promise<string> {
15
+ return new SignJWT(payload)
16
+ .setProtectedHeader({ alg: 'HS256' })
17
+ .setIssuedAt()
18
+ .setExpirationTime(`${seconds}s`)
19
+ .sign(keyOf(secret))
20
+ }
21
+
22
+ /** The payload back, or undefined when the token is missing, altered or expired. */
23
+ export async function readShortLived(
24
+ secret: string,
25
+ value: string | undefined,
26
+ ): Promise<JWTPayload | undefined> {
27
+ if (!value) return undefined
28
+ try {
29
+ return (await jwtVerify(value, keyOf(secret), { algorithms: ['HS256'] })).payload
30
+ } catch {
31
+ return undefined
32
+ }
33
+ }
package/src/store.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * usable credential.
6
6
  */
7
7
  import { type Db, DbDate, fromDbBool, Locks } from '@crombolo/data'
8
+ import { DeviceStore } from './device.ts'
8
9
  import { hashToken } from './pkce.ts'
9
10
 
10
11
  export interface UserRow {
@@ -88,9 +89,12 @@ const USER_COLUMNS =
88
89
 
89
90
  export class AuthStore {
90
91
  #db: Db
92
+ /** Device sign-ins (story 107), beside the sessions they become. */
93
+ readonly devices: DeviceStore
91
94
 
92
95
  constructor(db: Db) {
93
96
  this.#db = db
97
+ this.devices = new DeviceStore(db)
94
98
  }
95
99
 
96
100
  async findUserByLogin(login: string): Promise<UserRow | undefined> {
package/src/totp.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Time-based one-time passwords for two-factor sign-in (story 032): RFC 6238
3
+ * over HMAC-SHA1, 30-second steps and 6 digits, which is what every
4
+ * authenticator app expects. A code one step either side of now is accepted for
5
+ * clock drift; a code at or before the last step used is refused, so a code seen
6
+ * over someone's shoulder cannot be used again.
7
+ *
8
+ * Recovery codes stand in for a code once each, and are kept only as hashes.
9
+ */
10
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
11
+ import { renderSVG } from 'uqr'
12
+
13
+ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
14
+ export const TOTP_STEP_SECONDS = 30
15
+ export const TOTP_DIGITS = 6
16
+
17
+ /** Bytes as base32 (RFC 4648, no padding), the form authenticator apps read. */
18
+ export function base32Encode(bytes: Uint8Array): string {
19
+ let bits = 0
20
+ let value = 0
21
+ let out = ''
22
+ for (const byte of bytes) {
23
+ value = (value << 8) | byte
24
+ bits += 8
25
+ while (bits >= 5) {
26
+ out += ALPHABET[(value >>> (bits - 5)) & 31]
27
+ bits -= 5
28
+ }
29
+ }
30
+ if (bits > 0) out += ALPHABET[(value << (5 - bits)) & 31]
31
+ return out
32
+ }
33
+
34
+ /** Base32 back to bytes; spaces and lower case, as people type it, are fine. */
35
+ export function base32Decode(text: string): Uint8Array {
36
+ const clean = text.replace(/[\s=-]/g, '').toUpperCase()
37
+ let bits = 0
38
+ let value = 0
39
+ const out: number[] = []
40
+ for (const char of clean) {
41
+ const index = ALPHABET.indexOf(char)
42
+ if (index < 0) throw new Error('not base32')
43
+ value = (value << 5) | index
44
+ bits += 5
45
+ if (bits >= 8) {
46
+ out.push((value >>> (bits - 8)) & 255)
47
+ bits -= 8
48
+ }
49
+ }
50
+ return Uint8Array.from(out)
51
+ }
52
+
53
+ /** A new secret: 20 random bytes, as RFC 4226 recommends for HMAC-SHA1. */
54
+ export function generateTotpSecret(): string {
55
+ return base32Encode(randomBytes(20))
56
+ }
57
+
58
+ /** The step a moment falls in. */
59
+ export const totpStep = (at: Date = new Date()) =>
60
+ Math.floor(at.getTime() / 1000 / TOTP_STEP_SECONDS)
61
+
62
+ /** The code for a step (RFC 4226's HOTP over the step counter). */
63
+ export function totpCode(secret: Uint8Array, step: number, digits = TOTP_DIGITS): string {
64
+ const counter = Buffer.alloc(8)
65
+ counter.writeBigUInt64BE(BigInt(step))
66
+ const hmac = createHmac('sha1', secret).update(counter).digest()
67
+ const offset = (hmac[hmac.length - 1] as number) & 0x0f
68
+ const binary = (hmac.readUInt32BE(offset) & 0x7fffffff) % 10 ** digits
69
+ return binary.toString().padStart(digits, '0')
70
+ }
71
+
72
+ /**
73
+ * The step a code matches, within one step of now and after the last step used;
74
+ * undefined for a wrong, stale or reused code.
75
+ */
76
+ export function verifyTotp(
77
+ secretBase32: string,
78
+ code: string,
79
+ options: { at?: Date; lastUsedStep?: number | null } = {},
80
+ ): number | undefined {
81
+ const digits = code.replace(/\s/g, '')
82
+ if (!/^\d{6}$/.test(digits)) return undefined
83
+ let secret: Uint8Array
84
+ try {
85
+ secret = base32Decode(secretBase32)
86
+ } catch {
87
+ return undefined
88
+ }
89
+ const now = totpStep(options.at)
90
+ for (const step of [now, now - 1, now + 1]) {
91
+ if (
92
+ options.lastUsedStep !== undefined &&
93
+ options.lastUsedStep !== null &&
94
+ step <= options.lastUsedStep
95
+ )
96
+ continue
97
+ const expected = Buffer.from(totpCode(secret, step))
98
+ if (timingSafeEqual(expected, Buffer.from(digits))) return step
99
+ }
100
+ return undefined
101
+ }
102
+
103
+ /** The `otpauth://` URI an authenticator app reads from the QR code. */
104
+ export function otpauthUri(options: { secret: string; account: string; issuer: string }): string {
105
+ const label = encodeURIComponent(`${options.issuer}:${options.account}`)
106
+ const query = new URLSearchParams({
107
+ secret: options.secret,
108
+ issuer: options.issuer,
109
+ algorithm: 'SHA1',
110
+ digits: String(TOTP_DIGITS),
111
+ period: String(TOTP_STEP_SECONDS),
112
+ })
113
+ return `otpauth://totp/${label}?${query}`
114
+ }
115
+
116
+ /** The QR code for an `otpauth` URI, as an SVG `data:` URL an `<img>` can show. */
117
+ export function qrCodeDataUrl(text: string): string {
118
+ const svg = renderSVG(text, { border: 2 })
119
+ return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
120
+ }
121
+
122
+ const RECOVERY_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'
123
+
124
+ /** Ten recovery codes, `xxxxx-xxxxx`, from an alphabet with nothing to confuse. */
125
+ export function generateRecoveryCodes(count = 10): string[] {
126
+ return Array.from({ length: count }, () => {
127
+ const bytes = randomBytes(10)
128
+ const chars = Array.from(bytes, (b) => RECOVERY_ALPHABET[b % RECOVERY_ALPHABET.length])
129
+ return `${chars.slice(0, 5).join('')}-${chars.slice(5).join('')}`
130
+ })
131
+ }
132
+
133
+ /** How a recovery code is stored, and looked up: its SHA-256, ignoring case and spacing. */
134
+ export function hashRecoveryCode(code: string): string {
135
+ return createHash('sha256').update(code.replace(/[\s-]/g, '').toLowerCase()).digest('hex')
136
+ }