@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 +5 -3
- package/src/cookies.ts +3 -0
- package/src/index.ts +3 -0
- package/src/oidc.ts +223 -0
- package/src/routes.ts +91 -9
- package/src/service.ts +50 -2
- package/src/short-lived.ts +33 -0
- package/src/totp.ts +136 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/auth",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
32
|
-
"@crombolo/data": "0.
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|