@korajs/auth 1.0.0-beta.11 → 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,115 @@
|
|
|
1
|
+
import { type Readable, readable } from 'svelte/store'
|
|
2
|
+
import type { AuthSessionSnapshot } from '../bindings/create-auth-session'
|
|
3
|
+
import type {
|
|
4
|
+
AuthUser,
|
|
5
|
+
LinkedOAuthAccount,
|
|
6
|
+
OAuthAuthorizationOptions,
|
|
7
|
+
OAuthAuthorizationResult,
|
|
8
|
+
OAuthCallbackParams,
|
|
9
|
+
} from '../client/auth-client'
|
|
10
|
+
import { getAuthContext } from './auth-context'
|
|
11
|
+
|
|
12
|
+
export interface UseAuthResult extends AuthSessionSnapshot {
|
|
13
|
+
signUp: (params: {
|
|
14
|
+
email: string
|
|
15
|
+
password: string
|
|
16
|
+
name?: string
|
|
17
|
+
deviceId?: string
|
|
18
|
+
devicePublicKey?: string
|
|
19
|
+
}) => Promise<void>
|
|
20
|
+
signIn: (params: {
|
|
21
|
+
email: string
|
|
22
|
+
password: string
|
|
23
|
+
deviceId?: string
|
|
24
|
+
devicePublicKey?: string
|
|
25
|
+
}) => Promise<void>
|
|
26
|
+
signInWithOAuth: (
|
|
27
|
+
provider: string,
|
|
28
|
+
options?: OAuthAuthorizationOptions,
|
|
29
|
+
) => Promise<OAuthAuthorizationResult>
|
|
30
|
+
completeOAuthSignIn: (provider: string, params: OAuthCallbackParams) => Promise<void>
|
|
31
|
+
getOAuthAuthorizationUrl: (
|
|
32
|
+
provider: string,
|
|
33
|
+
options?: OAuthAuthorizationOptions,
|
|
34
|
+
) => Promise<OAuthAuthorizationResult>
|
|
35
|
+
linkOAuth: (provider: string, params: OAuthCallbackParams) => Promise<LinkedOAuthAccount | null>
|
|
36
|
+
listLinkedAccounts: () => Promise<LinkedOAuthAccount[]>
|
|
37
|
+
unlinkOAuth: (provider: string) => Promise<void>
|
|
38
|
+
signOut: () => Promise<void>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createAuthStore(): Readable<UseAuthResult> {
|
|
42
|
+
const { session } = getAuthContext()
|
|
43
|
+
|
|
44
|
+
return readable<UseAuthResult>(buildResult(session), (set) => {
|
|
45
|
+
const sync = (): void => {
|
|
46
|
+
set(buildResult(session))
|
|
47
|
+
}
|
|
48
|
+
sync()
|
|
49
|
+
return session.subscribe(sync)
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @alias createAuthStore */
|
|
54
|
+
export const useAuth = createAuthStore
|
|
55
|
+
|
|
56
|
+
function buildResult(session: ReturnType<typeof getAuthContext>['session']): UseAuthResult {
|
|
57
|
+
const snapshot = session.getSnapshot()
|
|
58
|
+
return {
|
|
59
|
+
...snapshot,
|
|
60
|
+
signUp: (params) => session.signUp(params),
|
|
61
|
+
signIn: (params) => session.signIn(params),
|
|
62
|
+
signInWithOAuth: (provider, options) => session.signInWithOAuth(provider, options),
|
|
63
|
+
completeOAuthSignIn: (provider, params) => session.completeOAuthSignIn(provider, params),
|
|
64
|
+
getOAuthAuthorizationUrl: (provider, options) =>
|
|
65
|
+
session.getOAuthAuthorizationUrl(provider, options),
|
|
66
|
+
linkOAuth: (provider, params) => session.linkOAuth(provider, params),
|
|
67
|
+
listLinkedAccounts: () => session.listLinkedAccounts(),
|
|
68
|
+
unlinkOAuth: (provider) => session.unlinkOAuth(provider),
|
|
69
|
+
signOut: () => session.signOut(),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createCurrentUserStore(): Readable<AuthUser | null> {
|
|
74
|
+
const { session } = getAuthContext()
|
|
75
|
+
return readable<AuthUser | null>(session.getSnapshot().user, (set) => {
|
|
76
|
+
const sync = (): void => {
|
|
77
|
+
set(session.getSnapshot().user)
|
|
78
|
+
}
|
|
79
|
+
sync()
|
|
80
|
+
return session.subscribe(sync)
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** @alias createCurrentUserStore */
|
|
85
|
+
export const useCurrentUser = createCurrentUserStore
|
|
86
|
+
|
|
87
|
+
export function createAuthStatusStore(): Readable<{
|
|
88
|
+
state: AuthSessionSnapshot['state']
|
|
89
|
+
isAuthenticated: boolean
|
|
90
|
+
isLoading: boolean
|
|
91
|
+
}> {
|
|
92
|
+
const { session } = getAuthContext()
|
|
93
|
+
return readable(
|
|
94
|
+
{
|
|
95
|
+
state: session.getSnapshot().state,
|
|
96
|
+
isAuthenticated: session.getSnapshot().isAuthenticated,
|
|
97
|
+
isLoading: session.getSnapshot().isLoading,
|
|
98
|
+
},
|
|
99
|
+
(set) => {
|
|
100
|
+
const sync = (): void => {
|
|
101
|
+
const snapshot = session.getSnapshot()
|
|
102
|
+
set({
|
|
103
|
+
state: snapshot.state,
|
|
104
|
+
isAuthenticated: snapshot.isAuthenticated,
|
|
105
|
+
isLoading: snapshot.isLoading,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
sync()
|
|
109
|
+
return session.subscribe(sync)
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** @alias createAuthStatusStore */
|
|
115
|
+
export const useAuthStatus = createAuthStatusStore
|
package/src/svelte.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export {
|
|
2
|
+
destroyAuthProvider,
|
|
3
|
+
getAuthContext,
|
|
4
|
+
initAuthProvider,
|
|
5
|
+
} from './svelte/auth-context'
|
|
6
|
+
export type { AuthContextValue } from './svelte/auth-context'
|
|
7
|
+
export {
|
|
8
|
+
createAuthStore,
|
|
9
|
+
createAuthStatusStore,
|
|
10
|
+
createCurrentUserStore,
|
|
11
|
+
useAuth,
|
|
12
|
+
useAuthStatus,
|
|
13
|
+
useCurrentUser,
|
|
14
|
+
} from './svelte/use-auth'
|
|
15
|
+
export type { UseAuthResult } from './svelte/use-auth'
|
|
16
|
+
export { destroyOrgProvider, getOrgContext, initOrgProvider } from './svelte/org-context'
|
|
17
|
+
export type { OrgContextValue } from './svelte/org-context'
|
|
18
|
+
export {
|
|
19
|
+
createPermissionStore,
|
|
20
|
+
useOrg,
|
|
21
|
+
useOrgMembers,
|
|
22
|
+
usePermission,
|
|
23
|
+
checkOrgPermission,
|
|
24
|
+
} from './svelte/org-hooks'
|
|
25
|
+
export type { UseOrgResult, UseOrgMembersResult } from './svelte/org-hooks'
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { KoraError } from '@korajs/core'
|
|
2
|
+
import { decryptData, encryptData } from '../encryption/database-encryption'
|
|
3
|
+
import type { AuthTokens } from '../types'
|
|
4
|
+
|
|
5
|
+
// --- Errors ---
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Thrown when encrypted token storage operations fail.
|
|
9
|
+
* Includes context about what went wrong to aid debugging without
|
|
10
|
+
* exposing sensitive key or token material.
|
|
11
|
+
*/
|
|
12
|
+
export class EncryptedTokenStoreError extends KoraError {
|
|
13
|
+
constructor(message: string, context?: Record<string, unknown>) {
|
|
14
|
+
super(message, 'ENCRYPTED_TOKEN_STORE_ERROR', context)
|
|
15
|
+
this.name = 'EncryptedTokenStoreError'
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// --- Encoding helpers ---
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Encodes a Uint8Array as a base64url string (no padding).
|
|
23
|
+
* Implemented locally to avoid coupling to the device-identity module.
|
|
24
|
+
*
|
|
25
|
+
* @param bytes - The binary data to encode
|
|
26
|
+
* @returns A base64url-encoded string without padding characters
|
|
27
|
+
*/
|
|
28
|
+
function toBase64Url(bytes: Uint8Array): string {
|
|
29
|
+
let binary = ''
|
|
30
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
31
|
+
binary += String.fromCharCode(bytes[i] as number)
|
|
32
|
+
}
|
|
33
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Decodes a base64url string (no padding) into a Uint8Array.
|
|
38
|
+
*
|
|
39
|
+
* @param str - A base64url-encoded string (with or without padding)
|
|
40
|
+
* @returns The decoded binary data as a Uint8Array
|
|
41
|
+
*/
|
|
42
|
+
function fromBase64Url(str: string): Uint8Array {
|
|
43
|
+
let base64 = str.replace(/-/g, '+').replace(/_/g, '/')
|
|
44
|
+
const paddingNeeded = (4 - (base64.length % 4)) % 4
|
|
45
|
+
base64 += '='.repeat(paddingNeeded)
|
|
46
|
+
|
|
47
|
+
const binary = atob(base64)
|
|
48
|
+
const bytes = new Uint8Array(binary.length)
|
|
49
|
+
for (let i = 0; i < binary.length; i++) {
|
|
50
|
+
bytes[i] = binary.charCodeAt(i)
|
|
51
|
+
}
|
|
52
|
+
return bytes
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- Storage helpers ---
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Minimal storage interface matching the subset of the Web Storage API
|
|
59
|
+
* that EncryptedTokenStore needs. Allows both localStorage and in-memory
|
|
60
|
+
* implementations to be used interchangeably.
|
|
61
|
+
*/
|
|
62
|
+
interface SimpleStorage {
|
|
63
|
+
getItem(key: string): string | null
|
|
64
|
+
setItem(key: string, value: string): void
|
|
65
|
+
removeItem(key: string): void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* In-memory storage fallback used when localStorage is unavailable
|
|
70
|
+
* (e.g., in Node.js, SSR environments, or when storage access is denied).
|
|
71
|
+
*/
|
|
72
|
+
class MemoryStorage implements SimpleStorage {
|
|
73
|
+
private store = new Map<string, string>()
|
|
74
|
+
|
|
75
|
+
getItem(key: string): string | null {
|
|
76
|
+
return this.store.get(key) ?? null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
setItem(key: string, value: string): void {
|
|
80
|
+
this.store.set(key, value)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
removeItem(key: string): void {
|
|
84
|
+
this.store.delete(key)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Attempts to access localStorage. Returns null if unavailable.
|
|
90
|
+
*
|
|
91
|
+
* localStorage may be unavailable in several scenarios:
|
|
92
|
+
* - Node.js / server-side rendering (no window object)
|
|
93
|
+
* - Private browsing modes with restricted storage
|
|
94
|
+
* - iframe sandboxing without storage access
|
|
95
|
+
* - User has disabled cookies/storage in browser settings
|
|
96
|
+
*/
|
|
97
|
+
function tryGetLocalStorage(): SimpleStorage | null {
|
|
98
|
+
try {
|
|
99
|
+
if (typeof globalThis !== 'undefined' && 'localStorage' in globalThis) {
|
|
100
|
+
const storage = globalThis.localStorage as SimpleStorage
|
|
101
|
+
const testKey = '__kora_encrypted_storage_test__'
|
|
102
|
+
storage.setItem(testKey, '1')
|
|
103
|
+
storage.removeItem(testKey)
|
|
104
|
+
return storage
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// localStorage exists but access is denied
|
|
108
|
+
}
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- Types ---
|
|
113
|
+
|
|
114
|
+
/** Default storage key prefix for encrypted tokens. */
|
|
115
|
+
const DEFAULT_STORAGE_KEY = 'kora_auth_encrypted'
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The serialized format stored in localStorage.
|
|
119
|
+
* Both `iv` and `data` are base64url-encoded strings.
|
|
120
|
+
*/
|
|
121
|
+
interface EncryptedPayload {
|
|
122
|
+
/** Base64url-encoded initialization vector (12 bytes for AES-GCM) */
|
|
123
|
+
iv: string
|
|
124
|
+
/** Base64url-encoded AES-256-GCM ciphertext of the JSON-serialized AuthTokens */
|
|
125
|
+
data: string
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Configuration for the encrypted token store.
|
|
130
|
+
*/
|
|
131
|
+
export interface EncryptedTokenStoreConfig {
|
|
132
|
+
/**
|
|
133
|
+
* Storage key prefix. Defaults to 'kora_auth_encrypted'.
|
|
134
|
+
* Use different keys if your app runs multiple Kora instances with separate auth.
|
|
135
|
+
*/
|
|
136
|
+
storageKey?: string
|
|
137
|
+
/**
|
|
138
|
+
* The AES-256-GCM CryptoKey used to encrypt and decrypt tokens.
|
|
139
|
+
*
|
|
140
|
+
* This can be:
|
|
141
|
+
* - A key derived from a user passphrase via {@link deriveEncryptionKey}
|
|
142
|
+
* - A device-derived key (e.g., from secure hardware or biometric unlock)
|
|
143
|
+
* - A randomly generated key from {@link generateEncryptionKey}
|
|
144
|
+
*
|
|
145
|
+
* The caller is responsible for obtaining the key before constructing the store.
|
|
146
|
+
*/
|
|
147
|
+
key: CryptoKey
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- Implementation ---
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Encrypted token store that protects auth tokens at rest.
|
|
154
|
+
*
|
|
155
|
+
* Addresses the security vulnerability of storing tokens in plaintext localStorage,
|
|
156
|
+
* which is accessible to any JavaScript running on the page (XSS attack surface).
|
|
157
|
+
* Tokens are encrypted with AES-256-GCM before being written to storage.
|
|
158
|
+
*
|
|
159
|
+
* The stored format is a JSON string with two base64url-encoded fields:
|
|
160
|
+
* - `iv`: the 12-byte initialization vector (unique per encryption)
|
|
161
|
+
* - `data`: the AES-256-GCM ciphertext of the JSON-serialized tokens
|
|
162
|
+
*
|
|
163
|
+
* AES-GCM provides both confidentiality (tokens are unreadable without the key)
|
|
164
|
+
* and integrity (tampered ciphertext is detected and rejected).
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* ```typescript
|
|
168
|
+
* import { deriveEncryptionKey } from '@korajs/auth'
|
|
169
|
+
*
|
|
170
|
+
* // Derive a key from the user's passphrase
|
|
171
|
+
* const { key } = await deriveEncryptionKey('user-passphrase', storedSalt)
|
|
172
|
+
*
|
|
173
|
+
* const store = new EncryptedTokenStore({ key })
|
|
174
|
+
*
|
|
175
|
+
* // After login: encrypt and persist tokens
|
|
176
|
+
* await store.saveTokens({ accessToken: '...', refreshToken: '...' })
|
|
177
|
+
*
|
|
178
|
+
* // Before API calls: decrypt and retrieve
|
|
179
|
+
* const accessToken = await store.getAccessToken()
|
|
180
|
+
*
|
|
181
|
+
* // On logout: remove encrypted data
|
|
182
|
+
* store.clearTokens()
|
|
183
|
+
* ```
|
|
184
|
+
*/
|
|
185
|
+
export class EncryptedTokenStore {
|
|
186
|
+
private readonly storageKey: string
|
|
187
|
+
private readonly key: CryptoKey
|
|
188
|
+
private readonly storage: SimpleStorage
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Creates a new EncryptedTokenStore instance.
|
|
192
|
+
*
|
|
193
|
+
* @param config - Configuration including the encryption key and optional storage key
|
|
194
|
+
*/
|
|
195
|
+
constructor(config: EncryptedTokenStoreConfig) {
|
|
196
|
+
this.storageKey = config.storageKey ?? DEFAULT_STORAGE_KEY
|
|
197
|
+
this.key = config.key
|
|
198
|
+
this.storage = tryGetLocalStorage() ?? new MemoryStorage()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Encrypt and save tokens to persistent storage.
|
|
203
|
+
*
|
|
204
|
+
* Serializes the tokens as JSON, encrypts with AES-256-GCM using a fresh
|
|
205
|
+
* random IV, then stores the result as a JSON object containing the
|
|
206
|
+
* base64url-encoded IV and ciphertext.
|
|
207
|
+
*
|
|
208
|
+
* Overwrites any previously stored tokens.
|
|
209
|
+
*
|
|
210
|
+
* @param tokens - The token set to encrypt and store
|
|
211
|
+
* @throws {EncryptedTokenStoreError} If encryption fails
|
|
212
|
+
*/
|
|
213
|
+
async saveTokens(tokens: AuthTokens): Promise<void> {
|
|
214
|
+
// Build a clean token object with only the expected fields
|
|
215
|
+
const serialized: AuthTokens = {
|
|
216
|
+
accessToken: tokens.accessToken,
|
|
217
|
+
refreshToken: tokens.refreshToken,
|
|
218
|
+
}
|
|
219
|
+
if (tokens.deviceCredential !== undefined) {
|
|
220
|
+
serialized.deviceCredential = tokens.deviceCredential
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(serialized))
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const { ciphertext, iv } = await encryptData(this.key, plaintext)
|
|
227
|
+
|
|
228
|
+
const payload: EncryptedPayload = {
|
|
229
|
+
iv: toBase64Url(iv),
|
|
230
|
+
data: toBase64Url(ciphertext),
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
this.storage.setItem(this.storageKey, JSON.stringify(payload))
|
|
234
|
+
} catch (cause) {
|
|
235
|
+
// Re-throw EncryptedTokenStoreError as-is
|
|
236
|
+
if (cause instanceof EncryptedTokenStoreError) {
|
|
237
|
+
throw cause
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
throw new EncryptedTokenStoreError(
|
|
241
|
+
'Failed to encrypt and save auth tokens. ' +
|
|
242
|
+
'Ensure the encryption key is a valid AES-256-GCM CryptoKey.',
|
|
243
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) },
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Load and decrypt tokens from storage.
|
|
250
|
+
*
|
|
251
|
+
* Reads the encrypted payload from localStorage, decodes the base64url IV
|
|
252
|
+
* and ciphertext, decrypts with AES-256-GCM, and parses the resulting JSON.
|
|
253
|
+
*
|
|
254
|
+
* Returns null (without throwing) if:
|
|
255
|
+
* - No tokens have been saved
|
|
256
|
+
* - The stored data is corrupted or not valid JSON
|
|
257
|
+
* - Decryption fails (wrong key, tampered ciphertext, or wrong IV)
|
|
258
|
+
* - The decrypted data does not contain valid token fields
|
|
259
|
+
*
|
|
260
|
+
* This fail-silent design prevents decryption errors from crashing the
|
|
261
|
+
* application. The caller should treat null as "no valid tokens available"
|
|
262
|
+
* and initiate a re-authentication flow.
|
|
263
|
+
*
|
|
264
|
+
* @returns The decrypted {@link AuthTokens}, or null if unavailable or decryption fails
|
|
265
|
+
*/
|
|
266
|
+
async loadTokens(): Promise<AuthTokens | null> {
|
|
267
|
+
const raw = this.storage.getItem(this.storageKey)
|
|
268
|
+
if (raw === null) {
|
|
269
|
+
return null
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
// Parse the stored encrypted payload
|
|
274
|
+
const parsed: unknown = JSON.parse(raw)
|
|
275
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
276
|
+
return null
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const record = parsed as Record<string, unknown>
|
|
280
|
+
if (typeof record.iv !== 'string' || typeof record.data !== 'string') {
|
|
281
|
+
return null
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Decode the base64url-encoded IV and ciphertext
|
|
285
|
+
const iv = fromBase64Url(record.iv)
|
|
286
|
+
const ciphertext = fromBase64Url(record.data)
|
|
287
|
+
|
|
288
|
+
// Decrypt with AES-256-GCM
|
|
289
|
+
const plaintextBytes = await decryptData(this.key, ciphertext, iv)
|
|
290
|
+
const json = new TextDecoder().decode(plaintextBytes)
|
|
291
|
+
|
|
292
|
+
// Parse the decrypted JSON and validate token structure
|
|
293
|
+
const tokenData: unknown = JSON.parse(json)
|
|
294
|
+
if (typeof tokenData !== 'object' || tokenData === null || Array.isArray(tokenData)) {
|
|
295
|
+
return null
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const tokenRecord = tokenData as Record<string, unknown>
|
|
299
|
+
if (
|
|
300
|
+
typeof tokenRecord.accessToken !== 'string' ||
|
|
301
|
+
typeof tokenRecord.refreshToken !== 'string'
|
|
302
|
+
) {
|
|
303
|
+
return null
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const tokens: AuthTokens = {
|
|
307
|
+
accessToken: tokenRecord.accessToken,
|
|
308
|
+
refreshToken: tokenRecord.refreshToken,
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (typeof tokenRecord.deviceCredential === 'string') {
|
|
312
|
+
tokens.deviceCredential = tokenRecord.deviceCredential
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return tokens
|
|
316
|
+
} catch {
|
|
317
|
+
// Decryption failure, corrupted data, or JSON parse error.
|
|
318
|
+
// Return null instead of throwing to allow graceful fallback
|
|
319
|
+
// to re-authentication.
|
|
320
|
+
return null
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Clear all stored encrypted tokens.
|
|
326
|
+
*
|
|
327
|
+
* Removes the encrypted payload from storage. This is a synchronous
|
|
328
|
+
* operation since it only removes the localStorage entry.
|
|
329
|
+
*
|
|
330
|
+
* Call this on logout to ensure no encrypted credential material
|
|
331
|
+
* remains in persistent storage.
|
|
332
|
+
*/
|
|
333
|
+
clearTokens(): void {
|
|
334
|
+
this.storage.removeItem(this.storageKey)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Get the current access token by decrypting stored tokens.
|
|
339
|
+
*
|
|
340
|
+
* Returns the raw token string without validating expiration.
|
|
341
|
+
* The caller is responsible for checking whether the token is
|
|
342
|
+
* still valid and initiating a refresh if needed.
|
|
343
|
+
*
|
|
344
|
+
* @returns The decrypted access token string, or null if no valid tokens are stored
|
|
345
|
+
*/
|
|
346
|
+
async getAccessToken(): Promise<string | null> {
|
|
347
|
+
const tokens = await this.loadTokens()
|
|
348
|
+
return tokens?.accessToken ?? null
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Get the current refresh token by decrypting stored tokens.
|
|
353
|
+
*
|
|
354
|
+
* @returns The decrypted refresh token string, or null if no valid tokens are stored
|
|
355
|
+
*/
|
|
356
|
+
async getRefreshToken(): Promise<string | null> {
|
|
357
|
+
const tokens = await this.loadTokens()
|
|
358
|
+
return tokens?.refreshToken ?? null
|
|
359
|
+
}
|
|
360
|
+
}
|