@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/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 }