@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.14
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/README.md +52 -47
- package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
- package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
- package/dist/index.cjs +645 -150
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -9
- package/dist/index.d.ts +27 -9
- package/dist/index.js +644 -150
- package/dist/index.js.map +1 -1
- package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
- package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
- package/dist/react.d.cts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/server.cjs +2880 -1667
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +810 -169
- package/dist/server.d.ts +810 -169
- package/dist/server.js +2848 -1646
- package/dist/server.js.map +1 -1
- package/dist/svelte.cjs +2 -2
- package/dist/svelte.cjs.map +1 -1
- package/dist/svelte.d.cts +2 -2
- package/dist/svelte.d.ts +2 -2
- package/dist/svelte.js +2 -2
- package/dist/svelte.js.map +1 -1
- package/dist/vue.d.cts +1 -1
- package/dist/vue.d.ts +1 -1
- package/package.json +7 -7
- package/src/admin/admin-api.ts +327 -0
- package/src/admin/audit-log.ts +324 -0
- package/src/admin/webhooks.ts +576 -0
- package/src/bindings/create-auth-session.ts +184 -0
- package/src/bindings/create-org-session.ts +130 -0
- package/src/client/auth-client.ts +1592 -0
- package/src/client/auth-sync.ts +213 -0
- package/src/client/device-session.ts +104 -0
- package/src/client/org-client.ts +399 -0
- package/src/client/quickstart.ts +108 -0
- package/src/client/storage.ts +94 -0
- package/src/device/device-identity.ts +330 -0
- package/src/device/device-store.ts +379 -0
- package/src/encryption/auto-lock.ts +170 -0
- package/src/encryption/database-encryption.ts +265 -0
- package/src/encryption/key-derivation.ts +149 -0
- package/src/encryption/operation-encryptor.ts +361 -0
- package/src/index.ts +132 -0
- package/src/mfa/totp.ts +826 -0
- package/src/org/org-routes.ts +758 -0
- package/src/org/org-store.ts +490 -0
- package/src/org/org-types.ts +230 -0
- package/src/passkey/passkey-client.ts +597 -0
- package/src/passkey/passkey-server.ts +779 -0
- package/src/postgres/ensure-schema.ts +65 -0
- package/src/provider/adapter.ts +246 -0
- package/src/provider/built-in/auth-routes.ts +1313 -0
- package/src/provider/built-in/email-verification.ts +303 -0
- package/src/provider/built-in/password-hash.ts +118 -0
- package/src/provider/built-in/password-reset.ts +416 -0
- package/src/provider/built-in/postgres-user-store.ts +365 -0
- package/src/provider/built-in/quickstart-server.ts +760 -0
- package/src/provider/built-in/sqlite-user-store.ts +335 -0
- package/src/provider/built-in/sync-scopes.ts +85 -0
- package/src/provider/built-in/user-store.ts +465 -0
- package/src/provider/external/clerk-adapter.ts +157 -0
- package/src/provider/external/external-jwt-provider.ts +491 -0
- package/src/provider/external/supabase-adapter.ts +163 -0
- package/src/provider/oauth/linked-identity-store.ts +108 -0
- package/src/provider/oauth/oauth-flow.ts +550 -0
- package/src/provider/oauth/oauth-types.ts +184 -0
- package/src/provider/oauth/postgres-oauth-store.ts +296 -0
- package/src/provider/oauth/sqlite-oauth-store.ts +285 -0
- package/src/rbac/rbac-engine.ts +323 -0
- package/src/rbac/rbac-types.ts +210 -0
- package/src/rbac/scope-resolver.ts +140 -0
- package/src/react/AuthProvider.tsx +97 -0
- package/src/react/OrgProvider.tsx +41 -0
- package/src/react/auth-context.ts +26 -0
- package/src/react/hooks.ts +110 -0
- package/src/react/org-hooks.ts +214 -0
- package/src/react.ts +26 -0
- package/src/server.ts +338 -0
- package/src/session/session.ts +401 -0
- package/src/svelte/auth-context.ts +50 -0
- package/src/svelte/org-context.ts +32 -0
- package/src/svelte/org-hooks.ts +201 -0
- package/src/svelte/use-auth.ts +115 -0
- package/src/svelte.ts +25 -0
- package/src/tokens/encrypted-token-store.ts +360 -0
- package/src/tokens/jwt.ts +236 -0
- package/src/tokens/postgres-token-revocation-store.ts +140 -0
- package/src/tokens/sqlite-token-revocation-store.ts +121 -0
- package/src/tokens/token-manager.ts +821 -0
- package/src/tokens/token-store.ts +192 -0
- package/src/types.ts +394 -0
- package/src/vue/auth-context.ts +10 -0
- package/src/vue/auth-provider-types.ts +5 -0
- package/src/vue/auth-provider.ts +76 -0
- package/src/vue/org-hooks.ts +193 -0
- package/src/vue/org-provider.ts +49 -0
- package/src/vue/use-auth.ts +139 -0
- package/src/vue.ts +10 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { KoraError } from '@korajs/core'
|
|
2
|
+
import type { AuthUser, StoredUser, UserStore } from '../provider/built-in/user-store'
|
|
3
|
+
import type { Session, SessionStore } from '../session/session'
|
|
4
|
+
import type { AuditAction, AuditLogger } from './audit-log'
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Admin API Types
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Configuration for the Admin API.
|
|
12
|
+
*/
|
|
13
|
+
export interface AdminApiConfig {
|
|
14
|
+
/** User store for managing users */
|
|
15
|
+
userStore: UserStore
|
|
16
|
+
/** Session store for managing sessions (optional) */
|
|
17
|
+
sessionStore?: SessionStore
|
|
18
|
+
/** Audit logger (optional) */
|
|
19
|
+
auditLogger?: AuditLogger
|
|
20
|
+
/**
|
|
21
|
+
* Authorization check for the acting admin. When set, every method that
|
|
22
|
+
* takes an `adminId` throws {@link AdminUnauthorizedError} unless it returns
|
|
23
|
+
* true. Without it, the app must gate access to AdminApi itself.
|
|
24
|
+
*/
|
|
25
|
+
isAdmin?: (adminId: string) => boolean | Promise<boolean>
|
|
26
|
+
/**
|
|
27
|
+
* Revoke every credential of a user (defaults to the user store's token
|
|
28
|
+
* revocation store). Called by `revokeUserSessions`, `suspend`-style flows
|
|
29
|
+
* and `deleteUser`, so JWTs and refresh tokens die with the session rows.
|
|
30
|
+
* Pass `authServer.revokeAllForUser` to also end live sync sessions.
|
|
31
|
+
*/
|
|
32
|
+
revokeAllForUser?: (userId: string) => Promise<void>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Paginated result set.
|
|
37
|
+
*/
|
|
38
|
+
export interface PaginatedResult<T> {
|
|
39
|
+
data: T[]
|
|
40
|
+
total: number
|
|
41
|
+
limit: number
|
|
42
|
+
offset: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* User list query parameters.
|
|
47
|
+
*/
|
|
48
|
+
export interface UserListQuery {
|
|
49
|
+
/** Search by email (substring match) */
|
|
50
|
+
email?: string
|
|
51
|
+
/** Filter by email verified status */
|
|
52
|
+
emailVerified?: boolean
|
|
53
|
+
/** Maximum number of results */
|
|
54
|
+
limit?: number
|
|
55
|
+
/** Offset for pagination */
|
|
56
|
+
offset?: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Admin-level user update (different from self-update).
|
|
61
|
+
*/
|
|
62
|
+
export interface AdminUserUpdate {
|
|
63
|
+
/** Update display name */
|
|
64
|
+
name?: string
|
|
65
|
+
/** Set email verified status */
|
|
66
|
+
emailVerified?: boolean
|
|
67
|
+
/** Update email (admin override) */
|
|
68
|
+
email?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// Errors
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
export class AdminApiError extends KoraError {
|
|
76
|
+
constructor(message: string, code: string, context?: Record<string, unknown>) {
|
|
77
|
+
super(message, code, context)
|
|
78
|
+
this.name = 'AdminApiError'
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class AdminUserNotFoundError extends AdminApiError {
|
|
83
|
+
constructor(userId: string) {
|
|
84
|
+
super(`User "${userId}" not found.`, 'ADMIN_USER_NOT_FOUND', { userId })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class AdminUnauthorizedError extends AdminApiError {
|
|
89
|
+
constructor() {
|
|
90
|
+
super('Admin privileges required.', 'ADMIN_UNAUTHORIZED')
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ============================================================================
|
|
95
|
+
// AdminApi
|
|
96
|
+
// ============================================================================
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Administrative API for managing users, sessions, and system configuration.
|
|
100
|
+
*
|
|
101
|
+
* Provides elevated operations that should only be accessible to administrators.
|
|
102
|
+
* All operations are audit-logged when an AuditLogger is configured.
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ```typescript
|
|
106
|
+
* const admin = new AdminApi({
|
|
107
|
+
* userStore: myUserStore,
|
|
108
|
+
* sessionStore: mySessionStore,
|
|
109
|
+
* auditLogger: myAuditLogger,
|
|
110
|
+
* })
|
|
111
|
+
*
|
|
112
|
+
* // List users
|
|
113
|
+
* const { data, total } = await admin.listUsers({ limit: 20 })
|
|
114
|
+
*
|
|
115
|
+
* // Suspend a user (revokes all sessions)
|
|
116
|
+
* await admin.suspendUser('admin-user-id', 'target-user-id', 'Policy violation')
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
export class AdminApi {
|
|
120
|
+
private readonly userStore: UserStore
|
|
121
|
+
private readonly sessionStore: SessionStore | null
|
|
122
|
+
private readonly auditLogger: AuditLogger | null
|
|
123
|
+
private readonly isAdmin: AdminApiConfig['isAdmin']
|
|
124
|
+
private readonly revokeAllForUserFn: AdminApiConfig['revokeAllForUser']
|
|
125
|
+
|
|
126
|
+
constructor(config: AdminApiConfig) {
|
|
127
|
+
this.userStore = config.userStore
|
|
128
|
+
this.sessionStore = config.sessionStore ?? null
|
|
129
|
+
this.auditLogger = config.auditLogger ?? null
|
|
130
|
+
this.isAdmin = config.isAdmin
|
|
131
|
+
this.revokeAllForUserFn = config.revokeAllForUser
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Throws AdminUnauthorizedError unless the configured `isAdmin` accepts the actor. */
|
|
135
|
+
private async authorize(adminId: string): Promise<void> {
|
|
136
|
+
if (this.isAdmin && !(await this.isAdmin(adminId))) {
|
|
137
|
+
throw new AdminUnauthorizedError()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Kill every JWT and refresh token of a user, not just session rows (AUTH-7b/AUTH-14). */
|
|
142
|
+
private async revokeCredentials(userId: string): Promise<void> {
|
|
143
|
+
if (this.revokeAllForUserFn) {
|
|
144
|
+
await this.revokeAllForUserFn(userId)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
await this.userStore.getTokenRevocationStore?.()?.revokeAllForUser(userId, Date.now())
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get a user by ID with full details.
|
|
152
|
+
*/
|
|
153
|
+
async getUser(adminId: string, userId: string): Promise<AuthUser> {
|
|
154
|
+
await this.authorize(adminId)
|
|
155
|
+
const user = await this.userStore.findById(userId)
|
|
156
|
+
if (!user) {
|
|
157
|
+
throw new AdminUserNotFoundError(userId)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await this.audit('admin.user_lookup', adminId, userId, 'user')
|
|
161
|
+
|
|
162
|
+
return toAuthUser(user)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* List users with optional filtering and pagination.
|
|
167
|
+
*/
|
|
168
|
+
async listUsers(query: UserListQuery = {}): Promise<PaginatedResult<AuthUser>> {
|
|
169
|
+
const limit = query.limit ?? 50
|
|
170
|
+
const offset = query.offset ?? 0
|
|
171
|
+
|
|
172
|
+
// Get all users (InMemoryUserStore doesn't have a list method, so we use findByEmail with patterns)
|
|
173
|
+
// For now, we expose a listing helper
|
|
174
|
+
const allUsers = await this.userStore.listAll()
|
|
175
|
+
|
|
176
|
+
let filtered = allUsers
|
|
177
|
+
|
|
178
|
+
if (query.email) {
|
|
179
|
+
const searchEmail = query.email.toLowerCase()
|
|
180
|
+
filtered = filtered.filter((u) => u.email.toLowerCase().includes(searchEmail))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (query.emailVerified !== undefined) {
|
|
184
|
+
filtered = filtered.filter((u) => u.emailVerified === query.emailVerified)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const total = filtered.length
|
|
188
|
+
const data = filtered.slice(offset, offset + limit).map(toAuthUser)
|
|
189
|
+
|
|
190
|
+
return { data, total, limit, offset }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Update a user's profile (admin-level).
|
|
195
|
+
*/
|
|
196
|
+
async updateUser(adminId: string, userId: string, updates: AdminUserUpdate): Promise<AuthUser> {
|
|
197
|
+
await this.authorize(adminId)
|
|
198
|
+
const user = await this.userStore.findById(userId)
|
|
199
|
+
if (!user) {
|
|
200
|
+
throw new AdminUserNotFoundError(userId)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (updates.name !== undefined) {
|
|
204
|
+
user.name = updates.name
|
|
205
|
+
}
|
|
206
|
+
if (updates.emailVerified !== undefined) {
|
|
207
|
+
user.emailVerified = updates.emailVerified
|
|
208
|
+
}
|
|
209
|
+
if (updates.email !== undefined) {
|
|
210
|
+
user.email = updates.email.toLowerCase().trim()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await this.userStore.update(user)
|
|
214
|
+
|
|
215
|
+
await this.audit('user.update', adminId, userId, 'user', { updates })
|
|
216
|
+
|
|
217
|
+
return toAuthUser(user)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Delete a user and all associated sessions.
|
|
222
|
+
*/
|
|
223
|
+
async deleteUser(adminId: string, userId: string): Promise<void> {
|
|
224
|
+
await this.authorize(adminId)
|
|
225
|
+
const user = await this.userStore.findById(userId)
|
|
226
|
+
if (!user) {
|
|
227
|
+
throw new AdminUserNotFoundError(userId)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Revoke all sessions and credentials first
|
|
231
|
+
if (this.sessionStore) {
|
|
232
|
+
await this.sessionStore.deleteAllForUser(userId)
|
|
233
|
+
}
|
|
234
|
+
await this.revokeCredentials(userId)
|
|
235
|
+
|
|
236
|
+
await this.userStore.delete(userId)
|
|
237
|
+
|
|
238
|
+
await this.audit('user.delete', adminId, userId, 'user')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Get all active sessions for a user.
|
|
243
|
+
*/
|
|
244
|
+
async getUserSessions(userId: string): Promise<Session[]> {
|
|
245
|
+
if (!this.sessionStore) return []
|
|
246
|
+
return this.sessionStore.listByUserId(userId)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Revoke all sessions for a user.
|
|
251
|
+
*/
|
|
252
|
+
async revokeUserSessions(adminId: string, userId: string): Promise<number> {
|
|
253
|
+
await this.authorize(adminId)
|
|
254
|
+
await this.revokeCredentials(userId)
|
|
255
|
+
if (!this.sessionStore) return 0
|
|
256
|
+
|
|
257
|
+
const count = await this.sessionStore.deleteAllForUser(userId)
|
|
258
|
+
|
|
259
|
+
await this.audit('session.revoke_all', adminId, userId, 'user', { sessionsRevoked: count })
|
|
260
|
+
|
|
261
|
+
return count
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Revoke a specific session.
|
|
266
|
+
*/
|
|
267
|
+
async revokeSession(adminId: string, sessionId: string): Promise<void> {
|
|
268
|
+
await this.authorize(adminId)
|
|
269
|
+
if (!this.sessionStore) return
|
|
270
|
+
|
|
271
|
+
await this.sessionStore.delete(sessionId)
|
|
272
|
+
|
|
273
|
+
await this.audit('session.revoke', adminId, sessionId, 'session')
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Get system statistics.
|
|
278
|
+
*/
|
|
279
|
+
async getStats(): Promise<{
|
|
280
|
+
totalUsers: number
|
|
281
|
+
verifiedUsers: number
|
|
282
|
+
unverifiedUsers: number
|
|
283
|
+
}> {
|
|
284
|
+
const allUsers = await this.userStore.listAll()
|
|
285
|
+
const verified = allUsers.filter((u) => u.emailVerified).length
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
totalUsers: allUsers.length,
|
|
289
|
+
verifiedUsers: verified,
|
|
290
|
+
unverifiedUsers: allUsers.length - verified,
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// --- Private ---
|
|
295
|
+
|
|
296
|
+
private async audit(
|
|
297
|
+
action: AuditAction,
|
|
298
|
+
actorId: string,
|
|
299
|
+
targetId: string,
|
|
300
|
+
targetType: string,
|
|
301
|
+
metadata?: Record<string, unknown>,
|
|
302
|
+
): Promise<void> {
|
|
303
|
+
if (!this.auditLogger) return
|
|
304
|
+
await this.auditLogger.log({
|
|
305
|
+
action,
|
|
306
|
+
actorId,
|
|
307
|
+
actorType: 'admin',
|
|
308
|
+
targetId,
|
|
309
|
+
targetType,
|
|
310
|
+
metadata,
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ============================================================================
|
|
316
|
+
// Helpers
|
|
317
|
+
// ============================================================================
|
|
318
|
+
|
|
319
|
+
function toAuthUser(stored: StoredUser): AuthUser {
|
|
320
|
+
return {
|
|
321
|
+
id: stored.id,
|
|
322
|
+
email: stored.email,
|
|
323
|
+
name: stored.name,
|
|
324
|
+
emailVerified: stored.emailVerified,
|
|
325
|
+
createdAt: stored.createdAt,
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { KoraError } from '@korajs/core'
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Audit Log Types
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Actions that can be audited.
|
|
9
|
+
*/
|
|
10
|
+
const AUDIT_ACTIONS = [
|
|
11
|
+
// Auth
|
|
12
|
+
'user.signup',
|
|
13
|
+
'user.signin',
|
|
14
|
+
'user.signout',
|
|
15
|
+
'user.token_refresh',
|
|
16
|
+
'user.password_change',
|
|
17
|
+
'user.password_reset_request',
|
|
18
|
+
'user.password_reset',
|
|
19
|
+
'user.email_verify',
|
|
20
|
+
// MFA
|
|
21
|
+
'mfa.enable',
|
|
22
|
+
'mfa.verify_setup',
|
|
23
|
+
'mfa.verify',
|
|
24
|
+
'mfa.recovery_used',
|
|
25
|
+
'mfa.recovery_regenerate',
|
|
26
|
+
'mfa.disable',
|
|
27
|
+
// Session
|
|
28
|
+
'session.create',
|
|
29
|
+
'session.revoke',
|
|
30
|
+
'session.revoke_all',
|
|
31
|
+
'session.expired',
|
|
32
|
+
// OAuth
|
|
33
|
+
'oauth.authorize',
|
|
34
|
+
'oauth.callback',
|
|
35
|
+
'oauth.link',
|
|
36
|
+
'oauth.unlink',
|
|
37
|
+
// User management
|
|
38
|
+
'user.create',
|
|
39
|
+
'user.update',
|
|
40
|
+
'user.delete',
|
|
41
|
+
'user.suspend',
|
|
42
|
+
'user.unsuspend',
|
|
43
|
+
// Org management
|
|
44
|
+
'org.create',
|
|
45
|
+
'org.update',
|
|
46
|
+
'org.delete',
|
|
47
|
+
'org.member_add',
|
|
48
|
+
'org.member_remove',
|
|
49
|
+
'org.member_role_change',
|
|
50
|
+
'org.ownership_transfer',
|
|
51
|
+
'org.invitation_create',
|
|
52
|
+
'org.invitation_accept',
|
|
53
|
+
'org.invitation_revoke',
|
|
54
|
+
// Admin
|
|
55
|
+
'admin.user_lookup',
|
|
56
|
+
'admin.impersonate',
|
|
57
|
+
'admin.config_change',
|
|
58
|
+
] as const
|
|
59
|
+
|
|
60
|
+
export type AuditAction = (typeof AUDIT_ACTIONS)[number]
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* An audit log entry.
|
|
64
|
+
*/
|
|
65
|
+
export interface AuditEntry {
|
|
66
|
+
/** Unique entry ID */
|
|
67
|
+
id: string
|
|
68
|
+
/** When this event occurred */
|
|
69
|
+
timestamp: number
|
|
70
|
+
/** The action that was performed */
|
|
71
|
+
action: AuditAction
|
|
72
|
+
/** Who performed the action (user ID, "system", or "anonymous") */
|
|
73
|
+
actorId: string
|
|
74
|
+
/** The type of actor */
|
|
75
|
+
actorType: 'user' | 'admin' | 'system'
|
|
76
|
+
/** The target of the action (e.g., user ID, org ID, session ID) */
|
|
77
|
+
targetId: string | null
|
|
78
|
+
/** The type of target */
|
|
79
|
+
targetType: string | null
|
|
80
|
+
/** IP address of the actor */
|
|
81
|
+
ipAddress: string | null
|
|
82
|
+
/** User agent */
|
|
83
|
+
userAgent: string | null
|
|
84
|
+
/** Whether the action succeeded */
|
|
85
|
+
success: boolean
|
|
86
|
+
/** Error message if the action failed */
|
|
87
|
+
errorMessage: string | null
|
|
88
|
+
/** Additional structured context */
|
|
89
|
+
metadata?: Record<string, unknown>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Query parameters for searching audit logs.
|
|
94
|
+
*/
|
|
95
|
+
export interface AuditLogQuery {
|
|
96
|
+
/** Filter by actor ID */
|
|
97
|
+
actorId?: string
|
|
98
|
+
/** Filter by target ID */
|
|
99
|
+
targetId?: string
|
|
100
|
+
/** Filter by action(s) */
|
|
101
|
+
actions?: AuditAction[]
|
|
102
|
+
/** Filter by success/failure */
|
|
103
|
+
success?: boolean
|
|
104
|
+
/** Start time (inclusive) */
|
|
105
|
+
startTime?: number
|
|
106
|
+
/** End time (inclusive) */
|
|
107
|
+
endTime?: number
|
|
108
|
+
/** Maximum number of entries to return */
|
|
109
|
+
limit?: number
|
|
110
|
+
/** Offset for pagination */
|
|
111
|
+
offset?: number
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Store for audit log entries.
|
|
116
|
+
*/
|
|
117
|
+
export interface AuditLogStore {
|
|
118
|
+
/** Append an audit entry */
|
|
119
|
+
append(entry: AuditEntry): Promise<void>
|
|
120
|
+
/** Query audit entries */
|
|
121
|
+
query(params: AuditLogQuery): Promise<AuditEntry[]>
|
|
122
|
+
/** Count matching entries */
|
|
123
|
+
count(params: AuditLogQuery): Promise<number>
|
|
124
|
+
/** Delete entries older than a given timestamp */
|
|
125
|
+
purgeOlderThan(timestamp: number): Promise<number>
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ============================================================================
|
|
129
|
+
// Errors
|
|
130
|
+
// ============================================================================
|
|
131
|
+
|
|
132
|
+
export class AuditLogError extends KoraError {
|
|
133
|
+
constructor(message: string, code: string, context?: Record<string, unknown>) {
|
|
134
|
+
super(message, code, context)
|
|
135
|
+
this.name = 'AuditLogError'
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ============================================================================
|
|
140
|
+
// InMemoryAuditLogStore
|
|
141
|
+
// ============================================================================
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* In-memory audit log store for development and testing.
|
|
145
|
+
*/
|
|
146
|
+
export class InMemoryAuditLogStore implements AuditLogStore {
|
|
147
|
+
private readonly entries: AuditEntry[] = []
|
|
148
|
+
|
|
149
|
+
async append(entry: AuditEntry): Promise<void> {
|
|
150
|
+
this.entries.push({ ...entry })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async query(params: AuditLogQuery): Promise<AuditEntry[]> {
|
|
154
|
+
let results = this.filterEntries(params)
|
|
155
|
+
|
|
156
|
+
// Sort by timestamp descending (newest first)
|
|
157
|
+
results.sort((a, b) => b.timestamp - a.timestamp)
|
|
158
|
+
|
|
159
|
+
// Pagination
|
|
160
|
+
const offset = params.offset ?? 0
|
|
161
|
+
const limit = params.limit ?? 100
|
|
162
|
+
results = results.slice(offset, offset + limit)
|
|
163
|
+
|
|
164
|
+
return results.map((e) => ({ ...e }))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async count(params: AuditLogQuery): Promise<number> {
|
|
168
|
+
return this.filterEntries(params).length
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async purgeOlderThan(timestamp: number): Promise<number> {
|
|
172
|
+
const initialLength = this.entries.length
|
|
173
|
+
let writeIndex = 0
|
|
174
|
+
for (let i = 0; i < this.entries.length; i++) {
|
|
175
|
+
const entry = this.entries[i] as AuditEntry
|
|
176
|
+
if (entry.timestamp >= timestamp) {
|
|
177
|
+
this.entries[writeIndex] = entry
|
|
178
|
+
writeIndex++
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
this.entries.length = writeIndex
|
|
182
|
+
return initialLength - writeIndex
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private filterEntries(params: AuditLogQuery): AuditEntry[] {
|
|
186
|
+
return this.entries.filter((e) => {
|
|
187
|
+
if (params.actorId !== undefined && e.actorId !== params.actorId) return false
|
|
188
|
+
if (params.targetId !== undefined && e.targetId !== params.targetId) return false
|
|
189
|
+
if (params.actions !== undefined && !params.actions.includes(e.action)) return false
|
|
190
|
+
if (params.success !== undefined && e.success !== params.success) return false
|
|
191
|
+
if (params.startTime !== undefined && e.timestamp < params.startTime) return false
|
|
192
|
+
if (params.endTime !== undefined && e.timestamp > params.endTime) return false
|
|
193
|
+
return true
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ============================================================================
|
|
199
|
+
// AuditLogger
|
|
200
|
+
// ============================================================================
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Structured audit logger for authentication events.
|
|
204
|
+
*
|
|
205
|
+
* Records all security-relevant actions for compliance and debugging.
|
|
206
|
+
* Designed to be plugged into the auth system at key decision points.
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* ```typescript
|
|
210
|
+
* const auditLog = new AuditLogger({
|
|
211
|
+
* store: new InMemoryAuditLogStore(),
|
|
212
|
+
* })
|
|
213
|
+
*
|
|
214
|
+
* await auditLog.log({
|
|
215
|
+
* action: 'user.signin',
|
|
216
|
+
* actorId: 'user-123',
|
|
217
|
+
* actorType: 'user',
|
|
218
|
+
* success: true,
|
|
219
|
+
* ipAddress: '192.168.1.1',
|
|
220
|
+
* })
|
|
221
|
+
*
|
|
222
|
+
* const entries = await auditLog.query({ actorId: 'user-123', limit: 50 })
|
|
223
|
+
* ```
|
|
224
|
+
*/
|
|
225
|
+
export class AuditLogger {
|
|
226
|
+
private readonly store: AuditLogStore
|
|
227
|
+
private readonly retentionMs: number | null
|
|
228
|
+
|
|
229
|
+
constructor(config: { store: AuditLogStore; retentionDays?: number }) {
|
|
230
|
+
this.store = config.store
|
|
231
|
+
this.retentionMs = config.retentionDays ? config.retentionDays * 24 * 60 * 60 * 1000 : null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Log an audit event.
|
|
236
|
+
*/
|
|
237
|
+
async log(params: {
|
|
238
|
+
action: AuditAction
|
|
239
|
+
actorId: string
|
|
240
|
+
actorType?: 'user' | 'admin' | 'system'
|
|
241
|
+
targetId?: string
|
|
242
|
+
targetType?: string
|
|
243
|
+
ipAddress?: string
|
|
244
|
+
userAgent?: string
|
|
245
|
+
success?: boolean
|
|
246
|
+
errorMessage?: string
|
|
247
|
+
metadata?: Record<string, unknown>
|
|
248
|
+
}): Promise<AuditEntry> {
|
|
249
|
+
const entry: AuditEntry = {
|
|
250
|
+
id: generateAuditId(),
|
|
251
|
+
timestamp: Date.now(),
|
|
252
|
+
action: params.action,
|
|
253
|
+
actorId: params.actorId,
|
|
254
|
+
actorType: params.actorType ?? 'user',
|
|
255
|
+
targetId: params.targetId ?? null,
|
|
256
|
+
targetType: params.targetType ?? null,
|
|
257
|
+
ipAddress: params.ipAddress ?? null,
|
|
258
|
+
userAgent: params.userAgent ?? null,
|
|
259
|
+
success: params.success ?? true,
|
|
260
|
+
errorMessage: params.errorMessage ?? null,
|
|
261
|
+
metadata: params.metadata,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await this.store.append(entry)
|
|
265
|
+
return entry
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Query audit log entries.
|
|
270
|
+
*/
|
|
271
|
+
async query(params: AuditLogQuery): Promise<AuditEntry[]> {
|
|
272
|
+
return this.store.query(params)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Count matching audit entries.
|
|
277
|
+
*/
|
|
278
|
+
async count(params: AuditLogQuery): Promise<number> {
|
|
279
|
+
return this.store.count(params)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Purge old entries based on retention policy.
|
|
284
|
+
* Returns the number of entries purged.
|
|
285
|
+
*/
|
|
286
|
+
async purge(): Promise<number> {
|
|
287
|
+
if (this.retentionMs === null) return 0
|
|
288
|
+
const cutoff = Date.now() - this.retentionMs
|
|
289
|
+
return this.store.purgeOlderThan(cutoff)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Get recent activity for a user.
|
|
294
|
+
*/
|
|
295
|
+
async getUserActivity(userId: string, limit = 50): Promise<AuditEntry[]> {
|
|
296
|
+
return this.store.query({ actorId: userId, limit })
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Get failed login attempts for a user within a time window.
|
|
301
|
+
*/
|
|
302
|
+
async getFailedLogins(userId: string, windowMs: number): Promise<AuditEntry[]> {
|
|
303
|
+
return this.store.query({
|
|
304
|
+
targetId: userId,
|
|
305
|
+
actions: ['user.signin'],
|
|
306
|
+
success: false,
|
|
307
|
+
startTime: Date.now() - windowMs,
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ============================================================================
|
|
313
|
+
// Helpers
|
|
314
|
+
// ============================================================================
|
|
315
|
+
|
|
316
|
+
function generateAuditId(): string {
|
|
317
|
+
const bytes = new Uint8Array(16)
|
|
318
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
319
|
+
let hex = ''
|
|
320
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
321
|
+
hex += bytes[i]?.toString(16).padStart(2, '0')
|
|
322
|
+
}
|
|
323
|
+
return hex
|
|
324
|
+
}
|