@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/store.ts ADDED
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Persistence for identity, sessions, codes and reference tokens.
3
+ *
4
+ * Everything bearer-shaped is stored hashed, so a database read cannot yield a
5
+ * usable credential.
6
+ */
7
+ import { type Db, DbDate, fromDbBool, Locks } from '@crombolo/data'
8
+ import { hashToken } from './pkce.ts'
9
+
10
+ export interface UserRow {
11
+ id: number
12
+ key: string
13
+ userName: string
14
+ login: string
15
+ email: string
16
+ passwordHash: string | null
17
+ language: string | null
18
+ avatar: string | null
19
+ isLockedOut: boolean
20
+ isApproved: boolean
21
+ failedLoginAttempts: number
22
+ /** Umbraco's `UserKind`: API users sign in with client credentials only. */
23
+ kind: 'Default' | 'Api'
24
+ }
25
+
26
+ export interface AuthorizationCodeInit {
27
+ code: string
28
+ userId: number
29
+ sessionId: string
30
+ clientId: string
31
+ redirectUri: string
32
+ codeChallenge: string
33
+ codeChallengeMethod: string
34
+ scope: string
35
+ expiresAt: Date
36
+ }
37
+
38
+ export interface AuthorizationCodeRow extends Omit<AuthorizationCodeInit, 'code'> {
39
+ consumedAt: Date | undefined
40
+ }
41
+
42
+ export type TokenType = 'access' | 'refresh'
43
+
44
+ export interface TokenInit {
45
+ token: string
46
+ type: TokenType
47
+ userId: number
48
+ sessionId: string
49
+ clientId: string
50
+ scope: string
51
+ expiresAt: Date
52
+ parentId?: number
53
+ }
54
+
55
+ export interface TokenRow {
56
+ id: number
57
+ type: TokenType
58
+ userId: number
59
+ sessionId: string
60
+ clientId: string
61
+ scope: string
62
+ expiresAt: Date
63
+ revokedAt: Date | undefined
64
+ }
65
+
66
+ function mapUser(row: Record<string, unknown>): UserRow {
67
+ return {
68
+ id: Number(row.id),
69
+ key: String(row.key),
70
+ userName: String(row.user_name),
71
+ login: String(row.login),
72
+ email: String(row.email),
73
+ passwordHash: (row.password_hash as string | null) ?? null,
74
+ language: (row.language as string | null) ?? null,
75
+ avatar: (row.avatar as string | null) ?? null,
76
+ isLockedOut: fromDbBool(row.is_locked_out),
77
+ isApproved: fromDbBool(row.is_approved),
78
+ failedLoginAttempts: Number(row.failed_login_attempts ?? 0),
79
+ kind: Number(row.kind) === 1 ? 'Api' : 'Default',
80
+ }
81
+ }
82
+
83
+ const USER_COLUMNS =
84
+ 'id, key, user_name, login, email, password_hash, language, avatar, is_locked_out, is_approved, failed_login_attempts, kind'
85
+
86
+ export class AuthStore {
87
+ #db: Db
88
+
89
+ constructor(db: Db) {
90
+ this.#db = db
91
+ }
92
+
93
+ async findUserByLogin(login: string): Promise<UserRow | undefined> {
94
+ // login is a case-insensitive column in both dialects, so no lower() needed.
95
+ const rows = await this.#db.query(`SELECT ${USER_COLUMNS} FROM user_account WHERE login = ?`, [
96
+ login,
97
+ ])
98
+ return rows[0] ? mapUser(rows[0]) : undefined
99
+ }
100
+
101
+ async findUserById(id: number): Promise<UserRow | undefined> {
102
+ const rows = await this.#db.query(`SELECT ${USER_COLUMNS} FROM user_account WHERE id = ?`, [id])
103
+ return rows[0] ? mapUser(rows[0]) : undefined
104
+ }
105
+
106
+ /** An API user's client credential: whose it is, and the secret's hash. */
107
+ async findClientCredential(
108
+ clientId: string,
109
+ ): Promise<{ userId: number; secretHash: string } | undefined> {
110
+ const rows = await this.#db.query<{ user_id: number; secret_hash: string }>(
111
+ 'SELECT user_id, secret_hash FROM user_client_credential WHERE client_id = ?',
112
+ [clientId],
113
+ )
114
+ return rows[0]
115
+ ? { userId: Number(rows[0].user_id), secretHash: String(rows[0].secret_hash) }
116
+ : undefined
117
+ }
118
+
119
+ async recordLoginSuccess(userId: number, sessionId: string, ipAddress?: string): Promise<void> {
120
+ const now = DbDate.toDb(new Date())
121
+ await this.#db.exec(
122
+ 'UPDATE user_account SET failed_login_attempts = 0, last_login_date = ?, update_date = ? WHERE id = ?',
123
+ [now, now, userId],
124
+ )
125
+ await this.#db.exec(
126
+ `INSERT INTO user_login_session (session_id, user_id, logged_in_utc, last_validated_utc, ip_address)
127
+ VALUES (?, ?, ?, ?, ?)`,
128
+ [sessionId, userId, now, now, ipAddress ?? null],
129
+ )
130
+ }
131
+
132
+ async recordLoginFailure(userId: number): Promise<number> {
133
+ const rows = await this.#db.query<{ failed_login_attempts: number }>(
134
+ 'SELECT failed_login_attempts FROM user_account WHERE id = ?',
135
+ [userId],
136
+ )
137
+ const attempts = Number(rows[0]?.failed_login_attempts ?? 0) + 1
138
+ await this.#db.exec(
139
+ 'UPDATE user_account SET failed_login_attempts = ?, update_date = ? WHERE id = ?',
140
+ [attempts, DbDate.toDb(new Date()), userId],
141
+ )
142
+ return attempts
143
+ }
144
+
145
+ async lockOut(userId: number): Promise<void> {
146
+ const now = DbDate.toDb(new Date())
147
+ await this.#db.exec(
148
+ 'UPDATE user_account SET is_locked_out = ?, last_lockout_date = ?, update_date = ? WHERE id = ?',
149
+ [this.#db.dialect.boolValue(true), now, now, userId],
150
+ )
151
+ }
152
+
153
+ /**
154
+ * Replaces a user's password. The caller supplies an already-hashed value, so
155
+ * this module never needs to know which algorithm is in use.
156
+ */
157
+ async setPassword(userId: number, passwordHash: string, passwordConfig: string): Promise<void> {
158
+ const now = DbDate.toDb(new Date())
159
+ await this.#db.exec(
160
+ `UPDATE user_account
161
+ SET password_hash = ?, password_config = ?, security_stamp = ?,
162
+ last_password_change_date = ?, failed_login_attempts = 0, update_date = ?
163
+ WHERE id = ?`,
164
+ [passwordHash, passwordConfig, crypto.randomUUID(), now, now, userId],
165
+ )
166
+ }
167
+
168
+ /** Ends every session a user holds, so a password change logs them out. */
169
+ async endAllSessionsForUser(userId: number): Promise<number> {
170
+ const sessions = await this.#db.query<{ session_id: string }>(
171
+ 'SELECT session_id FROM user_login_session WHERE user_id = ? AND logged_out_utc IS NULL',
172
+ [userId],
173
+ )
174
+ for (const session of sessions) await this.endSession(String(session.session_id))
175
+ return sessions.length
176
+ }
177
+
178
+ /** Clears a lockout without touching the password. */
179
+ async enable(userId: number): Promise<void> {
180
+ await this.#db.exec(
181
+ 'UPDATE user_account SET is_locked_out = ?, is_approved = ?, failed_login_attempts = 0, update_date = ? WHERE id = ?',
182
+ [
183
+ this.#db.dialect.boolValue(false),
184
+ this.#db.dialect.boolValue(true),
185
+ DbDate.toDb(new Date()),
186
+ userId,
187
+ ],
188
+ )
189
+ }
190
+
191
+ async findSession(sessionId: string): Promise<{ userId: number } | undefined> {
192
+ const rows = await this.#db.query<{ user_id: number }>(
193
+ 'SELECT user_id FROM user_login_session WHERE session_id = ? AND logged_out_utc IS NULL',
194
+ [sessionId],
195
+ )
196
+ return rows[0] ? { userId: Number(rows[0].user_id) } : undefined
197
+ }
198
+
199
+ async endSession(sessionId: string): Promise<void> {
200
+ const now = DbDate.toDb(new Date())
201
+ await this.#db.exec(
202
+ 'UPDATE user_login_session SET logged_out_utc = ? WHERE session_id = ? AND logged_out_utc IS NULL',
203
+ [now, sessionId],
204
+ )
205
+ await this.#db.exec(
206
+ 'UPDATE auth_token SET revoked_at = ? WHERE session_id = ? AND revoked_at IS NULL',
207
+ [now, sessionId],
208
+ )
209
+ }
210
+
211
+ async createAuthorizationCode(init: AuthorizationCodeInit): Promise<void> {
212
+ await this.#db.exec(
213
+ `INSERT INTO auth_code
214
+ (code_hash, user_id, session_id, client_id, redirect_uri, code_challenge,
215
+ code_challenge_method, scope, expires_at, create_date)
216
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
217
+ [
218
+ hashToken(init.code),
219
+ init.userId,
220
+ init.sessionId,
221
+ init.clientId,
222
+ init.redirectUri,
223
+ init.codeChallenge,
224
+ init.codeChallengeMethod,
225
+ init.scope,
226
+ DbDate.toDb(init.expiresAt),
227
+ DbDate.toDb(new Date()),
228
+ ],
229
+ )
230
+ }
231
+
232
+ /**
233
+ * Consumes a code atomically under the key-value lock: an authorization code is
234
+ * single-use, and two simultaneous exchanges must not both succeed.
235
+ */
236
+ async consumeAuthorizationCode(code: string): Promise<AuthorizationCodeRow | undefined> {
237
+ return this.#db.locks.withLock(Locks.KeyValues, async () => {
238
+ const hash = hashToken(code)
239
+ const rows = await this.#db.query(
240
+ `SELECT user_id, session_id, client_id, redirect_uri, code_challenge,
241
+ code_challenge_method, scope, expires_at, consumed_at
242
+ FROM auth_code WHERE code_hash = ?`,
243
+ [hash],
244
+ )
245
+ const row = rows[0]
246
+ if (!row) return undefined
247
+ await this.#db.exec('UPDATE auth_code SET consumed_at = ? WHERE code_hash = ?', [
248
+ DbDate.toDb(new Date()),
249
+ hash,
250
+ ])
251
+ return {
252
+ userId: Number(row.user_id),
253
+ sessionId: String(row.session_id),
254
+ clientId: String(row.client_id),
255
+ redirectUri: String(row.redirect_uri),
256
+ codeChallenge: String(row.code_challenge),
257
+ codeChallengeMethod: String(row.code_challenge_method),
258
+ scope: String(row.scope),
259
+ expiresAt: DbDate.fromDb(row.expires_at) ?? new Date(0),
260
+ consumedAt: DbDate.fromDb(row.consumed_at),
261
+ }
262
+ })
263
+ }
264
+
265
+ async createToken(init: TokenInit): Promise<number> {
266
+ await this.#db.exec(
267
+ `INSERT INTO auth_token
268
+ (token_hash, token_type, user_id, session_id, client_id, scope, expires_at, create_date, parent_id)
269
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
270
+ [
271
+ hashToken(init.token),
272
+ init.type,
273
+ init.userId,
274
+ init.sessionId,
275
+ init.clientId,
276
+ init.scope,
277
+ DbDate.toDb(init.expiresAt),
278
+ DbDate.toDb(new Date()),
279
+ init.parentId ?? null,
280
+ ],
281
+ )
282
+ const rows = await this.#db.query<{ id: number }>(
283
+ 'SELECT id FROM auth_token WHERE token_hash = ?',
284
+ [hashToken(init.token)],
285
+ )
286
+ return Number(rows[0]?.id ?? 0)
287
+ }
288
+
289
+ async findToken(token: string, type: TokenType): Promise<TokenRow | undefined> {
290
+ const rows = await this.#db.query(
291
+ `SELECT id, token_type, user_id, session_id, client_id, scope, expires_at, revoked_at
292
+ FROM auth_token WHERE token_hash = ? AND token_type = ?`,
293
+ [hashToken(token), type],
294
+ )
295
+ const row = rows[0]
296
+ if (!row) return undefined
297
+ return {
298
+ id: Number(row.id),
299
+ type: String(row.token_type) as TokenType,
300
+ userId: Number(row.user_id),
301
+ sessionId: String(row.session_id),
302
+ clientId: String(row.client_id),
303
+ scope: String(row.scope),
304
+ expiresAt: DbDate.fromDb(row.expires_at) ?? new Date(0),
305
+ revokedAt: DbDate.fromDb(row.revoked_at),
306
+ }
307
+ }
308
+
309
+ async revokeToken(id: number): Promise<void> {
310
+ await this.#db.exec(
311
+ 'UPDATE auth_token SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL',
312
+ [DbDate.toDb(new Date()), id],
313
+ )
314
+ }
315
+
316
+ /** Removes expired codes and tokens; called by the background job. */
317
+ async pruneExpired(now = new Date()): Promise<void> {
318
+ const cutoff = DbDate.toDb(now)
319
+ await this.#db.exec('DELETE FROM auth_code WHERE expires_at < ?', [cutoff])
320
+ await this.#db.exec('DELETE FROM auth_token WHERE expires_at < ?', [cutoff])
321
+ }
322
+
323
+ /** Section aliases and admin membership, unioned across the user's groups. */
324
+ async loadAuthorization(userId: number): Promise<{
325
+ appAliases: string[]
326
+ groupKeys: string[]
327
+ isAdmin: boolean
328
+ hasAccessToAllLanguages: boolean
329
+ permissions: string[]
330
+ granular: Array<{ nodeKey: string; permission: string; context: string }>
331
+ }> {
332
+ const groups = await this.#db.query(
333
+ `SELECT g.id, g.key, g.alias, g.has_access_to_all_languages
334
+ FROM user_group g
335
+ JOIN user_group_member m ON m.user_group_id = g.id
336
+ WHERE m.user_id = ?`,
337
+ [userId],
338
+ )
339
+ const groupKeys = groups.map((g) => String(g.key))
340
+ const isAdmin = groups.some((g) => String(g.alias) === 'admin')
341
+ const hasAccessToAllLanguages = groups.some((g) => fromDbBool(g.has_access_to_all_languages))
342
+
343
+ if (groups.length === 0) {
344
+ return {
345
+ appAliases: [],
346
+ groupKeys: [],
347
+ isAdmin: false,
348
+ hasAccessToAllLanguages: false,
349
+ permissions: [],
350
+ granular: [],
351
+ }
352
+ }
353
+
354
+ const sections = await this.#db.query<{ app_alias: string }>(
355
+ `SELECT DISTINCT s.app_alias
356
+ FROM user_group_section s
357
+ JOIN user_group_member m ON m.user_group_id = s.user_group_id
358
+ WHERE m.user_id = ?`,
359
+ [userId],
360
+ )
361
+ const permissions = await this.#db.query<{ permission: string }>(
362
+ `SELECT DISTINCT p.permission
363
+ FROM user_group_permission p
364
+ JOIN user_group g ON g.key = p.user_group_key
365
+ JOIN user_group_member m ON m.user_group_id = g.id
366
+ WHERE m.user_id = ?`,
367
+ [userId],
368
+ )
369
+ const granular = await this.#db.query(
370
+ `SELECT gp.unique_id, gp.permission, gp.context
371
+ FROM user_group_granular_permission gp
372
+ JOIN user_group g ON g.key = gp.user_group_key
373
+ JOIN user_group_member m ON m.user_group_id = g.id
374
+ WHERE m.user_id = ? AND gp.unique_id IS NOT NULL`,
375
+ [userId],
376
+ )
377
+
378
+ return {
379
+ appAliases: sections.map((s) => String(s.app_alias)),
380
+ groupKeys,
381
+ isAdmin,
382
+ hasAccessToAllLanguages,
383
+ permissions: permissions.map((p) => String(p.permission)),
384
+ granular: granular.map((g) => ({
385
+ nodeKey: String(g.unique_id),
386
+ permission: String(g.permission),
387
+ context: String(g.context),
388
+ })),
389
+ }
390
+ }
391
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.