@crombolo/auth 0.13.1 → 0.14.1

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.1",
3
+ "version": "0.14.1",
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.1",
32
- "@crombolo/data": "0.13.1"
31
+ "@crombolo/core": "0.14.1",
32
+ "@crombolo/data": "0.14.1",
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/index.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  export * from './admin.ts'
2
2
  export * from './cookies.ts'
3
3
  export * from './member-session.ts'
4
+ export * from './oidc.ts'
4
5
  export * from './password.ts'
5
6
  export * from './pkce.ts'
6
7
  export * from './routes.ts'
7
8
  export * from './service.ts'
9
+ export * from './short-lived.ts'
8
10
  export * from './store.ts'
11
+ export * from './totp.ts'
9
12
  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
@@ -17,8 +17,12 @@ import {
17
17
  } from './cookies.ts'
18
18
  import type { AuthService } from './service.ts'
19
19
  import { BACKOFFICE_CLIENT_ID, DEFAULT_SCOPE } from './service.ts'
20
+ import { readShortLived, signShortLived } from './short-lived.ts'
20
21
  import type { UserRow } from './store.ts'
21
22
 
23
+ /** How long a sign-in waits for its two-factor code. */
24
+ const TWO_FACTOR_PENDING_SECONDS = 5 * 60
25
+
22
26
  export const SECURITY_PREFIX = `${MANAGEMENT_API_PATH}/security/back-office`
23
27
 
24
28
  export interface AuthRouteOptions {
@@ -27,6 +31,13 @@ export interface AuthRouteOptions {
27
31
  cookies: CookieOptions
28
32
  /** Told about every sign-in, failed sign-in and sign-out, for the audit log. */
29
33
  onAudit?: (event: AuthAuditEvent, request: Request) => Promise<void>
34
+ /** Two-factor sign-in (story 032): who has a provider on, and whether a code is theirs. */
35
+ twoFactor?: {
36
+ providersFor(userKey: string): Promise<string[]>
37
+ verify(userKey: string, provider: string, code: string): Promise<boolean>
38
+ /** The key the pending cookie is signed with. */
39
+ secret(): Promise<string>
40
+ }
30
41
  }
31
42
 
32
43
  export interface AuthAuditEvent {
@@ -281,6 +292,9 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
281
292
  username,
282
293
  password,
283
294
  request.headers.get('x-forwarded-for') ?? undefined,
295
+ options.twoFactor
296
+ ? async (user) => (await options.twoFactor?.providersFor(user.key)) ?? []
297
+ : undefined,
284
298
  )
285
299
 
286
300
  if (result.status === 'success') await audit({ action: 'SignIn', user: result.user }, request)
@@ -322,12 +336,31 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
322
336
  problemDetails({ title: 'Login not allowed', status: 403, operationStatus: result.reason }),
323
337
  )
324
338
  }
325
- if (result.status === 'twoFactorRequired') {
339
+ if (result.status === 'twoFactorRequired' && options.twoFactor) {
340
+ // The password was right; the session waits for the code. The pending
341
+ // cookie names who is part-way in, for five minutes.
342
+ const pending = await signShortLived(
343
+ await options.twoFactor.secret(),
344
+ { sub: result.user.key, login: result.user.login },
345
+ TWO_FACTOR_PENDING_SECONDS,
346
+ )
326
347
  return Response.json(
327
348
  { twoFactorLoginView: null, enabledTwoFactorProviderNames: result.providers },
328
- { status: 402 },
349
+ {
350
+ status: 402,
351
+ headers: {
352
+ 'set-cookie': serializeCookie({
353
+ name: names.twoFactorPending,
354
+ value: pending,
355
+ secure,
356
+ maxAgeSeconds: TWO_FACTOR_PENDING_SECONDS,
357
+ }),
358
+ },
359
+ },
329
360
  )
330
361
  }
362
+ if (result.status === 'twoFactorRequired')
363
+ return oauthError('server_error', 'Two-factor sign-in is not set up.', 500)
331
364
 
332
365
  return new Response(null, {
333
366
  status: 200,
@@ -337,6 +370,60 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
337
370
  })
338
371
  }
339
372
 
373
+ /** The second step of a sign-in: the code, against the pending cookie the password set. */
374
+ async function handleVerifyTwoFactor(request: Request): Promise<Response> {
375
+ const twoFactor = options.twoFactor
376
+ if (!twoFactor)
377
+ return problemResponse(
378
+ problemDetails({ title: 'Two-factor sign-in is not set up', status: 400 }),
379
+ )
380
+ const pending = await readShortLived(
381
+ await twoFactor.secret(),
382
+ readCookie(request, names.twoFactorPending),
383
+ )
384
+ const userKey = typeof pending?.sub === 'string' ? pending.sub : undefined
385
+ const login = typeof pending?.login === 'string' ? pending.login : undefined
386
+ if (!userKey || !login)
387
+ return problemResponse(
388
+ problemDetails({
389
+ title: 'Sign in again',
390
+ status: 401,
391
+ detail: 'The sign-in took too long. Enter your password again.',
392
+ }),
393
+ )
394
+ const body = (await request.json().catch(() => ({}))) as { provider?: unknown; code?: unknown }
395
+ const provider = typeof body.provider === 'string' ? body.provider : ''
396
+ const code = typeof body.code === 'string' ? body.code.trim() : ''
397
+ const ok = provider && code ? await twoFactor.verify(userKey, provider, code) : false
398
+ if (!ok) {
399
+ const { lockedOut } = await service.twoFactorFailed(login)
400
+ await audit({ action: 'SignInFailed', login, detail: 'Wrong two-factor code' }, request)
401
+ return problemResponse(
402
+ problemDetails({
403
+ title: lockedOut ? 'Login not allowed' : 'Invalid code',
404
+ status: lockedOut ? 403 : 400,
405
+ operationStatus: lockedOut ? 'lockedOut' : 'InvalidCode',
406
+ }),
407
+ )
408
+ }
409
+ const result = await service.signInExternally(
410
+ login,
411
+ request.headers.get('x-forwarded-for') ?? undefined,
412
+ )
413
+ if (result.status !== 'success')
414
+ return problemResponse(
415
+ problemDetails({ title: 'Login not allowed', status: 403, operationStatus: result.reason }),
416
+ )
417
+ await audit({ action: 'SignIn', user: result.user, detail: 'With two-factor sign-in' }, request)
418
+ const headers = new Headers()
419
+ headers.append(
420
+ 'set-cookie',
421
+ sessionCookie(result.sessionId, service.settings.refreshTokenSeconds),
422
+ )
423
+ headers.append('set-cookie', expireCookie(names.twoFactorPending, secure))
424
+ return new Response(null, { status: 200, headers })
425
+ }
426
+
340
427
  async function handleRevoke(request: Request): Promise<Response> {
341
428
  const form = new URLSearchParams(await request.text())
342
429
  const token = resolveRedacted(form.get('token'), readCookie(request, names.accessToken))
@@ -371,13 +458,8 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
371
458
  if (action === 'login' && request.method === 'POST') return handleLogin(request)
372
459
  if (action === 'revoke' && request.method === 'POST') return handleRevoke(request)
373
460
  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
- }
461
+ if (action === 'verify-2fa' && request.method === 'POST')
462
+ return handleVerifyTwoFactor(request)
381
463
  return undefined
382
464
  },
383
465
  }
package/src/service.ts CHANGED
@@ -45,7 +45,7 @@ export const DEFAULT_AUTH_SETTINGS: AuthSettings = {
45
45
  export type LoginResult =
46
46
  | { status: 'success'; user: UserRow; sessionId: string }
47
47
  | { status: 'invalid'; user?: UserRow; lockedOut?: boolean }
48
- | { status: 'twoFactorRequired'; providers: string[] }
48
+ | { status: 'twoFactorRequired'; providers: string[]; user: UserRow }
49
49
  | { status: 'notAllowed'; reason: 'disabled' | 'lockedOut' | 'notApproved'; user?: UserRow }
50
50
 
51
51
  export interface AuthorizationRequest {
@@ -114,7 +114,17 @@ export class AuthService {
114
114
  return this.#settings
115
115
  }
116
116
 
117
- async login(login: string, password: string, ipAddress?: string): Promise<LoginResult> {
117
+ /**
118
+ * `twoFactorProviders`, when given, names the two-factor providers a user has
119
+ * turned on (story 032): with any, a correct password stops short of a session
120
+ * and the caller asks for a code.
121
+ */
122
+ async login(
123
+ login: string,
124
+ password: string,
125
+ ipAddress?: string,
126
+ twoFactorProviders?: (user: UserRow) => Promise<string[]>,
127
+ ): Promise<LoginResult> {
118
128
  return withMinimumDuration(120, async () => {
119
129
  const user = await this.#store.findUserByLogin(login)
120
130
  if (!user) {
@@ -155,12 +165,50 @@ export class AuthService {
155
165
  passwordConfigJson(),
156
166
  )
157
167
 
168
+ const providers = (await twoFactorProviders?.(user)) ?? []
169
+ if (providers.length > 0) return { status: 'twoFactorRequired', providers, user } as const
170
+
158
171
  const sessionId = crypto.randomUUID()
159
172
  await this.#store.recordLoginSuccess(user.id, sessionId, ipAddress)
160
173
  return { status: 'success', user, sessionId } as const
161
174
  })
162
175
  }
163
176
 
177
+ /**
178
+ * A wrong second factor counts as a wrong password does, towards lockout, so a
179
+ * stolen password cannot be followed by guessing codes for free.
180
+ */
181
+ async twoFactorFailed(login: string): Promise<{ lockedOut: boolean }> {
182
+ const user = await this.#store.findUserByLogin(login)
183
+ if (!user) return { lockedOut: false }
184
+ const attempts = await this.#store.recordLoginFailure(user.id)
185
+ const lockedOut =
186
+ this.#settings.maxFailedLoginAttempts > 0 && attempts >= this.#settings.maxFailedLoginAttempts
187
+ if (lockedOut) await this.#store.lockOut(user.id)
188
+ return { lockedOut }
189
+ }
190
+
191
+ /**
192
+ * Signs a user in whom an external identity provider has vouched for (story
193
+ * 031): no password, but the same refusals a password sign-in has — locked out
194
+ * or not approved — and the same session afterwards.
195
+ */
196
+ async signInExternally(
197
+ login: string,
198
+ ipAddress?: string,
199
+ ): Promise<
200
+ | { status: 'success'; user: UserRow; sessionId: string }
201
+ | { status: 'notAllowed'; reason: 'lockedOut' | 'notApproved' | 'unknown' }
202
+ > {
203
+ const user = await this.#store.findUserByLogin(login)
204
+ if (!user) return { status: 'notAllowed', reason: 'unknown' }
205
+ if (user.isLockedOut) return { status: 'notAllowed', reason: 'lockedOut' }
206
+ if (!user.isApproved) return { status: 'notAllowed', reason: 'notApproved' }
207
+ const sessionId = crypto.randomUUID()
208
+ await this.#store.recordLoginSuccess(user.id, sessionId, ipAddress)
209
+ return { status: 'success', user, sessionId }
210
+ }
211
+
164
212
  /** Issues a single-use authorization code for an already-authenticated session. */
165
213
  async authorize(
166
214
  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/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
+ }