@crombolo/auth 0.10.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/auth",
3
- "version": "0.10.0",
3
+ "version": "0.12.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,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.12.1",
32
+ "@crombolo/data": "0.12.1"
33
33
  }
34
34
  }
package/src/index.ts CHANGED
@@ -6,3 +6,4 @@ export * from './pkce.ts'
6
6
  export * from './routes.ts'
7
7
  export * from './service.ts'
8
8
  export * from './store.ts'
9
+ export * from './umbraco-passwords.ts'
package/src/password.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Password hashing.
3
- *
4
- * Umbraco stores ASP.NET Core Identity v3 hashes (PBKDF2-HMAC-SHA512). We are not
5
- * reading existing Umbraco databases, so we use argon2id instead, and keep the
6
- * `password_config` marker column so the algorithm is recorded per row and can be
7
- * rotated later exactly as Umbraco rotates its legacy formats.
2
+ * Password hashing: argon2id, with the algorithm recorded per row in
3
+ * `password_config`. A hash imported from Umbraco (story 071) is verified in
4
+ * its own format (`umbraco-passwords.ts`) and replaced with argon2id on the
5
+ * first successful sign-in, as Umbraco rotates its legacy formats.
8
6
  */
7
+ import { verifyUmbracoPassword } from './umbraco-passwords.ts'
8
+
9
9
  export const PASSWORD_ALGORITHM = 'ARGON2ID'
10
10
 
11
11
  export interface PasswordConfig {
@@ -19,12 +19,23 @@ export async function hashPassword(password: string): Promise<string> {
19
19
  return Bun.password.hash(password, { algorithm: 'argon2id' })
20
20
  }
21
21
 
22
+ /** Whether a stored hash is crombolo's own; anything else is replaced on a successful sign-in. Pure. */
23
+ export const isNativeHash = (hash: string | null | undefined): boolean =>
24
+ typeof hash === 'string' && hash.startsWith('$argon2')
25
+
22
26
  /**
23
27
  * Verifies a password. Returns false rather than throwing for a malformed or
24
- * absent hash, so a user row with no password simply cannot sign in.
28
+ * absent hash, so a user row with no password simply cannot sign in. A hash
29
+ * that is not argon2id is tried as Umbraco's, by its `passwordConfig`.
25
30
  */
26
- export async function verifyPassword(password: string, hash: string | null): Promise<boolean> {
31
+ export async function verifyPassword(
32
+ password: string,
33
+ hash: string | null,
34
+ imported: { passwordConfig?: string | null; forMember?: boolean } = {},
35
+ ): Promise<boolean> {
27
36
  if (!hash) return false
37
+ if (!isNativeHash(hash))
38
+ return verifyUmbracoPassword(password, hash, imported.passwordConfig, imported.forMember)
28
39
  try {
29
40
  return await Bun.password.verify(password, hash)
30
41
  } catch {
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
@@ -3,7 +3,7 @@
3
3
  * actually sends. See docs/backoffice-hosting.md for the wire contract.
4
4
  */
5
5
  import { type ExtensionSectionAlias, type SectionAlias, toSectionAliases } from '@crombolo/core'
6
- import { hashPassword, passwordConfigJson, verifyPassword } from './password.ts'
6
+ import { hashPassword, isNativeHash, passwordConfigJson, verifyPassword } from './password.ts'
7
7
  import { type CodeChallengeMethod, generateToken, verifyChallenge } from './pkce.ts'
8
8
  import type { AuthStore, UserRow } from './store.ts'
9
9
 
@@ -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,31 @@ 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
- if (!(await verifyPassword(password, user.passwordHash))) {
134
+ if (
135
+ !(await verifyPassword(password, user.passwordHash, {
136
+ passwordConfig: user.passwordConfig,
137
+ }))
138
+ ) {
131
139
  const attempts = await this.#store.recordLoginFailure(user.id)
132
- if (
140
+ const lockedOut =
133
141
  this.#settings.maxFailedLoginAttempts > 0 &&
134
142
  attempts >= this.#settings.maxFailedLoginAttempts
135
- ) {
136
- await this.#store.lockOut(user.id)
137
- }
138
- return { status: 'invalid' } as const
143
+ if (lockedOut) await this.#store.lockOut(user.id)
144
+ return { status: 'invalid', user, lockedOut } as const
139
145
  }
140
146
  // `isApproved` stays behind the password on purpose: it is not a state an
141
147
  // attacker can induce, so answering it only to whoever knows the password
142
148
  // discloses strictly less than checking it up front.
143
- if (!user.isApproved) return { status: 'notAllowed', reason: 'notApproved' } as const
149
+ if (!user.isApproved) return { status: 'notAllowed', reason: 'notApproved', user } as const
150
+ // An imported Umbraco hash is replaced now the password is known to match it.
151
+ if (!isNativeHash(user.passwordHash))
152
+ await this.#store.rehashPassword(
153
+ user.id,
154
+ await hashPassword(password),
155
+ passwordConfigJson(),
156
+ )
144
157
 
145
158
  const sessionId = crypto.randomUUID()
146
159
  await this.#store.recordLoginSuccess(user.id, sessionId, ipAddress)
@@ -247,7 +260,7 @@ export class AuthService {
247
260
  clientId: string,
248
261
  secret: string,
249
262
  ): Promise<
250
- | { ok: true; accessToken: string; expiresIn: number; issuedAt: number }
263
+ | { ok: true; accessToken: string; expiresIn: number; issuedAt: number; user: UserRow }
251
264
  | { ok: false; error: TokenError; description: string }
252
265
  > {
253
266
  const credential = await this.#store.findClientCredential(clientId)
@@ -273,6 +286,7 @@ export class AuthService {
273
286
  accessToken,
274
287
  expiresIn: this.#settings.accessTokenSeconds,
275
288
  issuedAt: Math.floor(now / 1000),
289
+ user,
276
290
  }
277
291
  }
278
292
 
@@ -301,13 +315,18 @@ export class AuthService {
301
315
  return this.#store.findSession(sessionId)
302
316
  }
303
317
 
304
- async signOut(sessionId: string): Promise<void> {
318
+ /** Ends the session, and returns whose it was while it was still open. */
319
+ async signOut(sessionId: string): Promise<UserRow | undefined> {
320
+ const session = await this.#store.findSession(sessionId)
321
+ const user = session ? await this.#store.findUserById(session.userId) : undefined
305
322
  await this.#store.endSession(sessionId)
323
+ return user
306
324
  }
307
325
 
308
- async revokeAccessToken(accessToken: string): Promise<void> {
326
+ async revokeAccessToken(accessToken: string): Promise<UserRow | undefined> {
309
327
  const token = await this.#store.findToken(accessToken, 'access')
310
- if (token) await this.#store.endSession(token.sessionId)
328
+ if (!token) return undefined
329
+ return this.signOut(token.sessionId)
311
330
  }
312
331
 
313
332
  async #issue(
package/src/store.ts CHANGED
@@ -14,6 +14,8 @@ export interface UserRow {
14
14
  login: string
15
15
  email: string
16
16
  passwordHash: string | null
17
+ /** The algorithm marker beside the hash; an imported one names Umbraco's. */
18
+ passwordConfig: string | null
17
19
  language: string | null
18
20
  avatar: string | null
19
21
  isLockedOut: boolean
@@ -71,6 +73,7 @@ function mapUser(row: Record<string, unknown>): UserRow {
71
73
  login: String(row.login),
72
74
  email: String(row.email),
73
75
  passwordHash: (row.password_hash as string | null) ?? null,
76
+ passwordConfig: (row.password_config as string | null) ?? null,
74
77
  language: (row.language as string | null) ?? null,
75
78
  avatar: (row.avatar as string | null) ?? null,
76
79
  isLockedOut: fromDbBool(row.is_locked_out),
@@ -81,7 +84,7 @@ function mapUser(row: Record<string, unknown>): UserRow {
81
84
  }
82
85
 
83
86
  const USER_COLUMNS =
84
- 'id, key, user_name, login, email, password_hash, language, avatar, is_locked_out, is_approved, failed_login_attempts, kind'
87
+ 'id, key, user_name, login, email, password_hash, password_config, language, avatar, is_locked_out, is_approved, failed_login_attempts, kind'
85
88
 
86
89
  export class AuthStore {
87
90
  #db: Db
@@ -154,6 +157,21 @@ export class AuthStore {
154
157
  * Replaces a user's password. The caller supplies an already-hashed value, so
155
158
  * this module never needs to know which algorithm is in use.
156
159
  */
160
+ /**
161
+ * Replaces an imported hash with argon2id after a successful sign-in. The
162
+ * password is the same, so the security stamp and change date stand.
163
+ */
164
+ async rehashPassword(
165
+ userId: number,
166
+ passwordHash: string,
167
+ passwordConfig: string,
168
+ ): Promise<void> {
169
+ await this.#db.exec(
170
+ 'UPDATE user_account SET password_hash = ?, password_config = ? WHERE id = ?',
171
+ [passwordHash, passwordConfig, userId],
172
+ )
173
+ }
174
+
157
175
  async setPassword(userId: number, passwordHash: string, passwordConfig: string): Promise<void> {
158
176
  const now = DbDate.toDb(new Date())
159
177
  await this.#db.exec(
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Verifying the password hashes an Umbraco site stores, so an imported user or
3
+ * member signs in with the password they had; a success is then re-hashed to
4
+ * argon2id. In the order Umbraco's `UmbracoPasswordHasher` and
5
+ * `MemberPasswordHasher` try them:
6
+ *
7
+ * - ASP.NET Core Identity v3 (`0x01`, PBKDF2 with the PRF and iterations it
8
+ * records) and v2 (`0x00`, PBKDF2-HMAC-SHA1, 1,000 iterations);
9
+ * - Umbraco 4's HMAC-SHA1 keyed with the password itself;
10
+ * - Umbraco 6–8's HMAC-SHA256 (or plain SHA1) over a base64 salt that prefixes
11
+ * the hash, named by `passwordConfig` — or assumed for a member with none,
12
+ * as a site upgraded from 8 has.
13
+ *
14
+ * Every comparison is constant-time. Pure.
15
+ */
16
+ import { createHash, createHmac, pbkdf2Sync, timingSafeEqual } from 'node:crypto'
17
+
18
+ export const UMBRACO_PASSWORD_ALGORITHMS = [
19
+ 'PBKDF2.ASPNETCORE.V3',
20
+ 'PBKDF2.ASPNETCORE.V2',
21
+ 'HMACSHA256',
22
+ 'HMACSHA1',
23
+ ] as const
24
+
25
+ const same = (a: Buffer, b: Buffer) => a.length === b.length && timingSafeEqual(a, b)
26
+
27
+ const utf16 = (value: string) => Buffer.from(value, 'utf16le')
28
+
29
+ function base64(value: string): Buffer | undefined {
30
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 !== 0) return undefined
31
+ return Buffer.from(value, 'base64')
32
+ }
33
+
34
+ const PRFS = ['sha1', 'sha256', 'sha512'] as const
35
+
36
+ /** ASP.NET Core Identity v3 or v2, by its first byte; the password as UTF-8. */
37
+ export function verifyAspNetIdentity(password: string, hash: string): boolean {
38
+ const bytes = base64(hash)
39
+ if (!bytes || bytes.length === 0) return false
40
+ if (bytes[0] === 0x00) {
41
+ if (bytes.length !== 49) return false
42
+ const salt = bytes.subarray(1, 17)
43
+ const expected = bytes.subarray(17)
44
+ return same(pbkdf2Sync(password, salt, 1000, 32, 'sha1'), expected)
45
+ }
46
+ if (bytes[0] === 0x01) {
47
+ if (bytes.length < 13) return false
48
+ const prf = PRFS[bytes.readUInt32BE(1)]
49
+ const iterations = bytes.readUInt32BE(5)
50
+ const saltLength = bytes.readUInt32BE(9)
51
+ if (!prf || iterations < 1 || saltLength < 16 || bytes.length < 13 + saltLength + 16)
52
+ return false
53
+ const salt = bytes.subarray(13, 13 + saltLength)
54
+ const expected = bytes.subarray(13 + saltLength)
55
+ return same(pbkdf2Sync(password, salt, iterations, expected.length, prf), expected)
56
+ }
57
+ return false
58
+ }
59
+
60
+ /** Umbraco 4's "legacy encoding": HMAC-SHA1 whose key is the password, over the password. */
61
+ export function verifyUmbraco4(password: string, hash: string): boolean {
62
+ const expected = base64(hash)
63
+ if (!expected) return false
64
+ return same(createHmac('sha1', utf16(password)).update(utf16(password)).digest(), expected)
65
+ }
66
+
67
+ /**
68
+ * Umbraco 6–8: a 24-character base64 salt, then the base64 hash of the
69
+ * password (UTF-16) under HMAC-SHA256 keyed with the salt repeated to 64
70
+ * bytes — .NET's key length for it — or SHA1 over salt and password.
71
+ */
72
+ export function verifyUmbraco8(password: string, hash: string, algorithm = 'HMACSHA256'): boolean {
73
+ if (hash.length <= 24) return false
74
+ const salt = base64(hash.slice(0, 24))
75
+ const expected = base64(hash.slice(24))
76
+ if (!salt || !expected || salt.length === 0) return false
77
+ const name = algorithm.toUpperCase()
78
+ if (name === 'HMACSHA256') {
79
+ const key = Buffer.alloc(64)
80
+ for (let at = 0; at < key.length; at += salt.length)
81
+ salt.copy(key, at, 0, Math.min(salt.length, key.length - at))
82
+ return same(createHmac('sha256', key).update(utf16(password)).digest(), expected)
83
+ }
84
+ if (name === 'SHA1')
85
+ return same(
86
+ createHash('sha1')
87
+ .update(Buffer.concat([salt, utf16(password)]))
88
+ .digest(),
89
+ expected,
90
+ )
91
+ return false
92
+ }
93
+
94
+ /** The `hashAlgorithm` an Umbraco `passwordConfig` names, when it names one. Pure. */
95
+ export function umbracoAlgorithmOf(passwordConfig: string | null | undefined): string | undefined {
96
+ if (!passwordConfig) return undefined
97
+ try {
98
+ const parsed = JSON.parse(passwordConfig) as { hashAlgorithm?: unknown }
99
+ return typeof parsed.hashAlgorithm === 'string' ? parsed.hashAlgorithm : undefined
100
+ } catch {
101
+ return undefined
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Whether a password matches a hash an Umbraco site stored. `forMember`: a
107
+ * member with no `passwordConfig` is assumed to be on Umbraco 8's hashing,
108
+ * which is how Umbraco treats one upgraded from 8.
109
+ */
110
+ export function verifyUmbracoPassword(
111
+ password: string,
112
+ hash: string | null | undefined,
113
+ passwordConfig: string | null | undefined,
114
+ forMember = false,
115
+ ): boolean {
116
+ if (!hash || !password) return false
117
+ if (verifyAspNetIdentity(password, hash)) return true
118
+ if (verifyUmbraco4(password, hash)) return true
119
+ const algorithm = umbracoAlgorithmOf(passwordConfig) ?? (forMember ? 'HMACSHA256' : undefined)
120
+ if (!algorithm) return false
121
+ return verifyUmbraco8(password, hash, algorithm)
122
+ }
123
+
124
+ /** Whether a `passwordConfig` is one of Umbraco's, rather than crombolo's argon2id. Pure. */
125
+ export const isUmbracoPasswordConfig = (passwordConfig: string | null | undefined): boolean => {
126
+ const algorithm = umbracoAlgorithmOf(passwordConfig)
127
+ return algorithm !== undefined && algorithm.toUpperCase() !== 'ARGON2ID'
128
+ }