@crombolo/auth 0.0.0-stage → 0.10.0
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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +32 -4
- package/src/admin.ts +48 -0
- package/src/cookies.ts +91 -0
- package/src/index.ts +8 -0
- package/src/member-session.ts +94 -0
- package/src/password.ts +33 -0
- package/src/pkce.ts +49 -0
- package/src/routes.ts +338 -0
- package/src/service.ts +354 -0
- package/src/store.ts +391 -0
- package/README.md +0 -3
package/src/routes.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP surface for back-office security.
|
|
3
|
+
*
|
|
4
|
+
* These endpoints live on Umbraco's BackOfficeController, which is excluded from
|
|
5
|
+
* the OpenAPI document, so they are matched by path rather than through the
|
|
6
|
+
* contract-first router.
|
|
7
|
+
*/
|
|
8
|
+
import { MANAGEMENT_API_PATH, problemDetails, problemResponse } from '@crombolo/core'
|
|
9
|
+
import {
|
|
10
|
+
type CookieOptions,
|
|
11
|
+
cookieNames,
|
|
12
|
+
expireCookie,
|
|
13
|
+
REDACTED,
|
|
14
|
+
readCookie,
|
|
15
|
+
resolveRedacted,
|
|
16
|
+
serializeCookie,
|
|
17
|
+
} from './cookies.ts'
|
|
18
|
+
import type { AuthService } from './service.ts'
|
|
19
|
+
import { BACKOFFICE_CLIENT_ID, DEFAULT_SCOPE } from './service.ts'
|
|
20
|
+
|
|
21
|
+
export const SECURITY_PREFIX = `${MANAGEMENT_API_PATH}/security/back-office`
|
|
22
|
+
|
|
23
|
+
export interface AuthRouteOptions {
|
|
24
|
+
backOfficePath: string
|
|
25
|
+
service: AuthService
|
|
26
|
+
cookies: CookieOptions
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AuthRoutes {
|
|
30
|
+
/** Handles a security endpoint, or returns undefined when the path is not one. */
|
|
31
|
+
handle(request: Request, pathname: string): Promise<Response | undefined>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* An OAuth error response. A 400 or 401 carrying a JSON `error` member is
|
|
36
|
+
* definitive: the client stops retrying. Any other shape is treated as transient
|
|
37
|
+
* and retried, which leaves the editor hanging.
|
|
38
|
+
*/
|
|
39
|
+
function oauthError(error: string, description: string, status = 400): Response {
|
|
40
|
+
return Response.json({ error, error_description: description }, { status })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
44
|
+
// The contract's path, not the backoffice's: these are Management API
|
|
45
|
+
// operations and the client calls them there wherever the editor is mounted.
|
|
46
|
+
const prefix = SECURITY_PREFIX
|
|
47
|
+
const names = cookieNames(options.cookies)
|
|
48
|
+
const secure = options.cookies.securePrefix
|
|
49
|
+
const { service } = options
|
|
50
|
+
|
|
51
|
+
const expectedRedirectUri = (origin: string) =>
|
|
52
|
+
`${origin}${options.backOfficePath}/oauth_complete`
|
|
53
|
+
|
|
54
|
+
function sessionCookie(sessionId: string, maxAgeSeconds: number): string {
|
|
55
|
+
return serializeCookie({ name: names.session, value: sessionId, secure, maxAgeSeconds })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Real token values only ever leave the server inside these cookies. */
|
|
59
|
+
function tokenCookies(
|
|
60
|
+
accessToken: string,
|
|
61
|
+
refreshToken: string,
|
|
62
|
+
accessTtl: number,
|
|
63
|
+
refreshTtl: number,
|
|
64
|
+
): string[] {
|
|
65
|
+
return [
|
|
66
|
+
serializeCookie({
|
|
67
|
+
name: names.accessToken,
|
|
68
|
+
value: accessToken,
|
|
69
|
+
secure,
|
|
70
|
+
maxAgeSeconds: accessTtl,
|
|
71
|
+
}),
|
|
72
|
+
serializeCookie({
|
|
73
|
+
name: names.refreshToken,
|
|
74
|
+
value: refreshToken,
|
|
75
|
+
secure,
|
|
76
|
+
maxAgeSeconds: refreshTtl,
|
|
77
|
+
}),
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function clearedCookies(): string[] {
|
|
82
|
+
return [
|
|
83
|
+
expireCookie(names.accessToken, secure),
|
|
84
|
+
expireCookie(names.refreshToken, secure),
|
|
85
|
+
expireCookie(names.session, secure),
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function handleAuthorize(request: Request, url: URL): Promise<Response> {
|
|
90
|
+
const params = url.searchParams
|
|
91
|
+
const clientId = params.get('client_id') ?? ''
|
|
92
|
+
const redirectUri = params.get('redirect_uri') ?? ''
|
|
93
|
+
const responseType = params.get('response_type') ?? ''
|
|
94
|
+
const codeChallenge = params.get('code_challenge') ?? ''
|
|
95
|
+
const method = (params.get('code_challenge_method') ?? 'S256') as 'S256' | 'plain'
|
|
96
|
+
const state = params.get('state') ?? undefined
|
|
97
|
+
|
|
98
|
+
if (clientId !== BACKOFFICE_CLIENT_ID) {
|
|
99
|
+
return oauthError('invalid_client', `Unknown client '${clientId}'.`)
|
|
100
|
+
}
|
|
101
|
+
if (responseType !== 'code') {
|
|
102
|
+
return oauthError(
|
|
103
|
+
'unsupported_response_type',
|
|
104
|
+
'Only the authorization code flow is supported.',
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
// PKCE is mandatory: the client is public and cannot hold a secret.
|
|
108
|
+
if (!codeChallenge) {
|
|
109
|
+
return oauthError('invalid_request', 'code_challenge is required.')
|
|
110
|
+
}
|
|
111
|
+
if (redirectUri !== expectedRedirectUri(url.origin)) {
|
|
112
|
+
return oauthError('invalid_request', 'redirect_uri is not registered for this client.')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const sessionId = readCookie(request, names.session)
|
|
116
|
+
const session = sessionId ? await service.resolveSession(sessionId) : undefined
|
|
117
|
+
if (!sessionId || !session) {
|
|
118
|
+
// No session yet: send the browser to the login page, which returns here.
|
|
119
|
+
const loginUrl = new URL(`${options.backOfficePath}/login`, url.origin)
|
|
120
|
+
loginUrl.searchParams.set('returnUrl', url.pathname + url.search)
|
|
121
|
+
return Response.redirect(loginUrl.href, 302)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const code = await service.authorize(sessionId, session.userId, {
|
|
125
|
+
clientId,
|
|
126
|
+
redirectUri,
|
|
127
|
+
scope: params.get('scope') ?? DEFAULT_SCOPE,
|
|
128
|
+
state,
|
|
129
|
+
codeChallenge,
|
|
130
|
+
codeChallengeMethod: method,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const target = new URL(redirectUri)
|
|
134
|
+
target.searchParams.set('code', code)
|
|
135
|
+
if (state) target.searchParams.set('state', state)
|
|
136
|
+
return Response.redirect(target.href, 302)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function handleToken(request: Request, url: URL): Promise<Response> {
|
|
140
|
+
const form = new URLSearchParams(await request.text())
|
|
141
|
+
const grantType = form.get('grant_type') ?? ''
|
|
142
|
+
const clientId = form.get('client_id') ?? ''
|
|
143
|
+
|
|
144
|
+
// API users: a machine client presents its own id and secret and gets a bearer token back
|
|
145
|
+
if (grantType === 'client_credentials') {
|
|
146
|
+
const issued = await service.clientCredentials(clientId, form.get('client_secret') ?? '')
|
|
147
|
+
if (!issued.ok) return oauthError(issued.error, issued.description, 401)
|
|
148
|
+
return Response.json({
|
|
149
|
+
access_token: issued.accessToken,
|
|
150
|
+
token_type: 'Bearer',
|
|
151
|
+
expires_in: issued.expiresIn,
|
|
152
|
+
issued_at: issued.issuedAt,
|
|
153
|
+
scope: DEFAULT_SCOPE,
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (clientId !== BACKOFFICE_CLIENT_ID) {
|
|
158
|
+
return oauthError('invalid_client', `Unknown client '${clientId}'.`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const result =
|
|
162
|
+
grantType === 'authorization_code'
|
|
163
|
+
? await (async () => {
|
|
164
|
+
const code = form.get('code')
|
|
165
|
+
const verifier = form.get('code_verifier')
|
|
166
|
+
if (!code || !verifier) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false as const,
|
|
169
|
+
error: 'invalid_request' as const,
|
|
170
|
+
description: 'code and code_verifier are required.',
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return service.exchangeCode(
|
|
174
|
+
code,
|
|
175
|
+
verifier,
|
|
176
|
+
clientId,
|
|
177
|
+
form.get('redirect_uri') ?? expectedRedirectUri(url.origin),
|
|
178
|
+
)
|
|
179
|
+
})()
|
|
180
|
+
: grantType === 'refresh_token'
|
|
181
|
+
? await (async () => {
|
|
182
|
+
// The client sends `[redacted]`; the real token is in the cookie.
|
|
183
|
+
const refreshToken = resolveRedacted(
|
|
184
|
+
form.get('refresh_token'),
|
|
185
|
+
readCookie(request, names.refreshToken),
|
|
186
|
+
)
|
|
187
|
+
if (!refreshToken) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false as const,
|
|
190
|
+
error: 'invalid_grant' as const,
|
|
191
|
+
description: 'No refresh token was presented.',
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return service.refresh(refreshToken, clientId)
|
|
195
|
+
})()
|
|
196
|
+
: {
|
|
197
|
+
ok: false as const,
|
|
198
|
+
error: 'unsupported_grant_type' as const,
|
|
199
|
+
description: `Unsupported grant_type '${grantType}'.`,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!result.ok) {
|
|
203
|
+
const status = result.error === 'invalid_client' ? 401 : 400
|
|
204
|
+
const response = oauthError(result.error, result.description, status)
|
|
205
|
+
// A definitive rejection must not leave stale cookies behind.
|
|
206
|
+
for (const cookie of clearedCookies()) response.headers.append('set-cookie', cookie)
|
|
207
|
+
return response
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const { grant } = result
|
|
211
|
+
const headers = new Headers({ 'content-type': 'application/json; charset=utf-8' })
|
|
212
|
+
for (const cookie of tokenCookies(
|
|
213
|
+
grant.accessToken,
|
|
214
|
+
grant.refreshToken,
|
|
215
|
+
service.settings.accessTokenSeconds,
|
|
216
|
+
service.settings.refreshTokenSeconds,
|
|
217
|
+
)) {
|
|
218
|
+
headers.append('set-cookie', cookie)
|
|
219
|
+
}
|
|
220
|
+
headers.append(
|
|
221
|
+
'set-cookie',
|
|
222
|
+
sessionCookie(grant.sessionId, service.settings.refreshTokenSeconds),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return new Response(
|
|
226
|
+
JSON.stringify({
|
|
227
|
+
access_token: REDACTED,
|
|
228
|
+
refresh_token: REDACTED,
|
|
229
|
+
token_type: 'Bearer',
|
|
230
|
+
expires_in: grant.expiresIn,
|
|
231
|
+
issued_at: grant.issuedAt,
|
|
232
|
+
scope: DEFAULT_SCOPE,
|
|
233
|
+
}),
|
|
234
|
+
{ status: 200, headers },
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function handleLogin(request: Request): Promise<Response> {
|
|
239
|
+
let body: { username?: unknown; password?: unknown }
|
|
240
|
+
try {
|
|
241
|
+
body = (await request.json()) as typeof body
|
|
242
|
+
} catch {
|
|
243
|
+
return problemResponse(
|
|
244
|
+
problemDetails({ title: 'Invalid request', status: 400, detail: 'Expected a JSON body.' }),
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
const username = typeof body.username === 'string' ? body.username : ''
|
|
248
|
+
const password = typeof body.password === 'string' ? body.password : ''
|
|
249
|
+
if (!username || !password) {
|
|
250
|
+
return problemResponse(
|
|
251
|
+
problemDetails({
|
|
252
|
+
title: 'Invalid request',
|
|
253
|
+
status: 400,
|
|
254
|
+
detail: 'username and password are required.',
|
|
255
|
+
}),
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const result = await service.login(
|
|
260
|
+
username,
|
|
261
|
+
password,
|
|
262
|
+
request.headers.get('x-forwarded-for') ?? undefined,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
// Status codes the login UI branches on: 401 invalid, 402 needs 2FA,
|
|
266
|
+
// 403 locked or not approved.
|
|
267
|
+
if (result.status === 'invalid') {
|
|
268
|
+
return problemResponse(
|
|
269
|
+
problemDetails({
|
|
270
|
+
title: 'Invalid credentials',
|
|
271
|
+
status: 401,
|
|
272
|
+
operationStatus: 'InvalidCredentials',
|
|
273
|
+
}),
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
if (result.status === 'notAllowed') {
|
|
277
|
+
return problemResponse(
|
|
278
|
+
problemDetails({ title: 'Login not allowed', status: 403, operationStatus: result.reason }),
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
if (result.status === 'twoFactorRequired') {
|
|
282
|
+
return Response.json(
|
|
283
|
+
{ twoFactorLoginView: null, enabledTwoFactorProviderNames: result.providers },
|
|
284
|
+
{ status: 402 },
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return new Response(null, {
|
|
289
|
+
status: 200,
|
|
290
|
+
headers: {
|
|
291
|
+
'set-cookie': sessionCookie(result.sessionId, service.settings.refreshTokenSeconds),
|
|
292
|
+
},
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function handleRevoke(request: Request): Promise<Response> {
|
|
297
|
+
const form = new URLSearchParams(await request.text())
|
|
298
|
+
const token = resolveRedacted(form.get('token'), readCookie(request, names.accessToken))
|
|
299
|
+
if (token) await service.revokeAccessToken(token)
|
|
300
|
+
const response = new Response(null, { status: 200 })
|
|
301
|
+
for (const cookie of clearedCookies()) response.headers.append('set-cookie', cookie)
|
|
302
|
+
return response
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function handleSignOut(request: Request, url: URL): Promise<Response> {
|
|
306
|
+
const sessionId = readCookie(request, names.session)
|
|
307
|
+
if (sessionId) await service.signOut(sessionId)
|
|
308
|
+
|
|
309
|
+
const requested = url.searchParams.get('post_logout_redirect_uri')
|
|
310
|
+
const allowed = `${url.origin}${options.backOfficePath}/logout`
|
|
311
|
+
const location = requested === allowed ? allowed : `${url.origin}${options.backOfficePath}`
|
|
312
|
+
const response = new Response(null, { status: 302, headers: { location } })
|
|
313
|
+
for (const cookie of clearedCookies()) response.headers.append('set-cookie', cookie)
|
|
314
|
+
return response
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
async handle(request, pathname) {
|
|
319
|
+
if (!pathname.startsWith(`${prefix}/`)) return undefined
|
|
320
|
+
const action = pathname.slice(prefix.length + 1)
|
|
321
|
+
const url = new URL(request.url)
|
|
322
|
+
|
|
323
|
+
if (action === 'authorize' && request.method === 'GET') return handleAuthorize(request, url)
|
|
324
|
+
if (action === 'token' && request.method === 'POST') return handleToken(request, url)
|
|
325
|
+
if (action === 'login' && request.method === 'POST') return handleLogin(request)
|
|
326
|
+
if (action === 'revoke' && request.method === 'POST') return handleRevoke(request)
|
|
327
|
+
if (action === 'signout') return handleSignOut(request, url)
|
|
328
|
+
if (action === 'verify-2fa' && request.method === 'POST') {
|
|
329
|
+
return oauthError(
|
|
330
|
+
'unsupported_grant_type',
|
|
331
|
+
'Two-factor authentication is not implemented.',
|
|
332
|
+
400,
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
return undefined
|
|
336
|
+
},
|
|
337
|
+
}
|
|
338
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OAuth 2.0 authorization server, shaped to what the Umbraco backoffice
|
|
3
|
+
* actually sends. See docs/backoffice-hosting.md for the wire contract.
|
|
4
|
+
*/
|
|
5
|
+
import { type ExtensionSectionAlias, type SectionAlias, toSectionAliases } from '@crombolo/core'
|
|
6
|
+
import { hashPassword, passwordConfigJson, verifyPassword } from './password.ts'
|
|
7
|
+
import { type CodeChallengeMethod, generateToken, verifyChallenge } from './pkce.ts'
|
|
8
|
+
import type { AuthStore, UserRow } from './store.ts'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The `client_id` the vendored backoffice sends, which `routes.ts` checks an
|
|
12
|
+
* incoming request against. Not a name we pick: `umb-auth-client.js` takes it as
|
|
13
|
+
* a constructor default and `auth.context.js` constructs that client without
|
|
14
|
+
* passing one, so the browser puts this exact string on every authorize and
|
|
15
|
+
* token call. There is no attribute and no setting to change it.
|
|
16
|
+
*
|
|
17
|
+
* Renaming the value therefore does not rebrand anything — it makes the server
|
|
18
|
+
* reject the only client that calls it, and every sign-in fails with
|
|
19
|
+
* `invalid_client`. The API-user prefix in `adapters/users.ts` is the one that
|
|
20
|
+
* was genuinely ours, and it moved to crombolo in migration 020.
|
|
21
|
+
*/
|
|
22
|
+
export const BACKOFFICE_CLIENT_ID = 'umbraco-back-office'
|
|
23
|
+
export const DEFAULT_SCOPE = 'offline_access'
|
|
24
|
+
|
|
25
|
+
export interface AuthSettings {
|
|
26
|
+
/** Access token lifetime. Umbraco uses a quarter of the session timeout. */
|
|
27
|
+
accessTokenSeconds: number
|
|
28
|
+
refreshTokenSeconds: number
|
|
29
|
+
authorizationCodeSeconds: number
|
|
30
|
+
/** Failed attempts before the account is locked. 0 disables lockout. */
|
|
31
|
+
maxFailedLoginAttempts: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_AUTH_SETTINGS: AuthSettings = {
|
|
35
|
+
accessTokenSeconds: 20 * 60,
|
|
36
|
+
refreshTokenSeconds: 80 * 60,
|
|
37
|
+
authorizationCodeSeconds: 5 * 60,
|
|
38
|
+
maxFailedLoginAttempts: 10,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type LoginResult =
|
|
42
|
+
| { status: 'success'; user: UserRow; sessionId: string }
|
|
43
|
+
| { status: 'invalid' }
|
|
44
|
+
| { status: 'twoFactorRequired'; providers: string[] }
|
|
45
|
+
| { status: 'notAllowed'; reason: 'disabled' | 'lockedOut' | 'notApproved' }
|
|
46
|
+
|
|
47
|
+
export interface AuthorizationRequest {
|
|
48
|
+
clientId: string
|
|
49
|
+
redirectUri: string
|
|
50
|
+
scope: string
|
|
51
|
+
state: string | undefined
|
|
52
|
+
codeChallenge: string
|
|
53
|
+
codeChallengeMethod: CodeChallengeMethod
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface TokenGrant {
|
|
57
|
+
accessToken: string
|
|
58
|
+
refreshToken: string
|
|
59
|
+
expiresIn: number
|
|
60
|
+
issuedAt: number
|
|
61
|
+
sessionId: string
|
|
62
|
+
userId: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type TokenError =
|
|
66
|
+
| 'invalid_grant'
|
|
67
|
+
| 'invalid_request'
|
|
68
|
+
| 'invalid_client'
|
|
69
|
+
| 'unauthorized_client'
|
|
70
|
+
|
|
71
|
+
export type TokenResult =
|
|
72
|
+
| { ok: true; grant: TokenGrant }
|
|
73
|
+
| { ok: false; error: TokenError; description: string }
|
|
74
|
+
|
|
75
|
+
export interface ResolvedIdentity {
|
|
76
|
+
user: UserRow
|
|
77
|
+
sessionId: string
|
|
78
|
+
allowedSections: Array<SectionAlias | ExtensionSectionAlias>
|
|
79
|
+
groupKeys: string[]
|
|
80
|
+
isAdmin: boolean
|
|
81
|
+
hasAccessToAllLanguages: boolean
|
|
82
|
+
permissions: string[]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Normalises login timing so a valid username with a wrong password takes the
|
|
87
|
+
* same time as an unknown username. Without this, response time reveals which
|
|
88
|
+
* accounts exist.
|
|
89
|
+
*/
|
|
90
|
+
async function withMinimumDuration<T>(minimumMs: number, work: () => Promise<T>): Promise<T> {
|
|
91
|
+
const started = Bun.nanoseconds()
|
|
92
|
+
const result = await work()
|
|
93
|
+
const elapsedMs = (Bun.nanoseconds() - started) / 1_000_000
|
|
94
|
+
if (elapsedMs < minimumMs) await Bun.sleep(minimumMs - elapsedMs)
|
|
95
|
+
return result
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const DUMMY_HASH_PROMISE = hashPassword(crypto.randomUUID())
|
|
99
|
+
|
|
100
|
+
export class AuthService {
|
|
101
|
+
#store: AuthStore
|
|
102
|
+
#settings: AuthSettings
|
|
103
|
+
|
|
104
|
+
constructor(store: AuthStore, settings: AuthSettings = DEFAULT_AUTH_SETTINGS) {
|
|
105
|
+
this.#store = store
|
|
106
|
+
this.#settings = settings
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
get settings(): AuthSettings {
|
|
110
|
+
return this.#settings
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async login(login: string, password: string, ipAddress?: string): Promise<LoginResult> {
|
|
114
|
+
return withMinimumDuration(120, async () => {
|
|
115
|
+
const user = await this.#store.findUserByLogin(login)
|
|
116
|
+
if (!user) {
|
|
117
|
+
// Verify against a throwaway hash so an unknown user costs the same.
|
|
118
|
+
await verifyPassword(password, await DUMMY_HASH_PROMISE)
|
|
119
|
+
return { status: 'invalid' } as const
|
|
120
|
+
}
|
|
121
|
+
// Before the password, as ASP.NET Identity's PreSignInCheck does it. After
|
|
122
|
+
// it, the two answers differ by whether the password was right — so once an
|
|
123
|
+
// attacker has tripped the lockout themselves, guessing on is free and the
|
|
124
|
+
// status code tells them when they have found it. The throwaway verify keeps
|
|
125
|
+
// this path costing what the others cost.
|
|
126
|
+
if (user.isLockedOut) {
|
|
127
|
+
await verifyPassword(password, await DUMMY_HASH_PROMISE)
|
|
128
|
+
return { status: 'notAllowed', reason: 'lockedOut' } as const
|
|
129
|
+
}
|
|
130
|
+
if (!(await verifyPassword(password, user.passwordHash))) {
|
|
131
|
+
const attempts = await this.#store.recordLoginFailure(user.id)
|
|
132
|
+
if (
|
|
133
|
+
this.#settings.maxFailedLoginAttempts > 0 &&
|
|
134
|
+
attempts >= this.#settings.maxFailedLoginAttempts
|
|
135
|
+
) {
|
|
136
|
+
await this.#store.lockOut(user.id)
|
|
137
|
+
}
|
|
138
|
+
return { status: 'invalid' } as const
|
|
139
|
+
}
|
|
140
|
+
// `isApproved` stays behind the password on purpose: it is not a state an
|
|
141
|
+
// attacker can induce, so answering it only to whoever knows the password
|
|
142
|
+
// discloses strictly less than checking it up front.
|
|
143
|
+
if (!user.isApproved) return { status: 'notAllowed', reason: 'notApproved' } as const
|
|
144
|
+
|
|
145
|
+
const sessionId = crypto.randomUUID()
|
|
146
|
+
await this.#store.recordLoginSuccess(user.id, sessionId, ipAddress)
|
|
147
|
+
return { status: 'success', user, sessionId } as const
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Issues a single-use authorization code for an already-authenticated session. */
|
|
152
|
+
async authorize(
|
|
153
|
+
sessionId: string,
|
|
154
|
+
userId: number,
|
|
155
|
+
request: AuthorizationRequest,
|
|
156
|
+
): Promise<string> {
|
|
157
|
+
const code = generateToken()
|
|
158
|
+
await this.#store.createAuthorizationCode({
|
|
159
|
+
code,
|
|
160
|
+
userId,
|
|
161
|
+
sessionId,
|
|
162
|
+
clientId: request.clientId,
|
|
163
|
+
redirectUri: request.redirectUri,
|
|
164
|
+
codeChallenge: request.codeChallenge,
|
|
165
|
+
codeChallengeMethod: request.codeChallengeMethod,
|
|
166
|
+
scope: request.scope,
|
|
167
|
+
expiresAt: new Date(Date.now() + this.#settings.authorizationCodeSeconds * 1000),
|
|
168
|
+
})
|
|
169
|
+
return code
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async exchangeCode(
|
|
173
|
+
code: string,
|
|
174
|
+
codeVerifier: string,
|
|
175
|
+
clientId: string,
|
|
176
|
+
redirectUri: string,
|
|
177
|
+
): Promise<TokenResult> {
|
|
178
|
+
const record = await this.#store.consumeAuthorizationCode(code)
|
|
179
|
+
if (!record)
|
|
180
|
+
return { ok: false, error: 'invalid_grant', description: 'Unknown authorization code.' }
|
|
181
|
+
if (record.consumedAt) {
|
|
182
|
+
// Replay: the code was already exchanged, so kill the whole session.
|
|
183
|
+
await this.#store.endSession(record.sessionId)
|
|
184
|
+
return { ok: false, error: 'invalid_grant', description: 'Authorization code already used.' }
|
|
185
|
+
}
|
|
186
|
+
if (record.expiresAt.getTime() < Date.now()) {
|
|
187
|
+
return { ok: false, error: 'invalid_grant', description: 'Authorization code has expired.' }
|
|
188
|
+
}
|
|
189
|
+
if (record.clientId !== clientId) {
|
|
190
|
+
return { ok: false, error: 'invalid_client', description: 'Client mismatch.' }
|
|
191
|
+
}
|
|
192
|
+
if (record.redirectUri !== redirectUri) {
|
|
193
|
+
return { ok: false, error: 'invalid_grant', description: 'Redirect URI mismatch.' }
|
|
194
|
+
}
|
|
195
|
+
if (
|
|
196
|
+
!verifyChallenge(
|
|
197
|
+
codeVerifier,
|
|
198
|
+
record.codeChallenge,
|
|
199
|
+
record.codeChallengeMethod as CodeChallengeMethod,
|
|
200
|
+
)
|
|
201
|
+
) {
|
|
202
|
+
return { ok: false, error: 'invalid_grant', description: 'PKCE verification failed.' }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
grant: await this.#issue(record.userId, record.sessionId, clientId, record.scope),
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async refresh(refreshToken: string, clientId: string): Promise<TokenResult> {
|
|
212
|
+
const existing = await this.#store.findToken(refreshToken, 'refresh')
|
|
213
|
+
if (!existing)
|
|
214
|
+
return { ok: false, error: 'invalid_grant', description: 'Unknown refresh token.' }
|
|
215
|
+
if (existing.revokedAt) {
|
|
216
|
+
// Reuse of a rotated token means the chain is compromised.
|
|
217
|
+
await this.#store.endSession(existing.sessionId)
|
|
218
|
+
return { ok: false, error: 'invalid_grant', description: 'Refresh token has been revoked.' }
|
|
219
|
+
}
|
|
220
|
+
if (existing.expiresAt.getTime() < Date.now()) {
|
|
221
|
+
return { ok: false, error: 'invalid_grant', description: 'Refresh token has expired.' }
|
|
222
|
+
}
|
|
223
|
+
if (existing.clientId !== clientId) {
|
|
224
|
+
return { ok: false, error: 'invalid_client', description: 'Client mismatch.' }
|
|
225
|
+
}
|
|
226
|
+
const session = await this.#store.findSession(existing.sessionId)
|
|
227
|
+
if (!session) return { ok: false, error: 'invalid_grant', description: 'Session has ended.' }
|
|
228
|
+
|
|
229
|
+
await this.#store.revokeToken(existing.id)
|
|
230
|
+
return {
|
|
231
|
+
ok: true,
|
|
232
|
+
grant: await this.#issue(
|
|
233
|
+
existing.userId,
|
|
234
|
+
existing.sessionId,
|
|
235
|
+
clientId,
|
|
236
|
+
existing.scope,
|
|
237
|
+
existing.id,
|
|
238
|
+
),
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The client-credentials grant for API users: a secret checked against the
|
|
244
|
+
* user's credential yields an access token and no refresh token.
|
|
245
|
+
*/
|
|
246
|
+
async clientCredentials(
|
|
247
|
+
clientId: string,
|
|
248
|
+
secret: string,
|
|
249
|
+
): Promise<
|
|
250
|
+
| { ok: true; accessToken: string; expiresIn: number; issuedAt: number }
|
|
251
|
+
| { ok: false; error: TokenError; description: string }
|
|
252
|
+
> {
|
|
253
|
+
const credential = await this.#store.findClientCredential(clientId)
|
|
254
|
+
const valid = credential ? await verifyPassword(secret, credential.secretHash) : false
|
|
255
|
+
const user = credential && valid ? await this.#store.findUserById(credential.userId) : undefined
|
|
256
|
+
if (user?.kind !== 'Api' || user.isLockedOut || !user.isApproved)
|
|
257
|
+
return { ok: false, error: 'invalid_client', description: 'Invalid client credentials.' }
|
|
258
|
+
const sessionId = crypto.randomUUID()
|
|
259
|
+
await this.#store.recordLoginSuccess(user.id, sessionId)
|
|
260
|
+
const accessToken = generateToken()
|
|
261
|
+
const now = Date.now()
|
|
262
|
+
await this.#store.createToken({
|
|
263
|
+
token: accessToken,
|
|
264
|
+
type: 'access',
|
|
265
|
+
userId: user.id,
|
|
266
|
+
sessionId,
|
|
267
|
+
clientId,
|
|
268
|
+
scope: DEFAULT_SCOPE,
|
|
269
|
+
expiresAt: new Date(now + this.#settings.accessTokenSeconds * 1000),
|
|
270
|
+
})
|
|
271
|
+
return {
|
|
272
|
+
ok: true,
|
|
273
|
+
accessToken,
|
|
274
|
+
expiresIn: this.#settings.accessTokenSeconds,
|
|
275
|
+
issuedAt: Math.floor(now / 1000),
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Resolves a reference access token to an identity, or undefined. */
|
|
280
|
+
async resolveAccessToken(accessToken: string): Promise<ResolvedIdentity | undefined> {
|
|
281
|
+
const token = await this.#store.findToken(accessToken, 'access')
|
|
282
|
+
if (!token || token.revokedAt || token.expiresAt.getTime() < Date.now()) return undefined
|
|
283
|
+
const session = await this.#store.findSession(token.sessionId)
|
|
284
|
+
if (!session) return undefined
|
|
285
|
+
const user = await this.#store.findUserById(token.userId)
|
|
286
|
+
if (!user || user.isLockedOut || !user.isApproved) return undefined
|
|
287
|
+
|
|
288
|
+
const authorization = await this.#store.loadAuthorization(user.id)
|
|
289
|
+
return {
|
|
290
|
+
user,
|
|
291
|
+
sessionId: token.sessionId,
|
|
292
|
+
allowedSections: toSectionAliases(authorization.appAliases),
|
|
293
|
+
groupKeys: authorization.groupKeys,
|
|
294
|
+
isAdmin: authorization.isAdmin,
|
|
295
|
+
hasAccessToAllLanguages: authorization.hasAccessToAllLanguages,
|
|
296
|
+
permissions: authorization.permissions,
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async resolveSession(sessionId: string): Promise<{ userId: number } | undefined> {
|
|
301
|
+
return this.#store.findSession(sessionId)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async signOut(sessionId: string): Promise<void> {
|
|
305
|
+
await this.#store.endSession(sessionId)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async revokeAccessToken(accessToken: string): Promise<void> {
|
|
309
|
+
const token = await this.#store.findToken(accessToken, 'access')
|
|
310
|
+
if (token) await this.#store.endSession(token.sessionId)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async #issue(
|
|
314
|
+
userId: number,
|
|
315
|
+
sessionId: string,
|
|
316
|
+
clientId: string,
|
|
317
|
+
scope: string,
|
|
318
|
+
parentId?: number,
|
|
319
|
+
): Promise<TokenGrant> {
|
|
320
|
+
const accessToken = generateToken()
|
|
321
|
+
const refreshToken = generateToken()
|
|
322
|
+
const now = Date.now()
|
|
323
|
+
await this.#store.createToken({
|
|
324
|
+
token: accessToken,
|
|
325
|
+
type: 'access',
|
|
326
|
+
userId,
|
|
327
|
+
sessionId,
|
|
328
|
+
clientId,
|
|
329
|
+
scope,
|
|
330
|
+
expiresAt: new Date(now + this.#settings.accessTokenSeconds * 1000),
|
|
331
|
+
parentId,
|
|
332
|
+
})
|
|
333
|
+
await this.#store.createToken({
|
|
334
|
+
token: refreshToken,
|
|
335
|
+
type: 'refresh',
|
|
336
|
+
userId,
|
|
337
|
+
sessionId,
|
|
338
|
+
clientId,
|
|
339
|
+
scope,
|
|
340
|
+
expiresAt: new Date(now + this.#settings.refreshTokenSeconds * 1000),
|
|
341
|
+
parentId,
|
|
342
|
+
})
|
|
343
|
+
return {
|
|
344
|
+
accessToken,
|
|
345
|
+
refreshToken,
|
|
346
|
+
expiresIn: this.#settings.accessTokenSeconds,
|
|
347
|
+
issuedAt: Math.floor(now / 1000),
|
|
348
|
+
sessionId,
|
|
349
|
+
userId,
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export { passwordConfigJson }
|