@crombolo/auth 0.10.0 → 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/auth",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
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,7 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@crombolo/core": "0.10.0",
32
- "@crombolo/data": "0.10.0"
31
+ "@crombolo/core": "0.11.0",
32
+ "@crombolo/data": "0.11.0"
33
33
  }
34
34
  }
package/src/routes.ts CHANGED
@@ -17,6 +17,7 @@ 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 type { UserRow } from './store.ts'
20
21
 
21
22
  export const SECURITY_PREFIX = `${MANAGEMENT_API_PATH}/security/back-office`
22
23
 
@@ -24,6 +25,17 @@ export interface AuthRouteOptions {
24
25
  backOfficePath: string
25
26
  service: AuthService
26
27
  cookies: CookieOptions
28
+ /** Told about every sign-in, failed sign-in and sign-out, for the audit log. */
29
+ onAudit?: (event: AuthAuditEvent, request: Request) => Promise<void>
30
+ }
31
+
32
+ export interface AuthAuditEvent {
33
+ action: 'SignIn' | 'SignInFailed' | 'LockedOut' | 'SignOut'
34
+ /** Absent when the login named nobody. */
35
+ user?: Pick<UserRow, 'key' | 'userName'>
36
+ /** The login that was tried, for a failure that names nobody. */
37
+ login?: string
38
+ detail?: string
27
39
  }
28
40
 
29
41
  export interface AuthRoutes {
@@ -47,6 +59,8 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
47
59
  const names = cookieNames(options.cookies)
48
60
  const secure = options.cookies.securePrefix
49
61
  const { service } = options
62
+ const audit = (event: AuthAuditEvent, request: Request) =>
63
+ options.onAudit?.(event, request) ?? Promise.resolve()
50
64
 
51
65
  const expectedRedirectUri = (origin: string) =>
52
66
  `${origin}${options.backOfficePath}/oauth_complete`
@@ -144,7 +158,14 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
144
158
  // API users: a machine client presents its own id and secret and gets a bearer token back
145
159
  if (grantType === 'client_credentials') {
146
160
  const issued = await service.clientCredentials(clientId, form.get('client_secret') ?? '')
147
- if (!issued.ok) return oauthError(issued.error, issued.description, 401)
161
+ if (!issued.ok) {
162
+ await audit(
163
+ { action: 'SignInFailed', login: clientId, detail: 'Client credentials refused' },
164
+ request,
165
+ )
166
+ return oauthError(issued.error, issued.description, 401)
167
+ }
168
+ await audit({ action: 'SignIn', user: issued.user, detail: 'Client credentials' }, request)
148
169
  return Response.json({
149
170
  access_token: issued.accessToken,
150
171
  token_type: 'Bearer',
@@ -262,6 +283,29 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
262
283
  request.headers.get('x-forwarded-for') ?? undefined,
263
284
  )
264
285
 
286
+ if (result.status === 'success') await audit({ action: 'SignIn', user: result.user }, request)
287
+ else if (result.status === 'invalid') {
288
+ await audit(
289
+ result.user
290
+ ? { action: 'SignInFailed', user: result.user, detail: 'Wrong password' }
291
+ : { action: 'SignInFailed', login: username, detail: 'No such user' },
292
+ request,
293
+ )
294
+ if (result.lockedOut && result.user)
295
+ await audit(
296
+ { action: 'LockedOut', user: result.user, detail: 'Too many failed attempts' },
297
+ request,
298
+ )
299
+ } else if (result.status === 'notAllowed')
300
+ await audit(
301
+ {
302
+ action: 'SignInFailed',
303
+ ...(result.user ? { user: result.user } : { login: username }),
304
+ detail: result.reason === 'notApproved' ? 'Account disabled' : 'Account locked',
305
+ },
306
+ request,
307
+ )
308
+
265
309
  // Status codes the login UI branches on: 401 invalid, 402 needs 2FA,
266
310
  // 403 locked or not approved.
267
311
  if (result.status === 'invalid') {
@@ -296,7 +340,8 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
296
340
  async function handleRevoke(request: Request): Promise<Response> {
297
341
  const form = new URLSearchParams(await request.text())
298
342
  const token = resolveRedacted(form.get('token'), readCookie(request, names.accessToken))
299
- if (token) await service.revokeAccessToken(token)
343
+ const revoked = token ? await service.revokeAccessToken(token) : undefined
344
+ if (revoked) await audit({ action: 'SignOut', user: revoked }, request)
300
345
  const response = new Response(null, { status: 200 })
301
346
  for (const cookie of clearedCookies()) response.headers.append('set-cookie', cookie)
302
347
  return response
@@ -304,7 +349,8 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
304
349
 
305
350
  async function handleSignOut(request: Request, url: URL): Promise<Response> {
306
351
  const sessionId = readCookie(request, names.session)
307
- if (sessionId) await service.signOut(sessionId)
352
+ const ended = sessionId ? await service.signOut(sessionId) : undefined
353
+ if (ended) await audit({ action: 'SignOut', user: ended }, request)
308
354
 
309
355
  const requested = url.searchParams.get('post_logout_redirect_uri')
310
356
  const allowed = `${url.origin}${options.backOfficePath}/logout`
package/src/service.ts CHANGED
@@ -38,11 +38,15 @@ export const DEFAULT_AUTH_SETTINGS: AuthSettings = {
38
38
  maxFailedLoginAttempts: 10,
39
39
  }
40
40
 
41
+ /**
42
+ * `user` on a refusal is for the audit log only, and never reaches the response:
43
+ * the status alone is what the sign-in page may learn.
44
+ */
41
45
  export type LoginResult =
42
46
  | { status: 'success'; user: UserRow; sessionId: string }
43
- | { status: 'invalid' }
47
+ | { status: 'invalid'; user?: UserRow; lockedOut?: boolean }
44
48
  | { status: 'twoFactorRequired'; providers: string[] }
45
- | { status: 'notAllowed'; reason: 'disabled' | 'lockedOut' | 'notApproved' }
49
+ | { status: 'notAllowed'; reason: 'disabled' | 'lockedOut' | 'notApproved'; user?: UserRow }
46
50
 
47
51
  export interface AuthorizationRequest {
48
52
  clientId: string
@@ -125,22 +129,20 @@ export class AuthService {
125
129
  // this path costing what the others cost.
126
130
  if (user.isLockedOut) {
127
131
  await verifyPassword(password, await DUMMY_HASH_PROMISE)
128
- return { status: 'notAllowed', reason: 'lockedOut' } as const
132
+ return { status: 'notAllowed', reason: 'lockedOut', user } as const
129
133
  }
130
134
  if (!(await verifyPassword(password, user.passwordHash))) {
131
135
  const attempts = await this.#store.recordLoginFailure(user.id)
132
- if (
136
+ const lockedOut =
133
137
  this.#settings.maxFailedLoginAttempts > 0 &&
134
138
  attempts >= this.#settings.maxFailedLoginAttempts
135
- ) {
136
- await this.#store.lockOut(user.id)
137
- }
138
- return { status: 'invalid' } as const
139
+ if (lockedOut) await this.#store.lockOut(user.id)
140
+ return { status: 'invalid', user, lockedOut } as const
139
141
  }
140
142
  // `isApproved` stays behind the password on purpose: it is not a state an
141
143
  // attacker can induce, so answering it only to whoever knows the password
142
144
  // discloses strictly less than checking it up front.
143
- if (!user.isApproved) return { status: 'notAllowed', reason: 'notApproved' } as const
145
+ if (!user.isApproved) return { status: 'notAllowed', reason: 'notApproved', user } as const
144
146
 
145
147
  const sessionId = crypto.randomUUID()
146
148
  await this.#store.recordLoginSuccess(user.id, sessionId, ipAddress)
@@ -247,7 +249,7 @@ export class AuthService {
247
249
  clientId: string,
248
250
  secret: string,
249
251
  ): Promise<
250
- | { ok: true; accessToken: string; expiresIn: number; issuedAt: number }
252
+ | { ok: true; accessToken: string; expiresIn: number; issuedAt: number; user: UserRow }
251
253
  | { ok: false; error: TokenError; description: string }
252
254
  > {
253
255
  const credential = await this.#store.findClientCredential(clientId)
@@ -273,6 +275,7 @@ export class AuthService {
273
275
  accessToken,
274
276
  expiresIn: this.#settings.accessTokenSeconds,
275
277
  issuedAt: Math.floor(now / 1000),
278
+ user,
276
279
  }
277
280
  }
278
281
 
@@ -301,13 +304,18 @@ export class AuthService {
301
304
  return this.#store.findSession(sessionId)
302
305
  }
303
306
 
304
- async signOut(sessionId: string): Promise<void> {
307
+ /** Ends the session, and returns whose it was while it was still open. */
308
+ async signOut(sessionId: string): Promise<UserRow | undefined> {
309
+ const session = await this.#store.findSession(sessionId)
310
+ const user = session ? await this.#store.findUserById(session.userId) : undefined
305
311
  await this.#store.endSession(sessionId)
312
+ return user
306
313
  }
307
314
 
308
- async revokeAccessToken(accessToken: string): Promise<void> {
315
+ async revokeAccessToken(accessToken: string): Promise<UserRow | undefined> {
309
316
  const token = await this.#store.findToken(accessToken, 'access')
310
- if (token) await this.#store.endSession(token.sessionId)
317
+ if (!token) return undefined
318
+ return this.signOut(token.sessionId)
311
319
  }
312
320
 
313
321
  async #issue(