@crombolo/auth 0.0.0-stage → 0.9.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/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/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