@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,192 @@
|
|
|
1
|
+
import type { AuthTokens } from '../types'
|
|
2
|
+
|
|
3
|
+
/** Default key used for localStorage persistence. */
|
|
4
|
+
const DEFAULT_STORAGE_KEY = 'kora_auth_tokens'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Minimal storage interface matching the subset of the Web Storage API
|
|
8
|
+
* that TokenStore needs. This allows both localStorage and in-memory
|
|
9
|
+
* implementations to be used interchangeably.
|
|
10
|
+
*/
|
|
11
|
+
interface SimpleStorage {
|
|
12
|
+
getItem(key: string): string | null
|
|
13
|
+
setItem(key: string, value: string): void
|
|
14
|
+
removeItem(key: string): void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* In-memory storage fallback used when localStorage is unavailable
|
|
19
|
+
* (e.g., in Node.js, SSR environments, or when storage access is denied).
|
|
20
|
+
*/
|
|
21
|
+
class MemoryStorage implements SimpleStorage {
|
|
22
|
+
private store = new Map<string, string>()
|
|
23
|
+
|
|
24
|
+
getItem(key: string): string | null {
|
|
25
|
+
return this.store.get(key) ?? null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
setItem(key: string, value: string): void {
|
|
29
|
+
this.store.set(key, value)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
removeItem(key: string): void {
|
|
33
|
+
this.store.delete(key)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Attempts to access localStorage. Returns null if unavailable.
|
|
39
|
+
*
|
|
40
|
+
* localStorage may be unavailable in several scenarios:
|
|
41
|
+
* - Node.js / server-side rendering (no window object)
|
|
42
|
+
* - Private browsing modes with restricted storage
|
|
43
|
+
* - iframe sandboxing without storage access
|
|
44
|
+
* - User has disabled cookies/storage in browser settings
|
|
45
|
+
*/
|
|
46
|
+
function tryGetLocalStorage(): SimpleStorage | null {
|
|
47
|
+
try {
|
|
48
|
+
if (typeof globalThis !== 'undefined' && 'localStorage' in globalThis) {
|
|
49
|
+
const storage = globalThis.localStorage as SimpleStorage
|
|
50
|
+
// Probe that read/write actually works (some browsers throw on access)
|
|
51
|
+
const testKey = '__kora_storage_test__'
|
|
52
|
+
storage.setItem(testKey, '1')
|
|
53
|
+
storage.removeItem(testKey)
|
|
54
|
+
return storage
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// localStorage exists but access is denied (e.g., private mode in some browsers)
|
|
58
|
+
}
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Client-side token storage for Kora auth tokens.
|
|
64
|
+
*
|
|
65
|
+
* Persists tokens to localStorage when available, falling back to in-memory
|
|
66
|
+
* storage in environments where localStorage is unavailable (Node.js, SSR,
|
|
67
|
+
* restricted browser contexts). Encrypted storage is planned for Phase 2.
|
|
68
|
+
*
|
|
69
|
+
* All operations are synchronous since both localStorage and in-memory
|
|
70
|
+
* storage are synchronous.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```typescript
|
|
74
|
+
* const store = new TokenStore()
|
|
75
|
+
*
|
|
76
|
+
* // After login
|
|
77
|
+
* store.saveTokens({ accessToken: '...', refreshToken: '...' })
|
|
78
|
+
*
|
|
79
|
+
* // Before API calls
|
|
80
|
+
* const token = store.getAccessToken()
|
|
81
|
+
*
|
|
82
|
+
* // On logout
|
|
83
|
+
* store.clearTokens()
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
export class TokenStore {
|
|
87
|
+
private readonly storageKey: string
|
|
88
|
+
private readonly storage: SimpleStorage
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Creates a new TokenStore instance.
|
|
92
|
+
*
|
|
93
|
+
* @param storageKey - The key under which tokens are stored. Defaults to 'kora_auth_tokens'.
|
|
94
|
+
* Use different keys if your app runs multiple Kora instances with separate auth.
|
|
95
|
+
*/
|
|
96
|
+
constructor(storageKey?: string) {
|
|
97
|
+
this.storageKey = storageKey ?? DEFAULT_STORAGE_KEY
|
|
98
|
+
this.storage = tryGetLocalStorage() ?? new MemoryStorage()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Save tokens to persistent storage.
|
|
103
|
+
*
|
|
104
|
+
* Overwrites any previously stored tokens. The tokens are serialized
|
|
105
|
+
* as JSON. Only the `accessToken`, `refreshToken`, and optional
|
|
106
|
+
* `deviceCredential` fields are persisted.
|
|
107
|
+
*
|
|
108
|
+
* @param tokens - The token set to store
|
|
109
|
+
*/
|
|
110
|
+
saveTokens(tokens: AuthTokens): void {
|
|
111
|
+
const serialized: AuthTokens = {
|
|
112
|
+
accessToken: tokens.accessToken,
|
|
113
|
+
refreshToken: tokens.refreshToken,
|
|
114
|
+
}
|
|
115
|
+
if (tokens.deviceCredential !== undefined) {
|
|
116
|
+
serialized.deviceCredential = tokens.deviceCredential
|
|
117
|
+
}
|
|
118
|
+
this.storage.setItem(this.storageKey, JSON.stringify(serialized))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Load tokens from storage.
|
|
123
|
+
*
|
|
124
|
+
* @returns The stored {@link AuthTokens}, or null if no tokens have been saved
|
|
125
|
+
*/
|
|
126
|
+
loadTokens(): AuthTokens | null {
|
|
127
|
+
const raw = this.storage.getItem(this.storageKey)
|
|
128
|
+
if (raw === null) {
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const parsed: unknown = JSON.parse(raw)
|
|
134
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
const record = parsed as Record<string, unknown>
|
|
138
|
+
|
|
139
|
+
// Validate required fields are present and are strings
|
|
140
|
+
if (typeof record.accessToken !== 'string' || typeof record.refreshToken !== 'string') {
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const tokens: AuthTokens = {
|
|
145
|
+
accessToken: record.accessToken,
|
|
146
|
+
refreshToken: record.refreshToken,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (typeof record.deviceCredential === 'string') {
|
|
150
|
+
tokens.deviceCredential = record.deviceCredential
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return tokens
|
|
154
|
+
} catch {
|
|
155
|
+
// Corrupted data in storage; treat as empty
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Clear all stored tokens.
|
|
162
|
+
*
|
|
163
|
+
* Call this on logout to remove credentials from persistent storage.
|
|
164
|
+
*/
|
|
165
|
+
clearTokens(): void {
|
|
166
|
+
this.storage.removeItem(this.storageKey)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Get the current access token.
|
|
171
|
+
*
|
|
172
|
+
* Returns the raw token string without validating expiration.
|
|
173
|
+
* The caller is responsible for checking whether the token is
|
|
174
|
+
* still valid and initiating a refresh if needed.
|
|
175
|
+
*
|
|
176
|
+
* @returns The access token string, or null if no tokens are stored
|
|
177
|
+
*/
|
|
178
|
+
getAccessToken(): string | null {
|
|
179
|
+
const tokens = this.loadTokens()
|
|
180
|
+
return tokens?.accessToken ?? null
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Get the current refresh token.
|
|
185
|
+
*
|
|
186
|
+
* @returns The refresh token string, or null if no tokens are stored
|
|
187
|
+
*/
|
|
188
|
+
getRefreshToken(): string | null {
|
|
189
|
+
const tokens = this.loadTokens()
|
|
190
|
+
return tokens?.refreshToken ?? null
|
|
191
|
+
}
|
|
192
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { KoraError } from '@korajs/core'
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// User types
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Authenticated user record.
|
|
9
|
+
* Represents the public-facing user identity returned by sign-in and sign-up flows.
|
|
10
|
+
*/
|
|
11
|
+
export interface AuthUser {
|
|
12
|
+
/** Unique user identifier (UUID v7) */
|
|
13
|
+
id: string
|
|
14
|
+
/** User's email address, used as the primary login credential */
|
|
15
|
+
email: string
|
|
16
|
+
/** Display name */
|
|
17
|
+
name: string
|
|
18
|
+
/** Timestamp of user creation (milliseconds since epoch) */
|
|
19
|
+
createdAt: number
|
|
20
|
+
/** Timestamp of last profile update (milliseconds since epoch) */
|
|
21
|
+
updatedAt: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A registered device that can operate on behalf of a user.
|
|
26
|
+
* Each device has its own keypair for offline credential verification.
|
|
27
|
+
*/
|
|
28
|
+
export interface AuthDevice {
|
|
29
|
+
/** Device identifier, same as the Kora nodeId for this device */
|
|
30
|
+
id: string
|
|
31
|
+
/** The user this device belongs to */
|
|
32
|
+
userId: string
|
|
33
|
+
/** JWK-encoded public key for this device's keypair */
|
|
34
|
+
publicKey: string
|
|
35
|
+
/** Human-readable device name (e.g., "Chrome on MacBook") */
|
|
36
|
+
name: string
|
|
37
|
+
/** Timestamp when the device was first registered (milliseconds since epoch) */
|
|
38
|
+
registeredAt: number
|
|
39
|
+
/** Timestamp of last activity from this device (milliseconds since epoch) */
|
|
40
|
+
lastSeenAt: number
|
|
41
|
+
/** Whether this device is allowed to sync */
|
|
42
|
+
status: DeviceStatus
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Device status values */
|
|
46
|
+
export type DeviceStatus = 'active' | 'revoked'
|
|
47
|
+
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Token types
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
/** The three kinds of tokens issued by the auth system */
|
|
53
|
+
export type TokenType = 'access' | 'refresh' | 'device_credential'
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Base payload present in all JWT tokens.
|
|
57
|
+
* Fields follow standard JWT claim names.
|
|
58
|
+
*/
|
|
59
|
+
export interface TokenPayload {
|
|
60
|
+
/** JWT ID: unique identifier for this specific token (for revocation and replay detection) */
|
|
61
|
+
jti: string
|
|
62
|
+
/** Subject: the user ID */
|
|
63
|
+
sub: string
|
|
64
|
+
/** Device ID that this token was issued to */
|
|
65
|
+
dev: string
|
|
66
|
+
/** Which kind of token this is */
|
|
67
|
+
type: TokenType
|
|
68
|
+
/** Issued-at time (seconds since epoch, per JWT spec) */
|
|
69
|
+
iat: number
|
|
70
|
+
/** Expiration time (seconds since epoch, per JWT spec) */
|
|
71
|
+
exp: number
|
|
72
|
+
/**
|
|
73
|
+
* Refresh-token family id. Every token minted by one sign-in and its refresh
|
|
74
|
+
* rotations shares it, so reuse detection can revoke exactly that family
|
|
75
|
+
* instead of the whole device. Absent on tokens minted before beta.13.
|
|
76
|
+
*/
|
|
77
|
+
fam?: string
|
|
78
|
+
/**
|
|
79
|
+
* Issue time in milliseconds. `iat` has one-second resolution, which is too
|
|
80
|
+
* coarse to order a token against a revocation made in the same second.
|
|
81
|
+
*/
|
|
82
|
+
iatMs?: number
|
|
83
|
+
/**
|
|
84
|
+
* Authentication methods used to obtain this session (RFC 8176), for example
|
|
85
|
+
* `['pwd']` or `['pwd', 'otp']`.
|
|
86
|
+
*/
|
|
87
|
+
amr?: string[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Short-lived token used to authenticate API requests.
|
|
92
|
+
* Typically lives for 15 minutes.
|
|
93
|
+
*/
|
|
94
|
+
export interface AccessTokenPayload extends TokenPayload {
|
|
95
|
+
type: 'access'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Longer-lived token used to obtain new access tokens.
|
|
100
|
+
* Typically lives for 90 days.
|
|
101
|
+
*/
|
|
102
|
+
export interface RefreshTokenPayload extends TokenPayload {
|
|
103
|
+
type: 'refresh'
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Offline credential stored on the device.
|
|
108
|
+
* Allows the device to continue operating offline, with a mandatory
|
|
109
|
+
* check-in deadline by which it must reconnect to remain authorized.
|
|
110
|
+
*/
|
|
111
|
+
export interface DeviceCredentialPayload extends TokenPayload {
|
|
112
|
+
type: 'device_credential'
|
|
113
|
+
/** Device public key thumbprint, binds this credential to a specific device keypair */
|
|
114
|
+
dpk: string
|
|
115
|
+
/** Timestamp (seconds since epoch) by which the device must check in with the server */
|
|
116
|
+
mustCheckinBy: number
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Bundle of tokens returned after successful authentication.
|
|
121
|
+
*/
|
|
122
|
+
export interface AuthTokens {
|
|
123
|
+
/** Short-lived access token */
|
|
124
|
+
accessToken: string
|
|
125
|
+
/** Long-lived refresh token */
|
|
126
|
+
refreshToken: string
|
|
127
|
+
/** Optional device credential for offline operation */
|
|
128
|
+
deviceCredential?: string
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ============================================================================
|
|
132
|
+
// Auth configuration
|
|
133
|
+
// ============================================================================
|
|
134
|
+
|
|
135
|
+
/** Supported auth provider types */
|
|
136
|
+
export type AuthProviderType = 'built-in' | 'custom'
|
|
137
|
+
|
|
138
|
+
/** Unlock mechanism for encrypted local storage */
|
|
139
|
+
export type UnlockMethod = 'biometric' | 'passphrase' | 'both'
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Encryption settings for locally stored auth credentials.
|
|
143
|
+
*/
|
|
144
|
+
export interface AuthEncryptionConfig {
|
|
145
|
+
/** Whether local credential encryption is enabled */
|
|
146
|
+
enabled: boolean
|
|
147
|
+
/** How the user unlocks encrypted credentials */
|
|
148
|
+
unlock: UnlockMethod
|
|
149
|
+
/** Milliseconds of inactivity before the app auto-locks. Defaults to 15 minutes. */
|
|
150
|
+
autoLockTimeout?: number
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Custom lifetimes for each token type.
|
|
155
|
+
* All values are in milliseconds.
|
|
156
|
+
*/
|
|
157
|
+
export interface TokenLifetimeConfig {
|
|
158
|
+
/** Access token lifetime in ms. Default: 15 minutes. */
|
|
159
|
+
access?: number
|
|
160
|
+
/** Refresh token lifetime in ms. Default: 90 days. */
|
|
161
|
+
refresh?: number
|
|
162
|
+
/** Device credential lifetime in ms. Default: 90 days. */
|
|
163
|
+
deviceCredential?: number
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Configuration for the Kora auth system.
|
|
168
|
+
* Passed to the auth initializer to control provider, encryption, and token behavior.
|
|
169
|
+
*/
|
|
170
|
+
export interface AuthConfig {
|
|
171
|
+
/** Which auth provider to use */
|
|
172
|
+
provider: AuthProviderType
|
|
173
|
+
/** Local encryption settings for stored credentials */
|
|
174
|
+
encryption?: AuthEncryptionConfig
|
|
175
|
+
/** Maximum time a device can operate offline without checking in (ms). Default: 30 days. */
|
|
176
|
+
maxOfflineDuration?: number
|
|
177
|
+
/** Custom token lifetimes */
|
|
178
|
+
tokenLifetimes?: TokenLifetimeConfig
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ============================================================================
|
|
182
|
+
// Auth state
|
|
183
|
+
// ============================================================================
|
|
184
|
+
|
|
185
|
+
/** Possible authentication states */
|
|
186
|
+
export type AuthStatus = 'authenticated' | 'unauthenticated' | 'locked'
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Current state of the auth system.
|
|
190
|
+
* This is the value exposed to the UI layer for rendering auth-dependent views.
|
|
191
|
+
*/
|
|
192
|
+
export interface AuthState {
|
|
193
|
+
/** Current authentication status */
|
|
194
|
+
status: AuthStatus
|
|
195
|
+
/** The authenticated user, or null if unauthenticated/locked */
|
|
196
|
+
user: AuthUser | null
|
|
197
|
+
/** The current device's ID, or null if not yet registered */
|
|
198
|
+
deviceId: string | null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ============================================================================
|
|
202
|
+
// Auth events
|
|
203
|
+
// ============================================================================
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Events emitted by the auth system.
|
|
207
|
+
* These integrate with Kora's event system for DevTools observability.
|
|
208
|
+
*/
|
|
209
|
+
export type AuthEvent =
|
|
210
|
+
| { type: 'auth:signed-in'; user: AuthUser }
|
|
211
|
+
| { type: 'auth:signed-out' }
|
|
212
|
+
| { type: 'auth:locked' }
|
|
213
|
+
| { type: 'auth:unlocked'; user: AuthUser }
|
|
214
|
+
| { type: 'auth:token-refreshed' }
|
|
215
|
+
| { type: 'auth:device-revoked'; deviceId: string }
|
|
216
|
+
| { type: 'auth:permission-changed' }
|
|
217
|
+
|
|
218
|
+
/** Extract the event type string union from AuthEvent */
|
|
219
|
+
export type AuthEventType = AuthEvent['type']
|
|
220
|
+
|
|
221
|
+
/** Extract a specific auth event by its type */
|
|
222
|
+
export type AuthEventByType<T extends AuthEventType> = Extract<AuthEvent, { type: T }>
|
|
223
|
+
|
|
224
|
+
// ============================================================================
|
|
225
|
+
// Auth errors
|
|
226
|
+
// ============================================================================
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Base error class for authentication-related failures.
|
|
230
|
+
* Extends KoraError to integrate with the framework's error handling patterns.
|
|
231
|
+
*/
|
|
232
|
+
export class AuthError extends KoraError {
|
|
233
|
+
constructor(message: string, code: string, context?: Record<string, unknown>) {
|
|
234
|
+
super(message, code, context)
|
|
235
|
+
this.name = 'AuthError'
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Thrown when sign-in credentials are invalid (wrong email or password).
|
|
241
|
+
*/
|
|
242
|
+
export class InvalidCredentialsError extends AuthError {
|
|
243
|
+
constructor() {
|
|
244
|
+
super('Invalid email or password.', 'AUTH_INVALID_CREDENTIALS')
|
|
245
|
+
this.name = 'InvalidCredentialsError'
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Thrown when a user tries to sign up with an email that already exists.
|
|
251
|
+
*/
|
|
252
|
+
export class EmailAlreadyExistsError extends AuthError {
|
|
253
|
+
constructor() {
|
|
254
|
+
super('An account with this email already exists.', 'AUTH_EMAIL_EXISTS')
|
|
255
|
+
this.name = 'EmailAlreadyExistsError'
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Thrown when a token is expired, malformed, or has an invalid signature.
|
|
261
|
+
*/
|
|
262
|
+
export class TokenError extends AuthError {
|
|
263
|
+
constructor(message: string, context?: Record<string, unknown>) {
|
|
264
|
+
super(message, 'AUTH_TOKEN_ERROR', context)
|
|
265
|
+
this.name = 'TokenError'
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Thrown when a device's credential has expired or the device has been revoked.
|
|
271
|
+
*/
|
|
272
|
+
export class DeviceRevokedError extends AuthError {
|
|
273
|
+
constructor(deviceId: string) {
|
|
274
|
+
super(`Device "${deviceId}" has been revoked and can no longer sync.`, 'AUTH_DEVICE_REVOKED', {
|
|
275
|
+
deviceId,
|
|
276
|
+
})
|
|
277
|
+
this.name = 'DeviceRevokedError'
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Thrown when the maximum offline duration has been exceeded
|
|
283
|
+
* and the device must reconnect to continue operating.
|
|
284
|
+
*/
|
|
285
|
+
export class OfflineExpiredError extends AuthError {
|
|
286
|
+
constructor(maxDuration: number, lastCheckin: number) {
|
|
287
|
+
const daysSinceCheckin = Math.round((Date.now() - lastCheckin) / (24 * 60 * 60 * 1000))
|
|
288
|
+
super(
|
|
289
|
+
`Device has been offline for ${daysSinceCheckin} days, exceeding the maximum offline duration. Reconnect to re-authenticate.`,
|
|
290
|
+
'AUTH_OFFLINE_EXPIRED',
|
|
291
|
+
{ maxDuration, lastCheckin, daysSinceCheckin },
|
|
292
|
+
)
|
|
293
|
+
this.name = 'OfflineExpiredError'
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ============================================================================
|
|
298
|
+
// Sign up / sign in params
|
|
299
|
+
// ============================================================================
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Parameters for creating a new user account.
|
|
303
|
+
*/
|
|
304
|
+
export interface SignUpParams {
|
|
305
|
+
/** Email address (used as login credential) */
|
|
306
|
+
email: string
|
|
307
|
+
/** Password (will be hashed before storage) */
|
|
308
|
+
password: string
|
|
309
|
+
/** Optional display name. Defaults to the local part of the email if omitted. */
|
|
310
|
+
name?: string
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Parameters for signing in to an existing account.
|
|
315
|
+
*/
|
|
316
|
+
export interface SignInParams {
|
|
317
|
+
/** Email address */
|
|
318
|
+
email: string
|
|
319
|
+
/** Password */
|
|
320
|
+
password: string
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ============================================================================
|
|
324
|
+
// Server-side types for the built-in provider
|
|
325
|
+
// ============================================================================
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Parameters for creating a user record in the server-side store.
|
|
329
|
+
* The password has already been hashed by the time this is used.
|
|
330
|
+
*/
|
|
331
|
+
export interface CreateUserParams {
|
|
332
|
+
/** Email address */
|
|
333
|
+
email: string
|
|
334
|
+
/** Argon2id or bcrypt hash of the password */
|
|
335
|
+
passwordHash: string
|
|
336
|
+
/** Random salt used during hashing */
|
|
337
|
+
salt: string
|
|
338
|
+
/** Display name */
|
|
339
|
+
name: string
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Full user record as stored on the server, including sensitive credential fields.
|
|
344
|
+
* This type must NEVER be returned to the client; strip passwordHash and salt first.
|
|
345
|
+
*/
|
|
346
|
+
export interface StoredUser extends AuthUser {
|
|
347
|
+
/** Hashed password */
|
|
348
|
+
passwordHash: string
|
|
349
|
+
/** Salt used for hashing */
|
|
350
|
+
salt: string
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ============================================================================
|
|
354
|
+
// Auth provider adapter interface
|
|
355
|
+
// ============================================================================
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Adapter interface for pluggable auth providers.
|
|
359
|
+
* Implement this to integrate Kora auth with a custom identity provider
|
|
360
|
+
* (e.g., Firebase Auth, Auth0, Supabase Auth).
|
|
361
|
+
*
|
|
362
|
+
* The built-in provider implements this interface internally.
|
|
363
|
+
*/
|
|
364
|
+
export interface AuthProviderAdapter {
|
|
365
|
+
/** Register a new user and return the user with initial tokens */
|
|
366
|
+
signUp(params: SignUpParams): Promise<{ user: AuthUser; tokens: AuthTokens }>
|
|
367
|
+
/** Authenticate an existing user and return the user with tokens */
|
|
368
|
+
signIn(params: SignInParams): Promise<{ user: AuthUser; tokens: AuthTokens }>
|
|
369
|
+
/** Exchange a refresh token for a new token set */
|
|
370
|
+
refreshToken(refreshToken: string): Promise<AuthTokens>
|
|
371
|
+
/** Validate an access token and return its payload, or null if invalid */
|
|
372
|
+
validateAccessToken(token: string): Promise<TokenPayload | null>
|
|
373
|
+
/** Revoke a device, preventing it from syncing */
|
|
374
|
+
revokeDevice(deviceId: string): Promise<void>
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ============================================================================
|
|
378
|
+
// Constants
|
|
379
|
+
// ============================================================================
|
|
380
|
+
|
|
381
|
+
/** Default access token lifetime: 15 minutes */
|
|
382
|
+
export const DEFAULT_ACCESS_TOKEN_LIFETIME = 15 * 60 * 1000
|
|
383
|
+
|
|
384
|
+
/** Default refresh token lifetime: 90 days */
|
|
385
|
+
export const DEFAULT_REFRESH_TOKEN_LIFETIME = 90 * 24 * 60 * 60 * 1000
|
|
386
|
+
|
|
387
|
+
/** Default device credential lifetime: 90 days */
|
|
388
|
+
export const DEFAULT_DEVICE_CREDENTIAL_LIFETIME = 90 * 24 * 60 * 60 * 1000
|
|
389
|
+
|
|
390
|
+
/** Default maximum offline duration: 30 days */
|
|
391
|
+
export const DEFAULT_MAX_OFFLINE_DURATION = 30 * 24 * 60 * 60 * 1000
|
|
392
|
+
|
|
393
|
+
/** Default auto-lock timeout: 15 minutes */
|
|
394
|
+
export const DEFAULT_AUTO_LOCK_TIMEOUT = 15 * 60 * 1000
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AuthClient, AuthState } from '../client/auth-client'
|
|
2
|
+
|
|
3
|
+
export interface AuthContextValue {
|
|
4
|
+
client: AuthClient
|
|
5
|
+
state: AuthState
|
|
6
|
+
isLoading: boolean
|
|
7
|
+
session: import('../bindings/create-auth-session').AuthSession
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const authContextKey = Symbol('korajs-auth-context')
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { PropType, VNode } from 'vue'
|
|
2
|
+
import { defineComponent, h, inject, onScopeDispose, provide, shallowRef } from 'vue'
|
|
3
|
+
import { type AuthSessionSnapshot, createAuthSession } from '../bindings/create-auth-session'
|
|
4
|
+
import type { AuthClient } from '../client/auth-client'
|
|
5
|
+
import { type AuthContextValue, authContextKey } from './auth-context'
|
|
6
|
+
|
|
7
|
+
export const AuthProvider = defineComponent({
|
|
8
|
+
name: 'AuthProvider',
|
|
9
|
+
props: {
|
|
10
|
+
client: {
|
|
11
|
+
type: Object as PropType<AuthClient>,
|
|
12
|
+
required: true,
|
|
13
|
+
},
|
|
14
|
+
fallback: {
|
|
15
|
+
type: [Object, String] as PropType<VNode | string | null>,
|
|
16
|
+
default: null,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
setup(props, { slots }) {
|
|
20
|
+
const session = createAuthSession(props.client)
|
|
21
|
+
const snapshot = shallowRef<AuthSessionSnapshot>(session.getSnapshot())
|
|
22
|
+
|
|
23
|
+
const unsubscribe = session.subscribe(() => {
|
|
24
|
+
snapshot.value = session.getSnapshot()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
onScopeDispose(() => {
|
|
28
|
+
unsubscribe()
|
|
29
|
+
session.destroy()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const contextValue: AuthContextValue = {
|
|
33
|
+
client: props.client,
|
|
34
|
+
get state() {
|
|
35
|
+
return snapshot.value.state
|
|
36
|
+
},
|
|
37
|
+
get isLoading() {
|
|
38
|
+
return snapshot.value.isLoading
|
|
39
|
+
},
|
|
40
|
+
session,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
provide(authContextKey, contextValue)
|
|
44
|
+
|
|
45
|
+
return () => {
|
|
46
|
+
const current = snapshot.value
|
|
47
|
+
|
|
48
|
+
if (current.initError) {
|
|
49
|
+
return h(
|
|
50
|
+
'div',
|
|
51
|
+
{
|
|
52
|
+
style: { color: 'red', padding: '1rem', fontFamily: 'monospace' },
|
|
53
|
+
role: 'alert',
|
|
54
|
+
},
|
|
55
|
+
[h('strong', null, 'Kora Auth initialization error: '), current.initError.message],
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (current.isLoading && props.fallback !== null) {
|
|
60
|
+
return props.fallback
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return slots.default?.()
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
export function useAuthContext(): AuthContextValue {
|
|
69
|
+
const context = inject<AuthContextValue | undefined>(authContextKey)
|
|
70
|
+
if (!context) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
'useAuth must be used within <AuthProvider>. Wrap your app with <AuthProvider client={authClient}>.',
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
return context
|
|
76
|
+
}
|