@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.13

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.
Files changed (101) hide show
  1. package/README.md +52 -47
  2. package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
  3. package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
  4. package/dist/index.cjs +645 -150
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +27 -9
  7. package/dist/index.d.ts +27 -9
  8. package/dist/index.js +644 -150
  9. package/dist/index.js.map +1 -1
  10. package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
  11. package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
  12. package/dist/react.d.cts +2 -2
  13. package/dist/react.d.ts +2 -2
  14. package/dist/server.cjs +2852 -1675
  15. package/dist/server.cjs.map +1 -1
  16. package/dist/server.d.cts +779 -168
  17. package/dist/server.d.ts +779 -168
  18. package/dist/server.js +2831 -1665
  19. package/dist/server.js.map +1 -1
  20. package/dist/svelte.cjs +2 -2
  21. package/dist/svelte.cjs.map +1 -1
  22. package/dist/svelte.d.cts +2 -2
  23. package/dist/svelte.d.ts +2 -2
  24. package/dist/svelte.js +2 -2
  25. package/dist/svelte.js.map +1 -1
  26. package/dist/vue.d.cts +1 -1
  27. package/dist/vue.d.ts +1 -1
  28. package/package.json +7 -7
  29. package/src/admin/admin-api.ts +327 -0
  30. package/src/admin/audit-log.ts +324 -0
  31. package/src/admin/webhooks.ts +576 -0
  32. package/src/bindings/create-auth-session.ts +184 -0
  33. package/src/bindings/create-org-session.ts +130 -0
  34. package/src/client/auth-client.ts +1592 -0
  35. package/src/client/auth-sync.ts +213 -0
  36. package/src/client/device-session.ts +104 -0
  37. package/src/client/org-client.ts +399 -0
  38. package/src/client/quickstart.ts +108 -0
  39. package/src/client/storage.ts +94 -0
  40. package/src/device/device-identity.ts +330 -0
  41. package/src/device/device-store.ts +379 -0
  42. package/src/encryption/auto-lock.ts +170 -0
  43. package/src/encryption/database-encryption.ts +265 -0
  44. package/src/encryption/key-derivation.ts +149 -0
  45. package/src/encryption/operation-encryptor.ts +361 -0
  46. package/src/index.ts +132 -0
  47. package/src/mfa/totp.ts +826 -0
  48. package/src/org/org-routes.ts +758 -0
  49. package/src/org/org-store.ts +490 -0
  50. package/src/org/org-types.ts +230 -0
  51. package/src/passkey/passkey-client.ts +597 -0
  52. package/src/passkey/passkey-server.ts +779 -0
  53. package/src/postgres/ensure-schema.ts +65 -0
  54. package/src/provider/adapter.ts +246 -0
  55. package/src/provider/built-in/auth-routes.ts +1313 -0
  56. package/src/provider/built-in/email-verification.ts +303 -0
  57. package/src/provider/built-in/password-hash.ts +118 -0
  58. package/src/provider/built-in/password-reset.ts +416 -0
  59. package/src/provider/built-in/postgres-user-store.ts +328 -0
  60. package/src/provider/built-in/quickstart-server.ts +760 -0
  61. package/src/provider/built-in/sqlite-user-store.ts +322 -0
  62. package/src/provider/built-in/sync-scopes.ts +85 -0
  63. package/src/provider/built-in/user-store.ts +465 -0
  64. package/src/provider/external/clerk-adapter.ts +157 -0
  65. package/src/provider/external/external-jwt-provider.ts +491 -0
  66. package/src/provider/external/supabase-adapter.ts +163 -0
  67. package/src/provider/oauth/linked-identity-store.ts +108 -0
  68. package/src/provider/oauth/oauth-flow.ts +550 -0
  69. package/src/provider/oauth/oauth-types.ts +184 -0
  70. package/src/provider/oauth/postgres-oauth-store.ts +296 -0
  71. package/src/provider/oauth/sqlite-oauth-store.ts +272 -0
  72. package/src/rbac/rbac-engine.ts +323 -0
  73. package/src/rbac/rbac-types.ts +210 -0
  74. package/src/rbac/scope-resolver.ts +140 -0
  75. package/src/react/AuthProvider.tsx +97 -0
  76. package/src/react/OrgProvider.tsx +41 -0
  77. package/src/react/auth-context.ts +26 -0
  78. package/src/react/hooks.ts +110 -0
  79. package/src/react/org-hooks.ts +214 -0
  80. package/src/react.ts +26 -0
  81. package/src/server.ts +334 -0
  82. package/src/session/session.ts +401 -0
  83. package/src/svelte/auth-context.ts +50 -0
  84. package/src/svelte/org-context.ts +32 -0
  85. package/src/svelte/org-hooks.ts +201 -0
  86. package/src/svelte/use-auth.ts +115 -0
  87. package/src/svelte.ts +25 -0
  88. package/src/tokens/encrypted-token-store.ts +360 -0
  89. package/src/tokens/jwt.ts +236 -0
  90. package/src/tokens/postgres-token-revocation-store.ts +140 -0
  91. package/src/tokens/sqlite-token-revocation-store.ts +121 -0
  92. package/src/tokens/token-manager.ts +821 -0
  93. package/src/tokens/token-store.ts +192 -0
  94. package/src/types.ts +394 -0
  95. package/src/vue/auth-context.ts +10 -0
  96. package/src/vue/auth-provider-types.ts +5 -0
  97. package/src/vue/auth-provider.ts +76 -0
  98. package/src/vue/org-hooks.ts +193 -0
  99. package/src/vue/org-provider.ts +49 -0
  100. package/src/vue/use-auth.ts +139 -0
  101. package/src/vue.ts +10 -0
@@ -0,0 +1,236 @@
1
+ import { createHmac } from 'node:crypto'
2
+
3
+ // ============================================================================
4
+ // Base64url helpers
5
+ // ============================================================================
6
+
7
+ /**
8
+ * Encodes a UTF-8 string to base64url format (RFC 7515).
9
+ * Base64url uses the URL-safe alphabet (- instead of +, _ instead of /)
10
+ * and strips trailing padding characters.
11
+ *
12
+ * @param input - The UTF-8 string to encode
13
+ * @returns Base64url-encoded string
14
+ */
15
+ export function base64urlEncode(input: string): string {
16
+ return Buffer.from(input, 'utf-8').toString('base64url')
17
+ }
18
+
19
+ /**
20
+ * Decodes a base64url-encoded string back to UTF-8.
21
+ *
22
+ * @param input - The base64url-encoded string to decode
23
+ * @returns Decoded UTF-8 string
24
+ */
25
+ export function base64urlDecode(input: string): string {
26
+ return Buffer.from(input, 'base64url').toString('utf-8')
27
+ }
28
+
29
+ // ============================================================================
30
+ // Internal signing
31
+ // ============================================================================
32
+
33
+ /**
34
+ * Computes an HMAC-SHA256 signature and returns it as a base64url string.
35
+ * Uses Node.js crypto module for synchronous signing.
36
+ */
37
+ function hmacSha256Base64url(data: string, secret: string): string {
38
+ return createHmac('sha256', secret).update(data).digest('base64url')
39
+ }
40
+
41
+ // ============================================================================
42
+ // JWT operations
43
+ // ============================================================================
44
+
45
+ /** Pre-encoded JWT header. Only HS256 is supported; the header never changes. */
46
+ const ENCODED_HEADER = base64urlEncode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
47
+
48
+ /**
49
+ * Creates a signed JWT (HS256) from a payload and secret.
50
+ *
51
+ * The token is structured as `header.payload.signature` per RFC 7519.
52
+ * Only HMAC-SHA256 is supported. The header is always `{"alg":"HS256","typ":"JWT"}`.
53
+ *
54
+ * @param payload - The claims to include in the token. Must be JSON-serializable.
55
+ * @param secret - The HMAC-SHA256 secret key used for signing.
56
+ * @returns A signed JWT string in the format `header.payload.signature`
57
+ *
58
+ * @example
59
+ * ```typescript
60
+ * const token = encodeJwt(
61
+ * { sub: 'user-123', exp: Math.floor(Date.now() / 1000) + 900 },
62
+ * 'my-secret'
63
+ * )
64
+ * ```
65
+ */
66
+ export function encodeJwt(payload: Record<string, unknown>, secret: string): string {
67
+ const encodedPayload = base64urlEncode(JSON.stringify(payload))
68
+ const signingInput = `${ENCODED_HEADER}.${encodedPayload}`
69
+ const signature = hmacSha256Base64url(signingInput, secret)
70
+ return `${signingInput}.${signature}`
71
+ }
72
+
73
+ /**
74
+ * Decodes a JWT without verifying its signature.
75
+ *
76
+ * Use this only when you need to inspect claims (e.g., reading `exp` to decide
77
+ * whether to attempt a refresh) and do NOT need to trust the token's authenticity.
78
+ * For trusted reads, use {@link verifyJwt} instead.
79
+ *
80
+ * @param token - The JWT string to decode
81
+ * @returns The decoded payload as a record, or null if the token is malformed
82
+ *
83
+ * @example
84
+ * ```typescript
85
+ * const claims = decodeJwt(token)
86
+ * if (claims && typeof claims.sub === 'string') {
87
+ * console.log('User:', claims.sub)
88
+ * }
89
+ * ```
90
+ */
91
+ export function decodeJwt(token: string): Record<string, unknown> | null {
92
+ const parts = token.split('.')
93
+ if (parts.length !== 3) {
94
+ return null
95
+ }
96
+
97
+ const payloadSegment = parts[1]
98
+ if (payloadSegment === undefined) {
99
+ return null
100
+ }
101
+
102
+ try {
103
+ const decoded = base64urlDecode(payloadSegment)
104
+ const parsed: unknown = JSON.parse(decoded)
105
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
106
+ return null
107
+ }
108
+ return parsed as Record<string, unknown>
109
+ } catch {
110
+ return null
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Decodes a JWT and verifies its HMAC-SHA256 signature.
116
+ *
117
+ * Returns the payload only if the signature is valid. Returns null if the token
118
+ * is malformed, has an invalid signature, or cannot be parsed.
119
+ *
120
+ * This function does NOT check expiration. Use {@link isExpired} separately
121
+ * to check the `exp` claim, allowing callers to distinguish between
122
+ * "invalid signature" (security issue) and "expired" (normal lifecycle).
123
+ *
124
+ * @param token - The JWT string to verify
125
+ * @param secret - The HMAC-SHA256 secret key that was used to sign the token
126
+ * @returns The decoded payload if the signature is valid, or null otherwise
127
+ *
128
+ * @example
129
+ * ```typescript
130
+ * const claims = verifyJwt(token, 'my-secret')
131
+ * if (claims === null) {
132
+ * throw new Error('Invalid token signature')
133
+ * }
134
+ * if (isExpired(claims)) {
135
+ * throw new Error('Token has expired')
136
+ * }
137
+ * ```
138
+ */
139
+ export function verifyJwt(token: string, secret: string): Record<string, unknown> | null {
140
+ // Callers ultimately trace back to request bodies (handleRefresh,
141
+ // handleSignOut, handleDeviceRegister, handleDeviceVerify all pass a body
142
+ // field straight through to validateToken -> verifyJwt). A malformed or
143
+ // missing field means `token` is not actually a string at runtime despite
144
+ // the type, and `.split` would throw, turning one bad request into a
145
+ // process-crashing uncaught exception. Treat non-string input as "not a
146
+ // valid token" instead, same as any other malformed token shape.
147
+ if (typeof token !== 'string') {
148
+ return null
149
+ }
150
+ const parts = token.split('.')
151
+ if (parts.length !== 3) {
152
+ return null
153
+ }
154
+
155
+ const headerSegment = parts[0]
156
+ const payloadSegment = parts[1]
157
+ const signatureSegment = parts[2]
158
+
159
+ if (
160
+ headerSegment === undefined ||
161
+ payloadSegment === undefined ||
162
+ signatureSegment === undefined
163
+ ) {
164
+ return null
165
+ }
166
+
167
+ // Validate the header to prevent algorithm confusion attacks.
168
+ // Only HS256 is supported; reject any token claiming a different algorithm.
169
+ if (headerSegment !== ENCODED_HEADER) {
170
+ return null
171
+ }
172
+
173
+ // Recompute signature and compare
174
+ const signingInput = `${headerSegment}.${payloadSegment}`
175
+ const expectedSignature = hmacSha256Base64url(signingInput, secret)
176
+
177
+ // Constant-time comparison to prevent timing attacks.
178
+ // Both strings are base64url-encoded HMAC outputs, so they are ASCII-safe
179
+ // and we can compare byte-by-byte without early exit.
180
+ if (expectedSignature.length !== signatureSegment.length) {
181
+ return null
182
+ }
183
+ let mismatch = 0
184
+ for (let i = 0; i < expectedSignature.length; i++) {
185
+ // Bitwise OR accumulates differences without short-circuiting
186
+ mismatch |= expectedSignature.charCodeAt(i) ^ signatureSegment.charCodeAt(i)
187
+ }
188
+ if (mismatch !== 0) {
189
+ return null
190
+ }
191
+
192
+ // Decode the payload
193
+ try {
194
+ const decoded = base64urlDecode(payloadSegment)
195
+ const parsed: unknown = JSON.parse(decoded)
196
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
197
+ return null
198
+ }
199
+ return parsed as Record<string, unknown>
200
+ } catch {
201
+ return null
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Clock skew tolerance in seconds. Allows for minor clock differences
207
+ * between servers in multi-server deployments.
208
+ */
209
+ const CLOCK_SKEW_TOLERANCE_SECONDS = 5
210
+
211
+ /**
212
+ * Checks whether a token payload has expired based on its `exp` claim.
213
+ *
214
+ * The `exp` claim is expected to be in seconds since the Unix epoch (per JWT spec).
215
+ * Includes a small clock skew tolerance (5 seconds) to handle minor time
216
+ * differences between servers. If the `exp` claim is missing or is not a number,
217
+ * the token is considered non-expiring and this function returns false.
218
+ *
219
+ * @param payload - An object with an optional `exp` field (seconds since epoch)
220
+ * @returns true if the token has expired, false otherwise
221
+ *
222
+ * @example
223
+ * ```typescript
224
+ * const claims = verifyJwt(token, secret)
225
+ * if (claims && isExpired(claims)) {
226
+ * // Token signature is valid but it has expired -- attempt refresh
227
+ * }
228
+ * ```
229
+ */
230
+ export function isExpired(payload: { exp?: number }): boolean {
231
+ if (typeof payload.exp !== 'number') {
232
+ return false
233
+ }
234
+ const nowSeconds = Math.floor(Date.now() / 1000)
235
+ return nowSeconds >= payload.exp + CLOCK_SKEW_TOLERANCE_SECONDS
236
+ }
@@ -0,0 +1,140 @@
1
+ import { ensurePostgresSchema } from '../postgres/ensure-schema'
2
+ import type { ConsumeResult, TokenRevocationStore } from './token-manager'
3
+
4
+ /**
5
+ * Minimal postgres-js tagged-template client.
6
+ */
7
+ export type PostgresRevocationClient = (
8
+ template: TemplateStringsArray,
9
+ ...args: unknown[]
10
+ ) => Promise<Record<string, unknown>[]>
11
+
12
+ /**
13
+ * PostgreSQL-backed {@link TokenRevocationStore} (postgres-js).
14
+ *
15
+ * Shared by every server instance using the database. `consume` is one
16
+ * `INSERT ... ON CONFLICT DO NOTHING RETURNING`, so exactly one concurrent
17
+ * caller, on any instance, consumes a refresh token (AUTH-6).
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * const userStore = await createPostgresUserStore({ connectionString })
22
+ * const revocationStore = userStore.getTokenRevocationStore()
23
+ * ```
24
+ */
25
+ export class PostgresTokenRevocationStore implements TokenRevocationStore {
26
+ private readonly ready: Promise<void>
27
+
28
+ constructor(private readonly sql: PostgresRevocationClient) {
29
+ this.ready = this.ensureTables()
30
+ }
31
+
32
+ private async ensureTables(): Promise<void> {
33
+ // Concurrency-safe on an empty database shared by several instances.
34
+ await ensurePostgresSchema(this.sql, async (sql) => {
35
+ await sql`
36
+ CREATE TABLE IF NOT EXISTS auth_token_revocations (
37
+ jti TEXT PRIMARY KEY,
38
+ expires_at BIGINT NOT NULL
39
+ )
40
+ `
41
+ await sql`
42
+ CREATE TABLE IF NOT EXISTS auth_token_consumptions (
43
+ jti TEXT PRIMARY KEY,
44
+ consumed_at BIGINT NOT NULL,
45
+ expires_at BIGINT NOT NULL
46
+ )
47
+ `
48
+ await sql`
49
+ CREATE TABLE IF NOT EXISTS auth_token_cutoffs (
50
+ kind TEXT NOT NULL,
51
+ subject_id TEXT NOT NULL,
52
+ revoked_before BIGINT NOT NULL,
53
+ PRIMARY KEY (kind, subject_id)
54
+ )
55
+ `
56
+ })
57
+ }
58
+
59
+ async isRevoked(jti: string): Promise<boolean> {
60
+ await this.ready
61
+ const rows = await this.sql`SELECT 1 FROM auth_token_revocations WHERE jti = ${jti}`
62
+ return rows.length > 0
63
+ }
64
+
65
+ async revoke(jti: string, expiresAt: number): Promise<void> {
66
+ await this.ready
67
+ await this.sql`
68
+ INSERT INTO auth_token_revocations (jti, expires_at) VALUES (${jti}, ${expiresAt})
69
+ ON CONFLICT (jti) DO NOTHING
70
+ `
71
+ }
72
+
73
+ async consume(jti: string, expiresAt: number): Promise<ConsumeResult> {
74
+ await this.ready
75
+ const now = Date.now()
76
+ const inserted = await this.sql`
77
+ INSERT INTO auth_token_consumptions (jti, consumed_at, expires_at)
78
+ VALUES (${jti}, ${now}, ${expiresAt})
79
+ ON CONFLICT (jti) DO NOTHING
80
+ RETURNING consumed_at
81
+ `
82
+ if (inserted.length === 1) return { firstUse: true, consumedAt: now }
83
+ const rows = await this.sql`SELECT consumed_at FROM auth_token_consumptions WHERE jti = ${jti}`
84
+ const consumedAt = rows[0]?.consumed_at
85
+ return { firstUse: false, consumedAt: consumedAt === undefined ? now : Number(consumedAt) }
86
+ }
87
+
88
+ async isConsumed(jti: string): Promise<boolean> {
89
+ await this.ready
90
+ const rows = await this.sql`SELECT 1 FROM auth_token_consumptions WHERE jti = ${jti}`
91
+ return rows.length > 0
92
+ }
93
+
94
+ async revokeAllForDevice(deviceId: string, before: number = Date.now()): Promise<void> {
95
+ await this.setCutoff('device', deviceId, before)
96
+ }
97
+
98
+ async getDeviceRevokedBefore(deviceId: string): Promise<number | null> {
99
+ return this.getCutoff('device', deviceId)
100
+ }
101
+
102
+ async revokeAllForUser(userId: string, before: number = Date.now()): Promise<void> {
103
+ await this.setCutoff('user', userId, before)
104
+ }
105
+
106
+ async getUserRevokedBefore(userId: string): Promise<number | null> {
107
+ return this.getCutoff('user', userId)
108
+ }
109
+
110
+ /** Delete revocations and consumptions whose tokens have expired. */
111
+ async cleanup(): Promise<void> {
112
+ await this.ready
113
+ const nowSeconds = Math.floor(Date.now() / 1000)
114
+ await this.sql`DELETE FROM auth_token_revocations WHERE expires_at < ${nowSeconds}`
115
+ await this.sql`DELETE FROM auth_token_consumptions WHERE expires_at < ${nowSeconds}`
116
+ }
117
+
118
+ private async setCutoff(
119
+ kind: 'device' | 'user',
120
+ subjectId: string,
121
+ before: number,
122
+ ): Promise<void> {
123
+ await this.ready
124
+ await this.sql`
125
+ INSERT INTO auth_token_cutoffs (kind, subject_id, revoked_before)
126
+ VALUES (${kind}, ${subjectId}, ${before})
127
+ ON CONFLICT (kind, subject_id)
128
+ DO UPDATE SET revoked_before = GREATEST(auth_token_cutoffs.revoked_before, EXCLUDED.revoked_before)
129
+ `
130
+ }
131
+
132
+ private async getCutoff(kind: 'device' | 'user', subjectId: string): Promise<number | null> {
133
+ await this.ready
134
+ const rows = await this.sql`
135
+ SELECT revoked_before FROM auth_token_cutoffs WHERE kind = ${kind} AND subject_id = ${subjectId}
136
+ `
137
+ const value = rows[0]?.revoked_before
138
+ return value === undefined ? null : Number(value)
139
+ }
140
+ }
@@ -0,0 +1,121 @@
1
+ import type { ConsumeResult, TokenRevocationStore } from './token-manager'
2
+
3
+ /**
4
+ * Minimal better-sqlite3 subset to avoid a hard dependency on the package.
5
+ */
6
+ export interface SqliteRevocationDatabase {
7
+ exec(source: string): void
8
+ prepare(source: string): {
9
+ run(...params: unknown[]): { changes: number }
10
+ get(...params: unknown[]): unknown
11
+ }
12
+ }
13
+
14
+ /**
15
+ * SQLite-backed {@link TokenRevocationStore} (better-sqlite3).
16
+ *
17
+ * Revocations survive restarts and are shared by every process using the same
18
+ * database file. `consume` is a single `INSERT ... ON CONFLICT DO NOTHING`, so it
19
+ * is atomic: better-sqlite3 executes statements synchronously under SQLite's
20
+ * write lock.
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * const userStore = await createSqliteUserStore({ filename: './auth.db' })
25
+ * const revocationStore = userStore.getTokenRevocationStore()
26
+ * ```
27
+ */
28
+ export class SqliteTokenRevocationStore implements TokenRevocationStore {
29
+ constructor(private readonly db: SqliteRevocationDatabase) {
30
+ this.db.exec(`
31
+ CREATE TABLE IF NOT EXISTS auth_token_revocations (
32
+ jti TEXT PRIMARY KEY,
33
+ expires_at INTEGER NOT NULL
34
+ );
35
+ CREATE TABLE IF NOT EXISTS auth_token_consumptions (
36
+ jti TEXT PRIMARY KEY,
37
+ consumed_at INTEGER NOT NULL,
38
+ expires_at INTEGER NOT NULL
39
+ );
40
+ CREATE TABLE IF NOT EXISTS auth_token_cutoffs (
41
+ kind TEXT NOT NULL,
42
+ subject_id TEXT NOT NULL,
43
+ revoked_before INTEGER NOT NULL,
44
+ PRIMARY KEY (kind, subject_id)
45
+ );
46
+ `)
47
+ }
48
+
49
+ async isRevoked(jti: string): Promise<boolean> {
50
+ return (
51
+ this.db.prepare('SELECT 1 FROM auth_token_revocations WHERE jti = ?').get(jti) !== undefined
52
+ )
53
+ }
54
+
55
+ async revoke(jti: string, expiresAt: number): Promise<void> {
56
+ this.db
57
+ .prepare(
58
+ 'INSERT INTO auth_token_revocations (jti, expires_at) VALUES (?, ?) ON CONFLICT(jti) DO NOTHING',
59
+ )
60
+ .run(jti, expiresAt)
61
+ }
62
+
63
+ async consume(jti: string, expiresAt: number): Promise<ConsumeResult> {
64
+ const now = Date.now()
65
+ const inserted = this.db
66
+ .prepare(
67
+ 'INSERT INTO auth_token_consumptions (jti, consumed_at, expires_at) VALUES (?, ?, ?) ON CONFLICT(jti) DO NOTHING',
68
+ )
69
+ .run(jti, now, expiresAt)
70
+ if (inserted.changes === 1) return { firstUse: true, consumedAt: now }
71
+ const row = this.db
72
+ .prepare('SELECT consumed_at FROM auth_token_consumptions WHERE jti = ?')
73
+ .get(jti) as { consumed_at: number } | undefined
74
+ return { firstUse: false, consumedAt: row ? Number(row.consumed_at) : now }
75
+ }
76
+
77
+ async isConsumed(jti: string): Promise<boolean> {
78
+ return (
79
+ this.db.prepare('SELECT 1 FROM auth_token_consumptions WHERE jti = ?').get(jti) !== undefined
80
+ )
81
+ }
82
+
83
+ async revokeAllForDevice(deviceId: string, before: number = Date.now()): Promise<void> {
84
+ this.setCutoff('device', deviceId, before)
85
+ }
86
+
87
+ async getDeviceRevokedBefore(deviceId: string): Promise<number | null> {
88
+ return this.getCutoff('device', deviceId)
89
+ }
90
+
91
+ async revokeAllForUser(userId: string, before: number = Date.now()): Promise<void> {
92
+ this.setCutoff('user', userId, before)
93
+ }
94
+
95
+ async getUserRevokedBefore(userId: string): Promise<number | null> {
96
+ return this.getCutoff('user', userId)
97
+ }
98
+
99
+ /** Delete revocations and consumptions whose tokens have expired. */
100
+ async cleanup(): Promise<void> {
101
+ const nowSeconds = Math.floor(Date.now() / 1000)
102
+ this.db.prepare('DELETE FROM auth_token_revocations WHERE expires_at < ?').run(nowSeconds)
103
+ this.db.prepare('DELETE FROM auth_token_consumptions WHERE expires_at < ?').run(nowSeconds)
104
+ }
105
+
106
+ private setCutoff(kind: 'device' | 'user', subjectId: string, before: number): void {
107
+ this.db
108
+ .prepare(
109
+ `INSERT INTO auth_token_cutoffs (kind, subject_id, revoked_before) VALUES (?, ?, ?)
110
+ ON CONFLICT(kind, subject_id) DO UPDATE SET revoked_before = MAX(revoked_before, excluded.revoked_before)`,
111
+ )
112
+ .run(kind, subjectId, before)
113
+ }
114
+
115
+ private getCutoff(kind: 'device' | 'user', subjectId: string): number | null {
116
+ const row = this.db
117
+ .prepare('SELECT revoked_before FROM auth_token_cutoffs WHERE kind = ? AND subject_id = ?')
118
+ .get(kind, subjectId) as { revoked_before: number } | undefined
119
+ return row ? Number(row.revoked_before) : null
120
+ }
121
+ }