@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.
- 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 +2852 -1675
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +779 -168
- package/dist/server.d.ts +779 -168
- package/dist/server.js +2831 -1665
- 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 +328 -0
- package/src/provider/built-in/quickstart-server.ts +760 -0
- package/src/provider/built-in/sqlite-user-store.ts +322 -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 +272 -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 +334 -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,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
|
+
}
|