@korajs/auth 0.1.0
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 +156 -0
- package/dist/auth-client-CrDNuh10.d.cts +181 -0
- package/dist/auth-client-CrDNuh10.d.ts +181 -0
- package/dist/chunk-L554ZDPY.js +174 -0
- package/dist/chunk-L554ZDPY.js.map +1 -0
- package/dist/device-identity-DiwdLsUB.d.cts +247 -0
- package/dist/device-identity-DiwdLsUB.d.ts +247 -0
- package/dist/index.cjs +740 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +80 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +552 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +206 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +189 -0
- package/dist/react.d.ts +189 -0
- package/dist/react.js +175 -0
- package/dist/react.js.map +1 -0
- package/dist/server.cjs +1040 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +780 -0
- package/dist/server.d.ts +780 -0
- package/dist/server.js +890 -0
- package/dist/server.js.map +1 -0
- package/package.json +74 -0
package/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# @korajs/auth
|
|
2
|
+
|
|
3
|
+
Offline-first authentication for Kora.js applications.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`@korajs/auth` provides a complete authentication system designed for offline-first applications. It includes:
|
|
8
|
+
|
|
9
|
+
- **Client-side auth management** — token storage, session restoration, sign-up/sign-in/sign-out
|
|
10
|
+
- **React hooks** — `useAuth()`, `useCurrentUser()`, `useAuthStatus()` for reactive auth state
|
|
11
|
+
- **Server-side auth routes** — email/password authentication with JWT tokens
|
|
12
|
+
- **Device identity** — ECDSA P-256 key pairs for proof-of-possession
|
|
13
|
+
- **Token management** — access/refresh token lifecycle with rotation
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add @korajs/auth
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
### Client-side (React)
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { AuthClient } from '@korajs/auth'
|
|
27
|
+
import { AuthProvider, useAuth } from '@korajs/auth/react'
|
|
28
|
+
|
|
29
|
+
const authClient = new AuthClient({ serverUrl: 'http://localhost:3001' })
|
|
30
|
+
|
|
31
|
+
function App() {
|
|
32
|
+
return (
|
|
33
|
+
<AuthProvider client={authClient}>
|
|
34
|
+
<MyApp />
|
|
35
|
+
</AuthProvider>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function MyApp() {
|
|
40
|
+
const { user, isAuthenticated, isLoading, signIn, signOut, error } = useAuth()
|
|
41
|
+
|
|
42
|
+
if (isLoading) return <div>Loading...</div>
|
|
43
|
+
|
|
44
|
+
if (!isAuthenticated) {
|
|
45
|
+
return (
|
|
46
|
+
<button onClick={() => signIn({ email: 'user@example.com', password: 'password' })}>
|
|
47
|
+
Sign In
|
|
48
|
+
</button>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div>
|
|
54
|
+
<p>Welcome, {user?.name ?? user?.email}</p>
|
|
55
|
+
<button onClick={() => signOut()}>Sign Out</button>
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Server-side
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
import { BuiltInAuthRoutes, InMemoryUserStore, TokenManager } from '@korajs/auth/server'
|
|
65
|
+
|
|
66
|
+
const userStore = new InMemoryUserStore()
|
|
67
|
+
const tokenManager = new TokenManager({ secret: process.env.AUTH_SECRET! })
|
|
68
|
+
const authRoutes = new BuiltInAuthRoutes({ userStore, tokenManager })
|
|
69
|
+
|
|
70
|
+
// Wire into your HTTP server:
|
|
71
|
+
app.post('/auth/signup', async (req, res) => {
|
|
72
|
+
const result = await authRoutes.handleSignUp(req.body)
|
|
73
|
+
res.status(result.status).json(result.body)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
app.post('/auth/signin', async (req, res) => {
|
|
77
|
+
const result = await authRoutes.handleSignIn(req.body)
|
|
78
|
+
res.status(result.status).json(result.body)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
app.post('/auth/refresh', async (req, res) => {
|
|
82
|
+
const result = await authRoutes.handleRefresh(req.body)
|
|
83
|
+
res.status(result.status).json(result.body)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
app.get('/auth/me', async (req, res) => {
|
|
87
|
+
const token = req.headers.authorization?.replace('Bearer ', '') ?? ''
|
|
88
|
+
const result = await authRoutes.handleGetMe(token)
|
|
89
|
+
res.status(result.status).json(result.body)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// Bridge to Kora sync server:
|
|
93
|
+
const syncServer = new KoraSyncServer({
|
|
94
|
+
store,
|
|
95
|
+
auth: authRoutes.toSyncAuthProvider(),
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Exports
|
|
100
|
+
|
|
101
|
+
### `@korajs/auth` (client entry)
|
|
102
|
+
|
|
103
|
+
| Export | Description |
|
|
104
|
+
|--------|-------------|
|
|
105
|
+
| `AuthClient` | Client-side auth manager |
|
|
106
|
+
| `TokenStore` | Client-side token persistence |
|
|
107
|
+
| `generateDeviceKeyPair` | ECDSA P-256 key pair generation |
|
|
108
|
+
| `exportPublicKeyJwk` | Export public key as JWK |
|
|
109
|
+
| `signChallenge` | Sign challenge with device key |
|
|
110
|
+
| `verifyChallenge` | Verify challenge signature |
|
|
111
|
+
| `computePublicKeyThumbprint` | RFC 7638 JWK thumbprint |
|
|
112
|
+
|
|
113
|
+
### `@korajs/auth/react`
|
|
114
|
+
|
|
115
|
+
| Export | Description |
|
|
116
|
+
|--------|-------------|
|
|
117
|
+
| `AuthProvider` | React context provider |
|
|
118
|
+
| `useAuth` | Full auth hook (user, methods, error) |
|
|
119
|
+
| `useCurrentUser` | Lightweight current user hook |
|
|
120
|
+
| `useAuthStatus` | Auth status for route guards |
|
|
121
|
+
|
|
122
|
+
### `@korajs/auth/server`
|
|
123
|
+
|
|
124
|
+
| Export | Description |
|
|
125
|
+
|--------|-------------|
|
|
126
|
+
| `BuiltInAuthRoutes` | HTTP route handlers |
|
|
127
|
+
| `TokenManager` | JWT issuing/validation |
|
|
128
|
+
| `InMemoryUserStore` | Dev/test user store |
|
|
129
|
+
| `BuiltInProvider` | Adapter wrapping routes |
|
|
130
|
+
| `hashPassword` / `verifyPassword` | PBKDF2 password hashing |
|
|
131
|
+
| `encodeJwt` / `verifyJwt` | Low-level JWT operations |
|
|
132
|
+
|
|
133
|
+
## Security
|
|
134
|
+
|
|
135
|
+
- Passwords hashed with PBKDF2-SHA512 (600,000 iterations, 32-byte salt)
|
|
136
|
+
- JWT tokens signed with HMAC-SHA256
|
|
137
|
+
- Constant-time signature comparison (prevents timing attacks)
|
|
138
|
+
- Device keys use ECDSA P-256 with non-extractable private keys
|
|
139
|
+
- Refresh token rotation on each use
|
|
140
|
+
- Access tokens expire in 15 minutes (configurable)
|
|
141
|
+
|
|
142
|
+
## Architecture
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
Client Server
|
|
146
|
+
┌──────────────────┐ ┌──────────────────┐
|
|
147
|
+
│ AuthClient │ │ BuiltInAuthRoutes│
|
|
148
|
+
│ ├─ TokenStore │ ──── HTTP ────> │ ├─ UserStore │
|
|
149
|
+
│ └─ AuthState │ │ ├─ TokenManager │
|
|
150
|
+
│ │ │ └─ PasswordHash │
|
|
151
|
+
│ React Hooks │ │ │
|
|
152
|
+
│ ├─ useAuth │ │ SyncAuthProvider │
|
|
153
|
+
│ ├─ useCurrentUser │ └─ authenticate()│
|
|
154
|
+
│ └─ useAuthStatus └──────────────────┘
|
|
155
|
+
└──────────────────┘
|
|
156
|
+
```
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { KoraError } from '@korajs/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Thrown when an authentication operation fails.
|
|
5
|
+
* Includes a machine-readable code and optional context for debugging.
|
|
6
|
+
*/
|
|
7
|
+
declare class AuthError extends KoraError {
|
|
8
|
+
constructor(message: string, code: string, context?: Record<string, unknown>);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Possible authentication states for the client.
|
|
12
|
+
* - 'loading': Initial state while restoring tokens from storage
|
|
13
|
+
* - 'authenticated': User is signed in with a valid session
|
|
14
|
+
* - 'unauthenticated': No valid session exists
|
|
15
|
+
*/
|
|
16
|
+
type AuthState = 'loading' | 'authenticated' | 'unauthenticated';
|
|
17
|
+
/**
|
|
18
|
+
* Authenticated user information.
|
|
19
|
+
*/
|
|
20
|
+
interface AuthUser {
|
|
21
|
+
/** Unique user identifier */
|
|
22
|
+
id: string;
|
|
23
|
+
/** User email address */
|
|
24
|
+
email: string;
|
|
25
|
+
/** Display name (may be absent if user did not provide one) */
|
|
26
|
+
name: string | null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Configuration for the AuthClient.
|
|
30
|
+
*/
|
|
31
|
+
interface AuthClientConfig {
|
|
32
|
+
/** Base URL of the auth server (e.g. 'http://localhost:3001') */
|
|
33
|
+
serverUrl: string;
|
|
34
|
+
/** Storage key prefix for tokens. Defaults to 'kora_auth' */
|
|
35
|
+
storageKey?: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Client-side authentication manager for Kora.js.
|
|
39
|
+
*
|
|
40
|
+
* Manages token storage, session restoration, sign-up, sign-in, sign-out,
|
|
41
|
+
* token refresh, and auth state change notifications. Framework-agnostic --
|
|
42
|
+
* works in any JavaScript environment with `fetch` and optionally `localStorage`.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```typescript
|
|
46
|
+
* const auth = new AuthClient({ serverUrl: 'http://localhost:3001' })
|
|
47
|
+
* await auth.initialize()
|
|
48
|
+
*
|
|
49
|
+
* if (!auth.isAuthenticated) {
|
|
50
|
+
* await auth.signIn({ email: 'user@example.com', password: 'secret' })
|
|
51
|
+
* }
|
|
52
|
+
*
|
|
53
|
+
* const unsub = auth.onAuthChange((state) => {
|
|
54
|
+
* console.log('Auth state:', state)
|
|
55
|
+
* })
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
declare class AuthClient {
|
|
59
|
+
private readonly serverUrl;
|
|
60
|
+
private readonly storage;
|
|
61
|
+
private readonly listeners;
|
|
62
|
+
private _state;
|
|
63
|
+
private _user;
|
|
64
|
+
private _refreshPromise;
|
|
65
|
+
/**
|
|
66
|
+
* Creates a new AuthClient.
|
|
67
|
+
*
|
|
68
|
+
* @param config - Auth client configuration
|
|
69
|
+
*/
|
|
70
|
+
constructor(config: AuthClientConfig);
|
|
71
|
+
/** Current authentication state. */
|
|
72
|
+
get state(): AuthState;
|
|
73
|
+
/** Current authenticated user, or null if not signed in. */
|
|
74
|
+
get currentUser(): AuthUser | null;
|
|
75
|
+
/** Whether the user is currently authenticated. */
|
|
76
|
+
get isAuthenticated(): boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Initialize the auth client by restoring a session from stored tokens.
|
|
79
|
+
*
|
|
80
|
+
* Loads tokens from storage, validates the access token, and attempts a
|
|
81
|
+
* refresh if the access token is expired but a refresh token is available.
|
|
82
|
+
* Safe to call multiple times -- subsequent calls are no-ops once initialized.
|
|
83
|
+
*/
|
|
84
|
+
initialize(): Promise<void>;
|
|
85
|
+
/**
|
|
86
|
+
* Register a new user account.
|
|
87
|
+
*
|
|
88
|
+
* @param params - Sign-up credentials
|
|
89
|
+
* @returns The newly created AuthUser
|
|
90
|
+
* @throws {AuthError} If the request fails or the server returns an error
|
|
91
|
+
*/
|
|
92
|
+
signUp(params: {
|
|
93
|
+
email: string;
|
|
94
|
+
password: string;
|
|
95
|
+
name?: string;
|
|
96
|
+
}): Promise<AuthUser>;
|
|
97
|
+
/**
|
|
98
|
+
* Sign in with email and password.
|
|
99
|
+
*
|
|
100
|
+
* @param params - Sign-in credentials
|
|
101
|
+
* @returns The authenticated AuthUser
|
|
102
|
+
* @throws {AuthError} If the credentials are invalid or the request fails
|
|
103
|
+
*/
|
|
104
|
+
signIn(params: {
|
|
105
|
+
email: string;
|
|
106
|
+
password: string;
|
|
107
|
+
}): Promise<AuthUser>;
|
|
108
|
+
/**
|
|
109
|
+
* Sign out the current user.
|
|
110
|
+
*
|
|
111
|
+
* Clears local tokens and state. Does not make a network request to the
|
|
112
|
+
* server -- tokens are simply discarded locally.
|
|
113
|
+
*/
|
|
114
|
+
signOut(): Promise<void>;
|
|
115
|
+
/**
|
|
116
|
+
* Get a valid access token, automatically refreshing if expired.
|
|
117
|
+
*
|
|
118
|
+
* @returns A valid access token string, or null if the user is not
|
|
119
|
+
* authenticated and refresh is not possible
|
|
120
|
+
*/
|
|
121
|
+
getAccessToken(): Promise<string | null>;
|
|
122
|
+
/**
|
|
123
|
+
* Get a valid token for the sync engine handshake.
|
|
124
|
+
* Alias for {@link getAccessToken}.
|
|
125
|
+
*
|
|
126
|
+
* @returns A valid access token string, or null if unavailable
|
|
127
|
+
*/
|
|
128
|
+
getSyncToken(): Promise<string | null>;
|
|
129
|
+
/**
|
|
130
|
+
* Subscribe to authentication state changes.
|
|
131
|
+
*
|
|
132
|
+
* The callback is invoked whenever the auth state transitions (e.g., from
|
|
133
|
+
* 'unauthenticated' to 'authenticated' on sign-in).
|
|
134
|
+
*
|
|
135
|
+
* @param callback - Function called with the new AuthState on each change
|
|
136
|
+
* @returns An unsubscribe function that removes the listener
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* ```typescript
|
|
140
|
+
* const unsub = auth.onAuthChange((state) => {
|
|
141
|
+
* console.log('Auth state changed to:', state)
|
|
142
|
+
* })
|
|
143
|
+
* // Later: unsub()
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
146
|
+
onAuthChange(callback: (state: AuthState) => void): () => void;
|
|
147
|
+
/**
|
|
148
|
+
* Update internal state and notify all listeners.
|
|
149
|
+
*/
|
|
150
|
+
private setState;
|
|
151
|
+
/**
|
|
152
|
+
* Restore a session from a valid access token by fetching the user profile.
|
|
153
|
+
* Falls back to extracting the user ID from the token payload if the
|
|
154
|
+
* /auth/me request fails (offline scenario).
|
|
155
|
+
*/
|
|
156
|
+
private restoreSession;
|
|
157
|
+
/**
|
|
158
|
+
* Fetch the current user profile from the server.
|
|
159
|
+
*/
|
|
160
|
+
private fetchUserProfile;
|
|
161
|
+
/**
|
|
162
|
+
* Refresh the access token using a refresh token.
|
|
163
|
+
* De-duplicates concurrent refresh calls so only one network request is made.
|
|
164
|
+
*/
|
|
165
|
+
private refreshAccessToken;
|
|
166
|
+
/**
|
|
167
|
+
* Execute the token refresh network request.
|
|
168
|
+
*/
|
|
169
|
+
private performRefresh;
|
|
170
|
+
/**
|
|
171
|
+
* Make an HTTP request to the auth server.
|
|
172
|
+
*
|
|
173
|
+
* @param path - URL path relative to serverUrl (e.g. '/auth/signin')
|
|
174
|
+
* @param options - Request options
|
|
175
|
+
* @returns Parsed JSON response body
|
|
176
|
+
* @throws {AuthError} On network failure or non-2xx response
|
|
177
|
+
*/
|
|
178
|
+
private request;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export { AuthClient as A, type AuthClientConfig as a, AuthError as b, type AuthState as c, type AuthUser as d };
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { KoraError } from '@korajs/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Thrown when an authentication operation fails.
|
|
5
|
+
* Includes a machine-readable code and optional context for debugging.
|
|
6
|
+
*/
|
|
7
|
+
declare class AuthError extends KoraError {
|
|
8
|
+
constructor(message: string, code: string, context?: Record<string, unknown>);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Possible authentication states for the client.
|
|
12
|
+
* - 'loading': Initial state while restoring tokens from storage
|
|
13
|
+
* - 'authenticated': User is signed in with a valid session
|
|
14
|
+
* - 'unauthenticated': No valid session exists
|
|
15
|
+
*/
|
|
16
|
+
type AuthState = 'loading' | 'authenticated' | 'unauthenticated';
|
|
17
|
+
/**
|
|
18
|
+
* Authenticated user information.
|
|
19
|
+
*/
|
|
20
|
+
interface AuthUser {
|
|
21
|
+
/** Unique user identifier */
|
|
22
|
+
id: string;
|
|
23
|
+
/** User email address */
|
|
24
|
+
email: string;
|
|
25
|
+
/** Display name (may be absent if user did not provide one) */
|
|
26
|
+
name: string | null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Configuration for the AuthClient.
|
|
30
|
+
*/
|
|
31
|
+
interface AuthClientConfig {
|
|
32
|
+
/** Base URL of the auth server (e.g. 'http://localhost:3001') */
|
|
33
|
+
serverUrl: string;
|
|
34
|
+
/** Storage key prefix for tokens. Defaults to 'kora_auth' */
|
|
35
|
+
storageKey?: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Client-side authentication manager for Kora.js.
|
|
39
|
+
*
|
|
40
|
+
* Manages token storage, session restoration, sign-up, sign-in, sign-out,
|
|
41
|
+
* token refresh, and auth state change notifications. Framework-agnostic --
|
|
42
|
+
* works in any JavaScript environment with `fetch` and optionally `localStorage`.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```typescript
|
|
46
|
+
* const auth = new AuthClient({ serverUrl: 'http://localhost:3001' })
|
|
47
|
+
* await auth.initialize()
|
|
48
|
+
*
|
|
49
|
+
* if (!auth.isAuthenticated) {
|
|
50
|
+
* await auth.signIn({ email: 'user@example.com', password: 'secret' })
|
|
51
|
+
* }
|
|
52
|
+
*
|
|
53
|
+
* const unsub = auth.onAuthChange((state) => {
|
|
54
|
+
* console.log('Auth state:', state)
|
|
55
|
+
* })
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
declare class AuthClient {
|
|
59
|
+
private readonly serverUrl;
|
|
60
|
+
private readonly storage;
|
|
61
|
+
private readonly listeners;
|
|
62
|
+
private _state;
|
|
63
|
+
private _user;
|
|
64
|
+
private _refreshPromise;
|
|
65
|
+
/**
|
|
66
|
+
* Creates a new AuthClient.
|
|
67
|
+
*
|
|
68
|
+
* @param config - Auth client configuration
|
|
69
|
+
*/
|
|
70
|
+
constructor(config: AuthClientConfig);
|
|
71
|
+
/** Current authentication state. */
|
|
72
|
+
get state(): AuthState;
|
|
73
|
+
/** Current authenticated user, or null if not signed in. */
|
|
74
|
+
get currentUser(): AuthUser | null;
|
|
75
|
+
/** Whether the user is currently authenticated. */
|
|
76
|
+
get isAuthenticated(): boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Initialize the auth client by restoring a session from stored tokens.
|
|
79
|
+
*
|
|
80
|
+
* Loads tokens from storage, validates the access token, and attempts a
|
|
81
|
+
* refresh if the access token is expired but a refresh token is available.
|
|
82
|
+
* Safe to call multiple times -- subsequent calls are no-ops once initialized.
|
|
83
|
+
*/
|
|
84
|
+
initialize(): Promise<void>;
|
|
85
|
+
/**
|
|
86
|
+
* Register a new user account.
|
|
87
|
+
*
|
|
88
|
+
* @param params - Sign-up credentials
|
|
89
|
+
* @returns The newly created AuthUser
|
|
90
|
+
* @throws {AuthError} If the request fails or the server returns an error
|
|
91
|
+
*/
|
|
92
|
+
signUp(params: {
|
|
93
|
+
email: string;
|
|
94
|
+
password: string;
|
|
95
|
+
name?: string;
|
|
96
|
+
}): Promise<AuthUser>;
|
|
97
|
+
/**
|
|
98
|
+
* Sign in with email and password.
|
|
99
|
+
*
|
|
100
|
+
* @param params - Sign-in credentials
|
|
101
|
+
* @returns The authenticated AuthUser
|
|
102
|
+
* @throws {AuthError} If the credentials are invalid or the request fails
|
|
103
|
+
*/
|
|
104
|
+
signIn(params: {
|
|
105
|
+
email: string;
|
|
106
|
+
password: string;
|
|
107
|
+
}): Promise<AuthUser>;
|
|
108
|
+
/**
|
|
109
|
+
* Sign out the current user.
|
|
110
|
+
*
|
|
111
|
+
* Clears local tokens and state. Does not make a network request to the
|
|
112
|
+
* server -- tokens are simply discarded locally.
|
|
113
|
+
*/
|
|
114
|
+
signOut(): Promise<void>;
|
|
115
|
+
/**
|
|
116
|
+
* Get a valid access token, automatically refreshing if expired.
|
|
117
|
+
*
|
|
118
|
+
* @returns A valid access token string, or null if the user is not
|
|
119
|
+
* authenticated and refresh is not possible
|
|
120
|
+
*/
|
|
121
|
+
getAccessToken(): Promise<string | null>;
|
|
122
|
+
/**
|
|
123
|
+
* Get a valid token for the sync engine handshake.
|
|
124
|
+
* Alias for {@link getAccessToken}.
|
|
125
|
+
*
|
|
126
|
+
* @returns A valid access token string, or null if unavailable
|
|
127
|
+
*/
|
|
128
|
+
getSyncToken(): Promise<string | null>;
|
|
129
|
+
/**
|
|
130
|
+
* Subscribe to authentication state changes.
|
|
131
|
+
*
|
|
132
|
+
* The callback is invoked whenever the auth state transitions (e.g., from
|
|
133
|
+
* 'unauthenticated' to 'authenticated' on sign-in).
|
|
134
|
+
*
|
|
135
|
+
* @param callback - Function called with the new AuthState on each change
|
|
136
|
+
* @returns An unsubscribe function that removes the listener
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* ```typescript
|
|
140
|
+
* const unsub = auth.onAuthChange((state) => {
|
|
141
|
+
* console.log('Auth state changed to:', state)
|
|
142
|
+
* })
|
|
143
|
+
* // Later: unsub()
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
146
|
+
onAuthChange(callback: (state: AuthState) => void): () => void;
|
|
147
|
+
/**
|
|
148
|
+
* Update internal state and notify all listeners.
|
|
149
|
+
*/
|
|
150
|
+
private setState;
|
|
151
|
+
/**
|
|
152
|
+
* Restore a session from a valid access token by fetching the user profile.
|
|
153
|
+
* Falls back to extracting the user ID from the token payload if the
|
|
154
|
+
* /auth/me request fails (offline scenario).
|
|
155
|
+
*/
|
|
156
|
+
private restoreSession;
|
|
157
|
+
/**
|
|
158
|
+
* Fetch the current user profile from the server.
|
|
159
|
+
*/
|
|
160
|
+
private fetchUserProfile;
|
|
161
|
+
/**
|
|
162
|
+
* Refresh the access token using a refresh token.
|
|
163
|
+
* De-duplicates concurrent refresh calls so only one network request is made.
|
|
164
|
+
*/
|
|
165
|
+
private refreshAccessToken;
|
|
166
|
+
/**
|
|
167
|
+
* Execute the token refresh network request.
|
|
168
|
+
*/
|
|
169
|
+
private performRefresh;
|
|
170
|
+
/**
|
|
171
|
+
* Make an HTTP request to the auth server.
|
|
172
|
+
*
|
|
173
|
+
* @param path - URL path relative to serverUrl (e.g. '/auth/signin')
|
|
174
|
+
* @param options - Request options
|
|
175
|
+
* @returns Parsed JSON response body
|
|
176
|
+
* @throws {AuthError} On network failure or non-2xx response
|
|
177
|
+
*/
|
|
178
|
+
private request;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export { AuthClient as A, type AuthClientConfig as a, AuthError as b, type AuthState as c, type AuthUser as d };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// src/device/device-identity.ts
|
|
2
|
+
import { KoraError } from "@korajs/core";
|
|
3
|
+
var CryptoUnavailableError = class extends KoraError {
|
|
4
|
+
constructor() {
|
|
5
|
+
super(
|
|
6
|
+
"Web Crypto API (crypto.subtle) is not available in this environment. Device identity requires crypto.subtle, which is available in modern browsers and Node.js 20+. If running in SSR, ensure your runtime provides the Web Crypto API.",
|
|
7
|
+
"CRYPTO_UNAVAILABLE"
|
|
8
|
+
);
|
|
9
|
+
this.name = "CryptoUnavailableError";
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var DeviceIdentityError = class extends KoraError {
|
|
13
|
+
constructor(message, context) {
|
|
14
|
+
super(message, "DEVICE_IDENTITY_ERROR", context);
|
|
15
|
+
this.name = "DeviceIdentityError";
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
function toBase64Url(buffer) {
|
|
19
|
+
const bytes = new Uint8Array(buffer);
|
|
20
|
+
let binary = "";
|
|
21
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
22
|
+
binary += String.fromCharCode(bytes[i]);
|
|
23
|
+
}
|
|
24
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
25
|
+
}
|
|
26
|
+
function fromBase64Url(str) {
|
|
27
|
+
let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
28
|
+
const paddingNeeded = (4 - base64.length % 4) % 4;
|
|
29
|
+
base64 += "=".repeat(paddingNeeded);
|
|
30
|
+
const binary = atob(base64);
|
|
31
|
+
const bytes = new Uint8Array(binary.length);
|
|
32
|
+
for (let i = 0; i < binary.length; i++) {
|
|
33
|
+
bytes[i] = binary.charCodeAt(i);
|
|
34
|
+
}
|
|
35
|
+
return bytes;
|
|
36
|
+
}
|
|
37
|
+
function assertCryptoAvailable() {
|
|
38
|
+
if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
|
|
39
|
+
throw new CryptoUnavailableError();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
var ECDSA_ALGORITHM = {
|
|
43
|
+
name: "ECDSA",
|
|
44
|
+
namedCurve: "P-256"
|
|
45
|
+
};
|
|
46
|
+
var ECDSA_SIGN_ALGORITHM = {
|
|
47
|
+
name: "ECDSA",
|
|
48
|
+
hash: { name: "SHA-256" }
|
|
49
|
+
};
|
|
50
|
+
async function generateDeviceKeyPair() {
|
|
51
|
+
assertCryptoAvailable();
|
|
52
|
+
try {
|
|
53
|
+
const keyPair = await globalThis.crypto.subtle.generateKey(
|
|
54
|
+
ECDSA_ALGORITHM,
|
|
55
|
+
// extractable: false makes the private key non-extractable.
|
|
56
|
+
// The public key is always extractable regardless of this flag.
|
|
57
|
+
false,
|
|
58
|
+
["sign", "verify"]
|
|
59
|
+
);
|
|
60
|
+
return keyPair;
|
|
61
|
+
} catch (cause) {
|
|
62
|
+
throw new DeviceIdentityError(
|
|
63
|
+
"Failed to generate ECDSA P-256 device key pair. Ensure the runtime supports the ECDSA algorithm with the P-256 curve.",
|
|
64
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function exportPublicKeyJwk(keyPair) {
|
|
69
|
+
assertCryptoAvailable();
|
|
70
|
+
try {
|
|
71
|
+
const jwk = await globalThis.crypto.subtle.exportKey("jwk", keyPair.publicKey);
|
|
72
|
+
return jwk;
|
|
73
|
+
} catch (cause) {
|
|
74
|
+
throw new DeviceIdentityError(
|
|
75
|
+
"Failed to export public key as JWK. The key pair may be invalid or the public key may not support JWK export.",
|
|
76
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function signChallenge(privateKey, challenge) {
|
|
81
|
+
assertCryptoAvailable();
|
|
82
|
+
try {
|
|
83
|
+
const encoded = new TextEncoder().encode(challenge);
|
|
84
|
+
const signatureBuffer = await globalThis.crypto.subtle.sign(
|
|
85
|
+
ECDSA_SIGN_ALGORITHM,
|
|
86
|
+
privateKey,
|
|
87
|
+
encoded
|
|
88
|
+
);
|
|
89
|
+
return toBase64Url(signatureBuffer);
|
|
90
|
+
} catch (cause) {
|
|
91
|
+
throw new DeviceIdentityError(
|
|
92
|
+
'Failed to sign challenge. Ensure the key is a valid ECDSA P-256 private key with "sign" usage.',
|
|
93
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function verifyChallenge(publicKeyJwk, challenge, signature) {
|
|
98
|
+
assertCryptoAvailable();
|
|
99
|
+
try {
|
|
100
|
+
const publicKey = await globalThis.crypto.subtle.importKey(
|
|
101
|
+
"jwk",
|
|
102
|
+
publicKeyJwk,
|
|
103
|
+
ECDSA_ALGORITHM,
|
|
104
|
+
true,
|
|
105
|
+
["verify"]
|
|
106
|
+
);
|
|
107
|
+
const encoded = new TextEncoder().encode(challenge);
|
|
108
|
+
const signatureBytes = fromBase64Url(signature);
|
|
109
|
+
const isValid = await globalThis.crypto.subtle.verify(
|
|
110
|
+
ECDSA_SIGN_ALGORITHM,
|
|
111
|
+
publicKey,
|
|
112
|
+
signatureBytes,
|
|
113
|
+
encoded
|
|
114
|
+
);
|
|
115
|
+
return isValid;
|
|
116
|
+
} catch (cause) {
|
|
117
|
+
throw new DeviceIdentityError(
|
|
118
|
+
"Failed to verify challenge signature. The public key JWK or signature format may be invalid.",
|
|
119
|
+
{
|
|
120
|
+
cause: cause instanceof Error ? cause.message : String(cause),
|
|
121
|
+
publicKeyKty: publicKeyJwk.kty,
|
|
122
|
+
publicKeyCrv: publicKeyJwk.crv
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function computePublicKeyThumbprint(publicKeyJwk) {
|
|
128
|
+
assertCryptoAvailable();
|
|
129
|
+
if (publicKeyJwk.kty !== "EC") {
|
|
130
|
+
throw new DeviceIdentityError(
|
|
131
|
+
`Expected JWK key type "EC" but received "${publicKeyJwk.kty ?? "undefined"}". Only ECDSA public keys are supported for device identity.`,
|
|
132
|
+
{ kty: publicKeyJwk.kty }
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (!publicKeyJwk.crv || !publicKeyJwk.x || !publicKeyJwk.y) {
|
|
136
|
+
throw new DeviceIdentityError(
|
|
137
|
+
'JWK is missing required EC fields. An EC public key JWK must include "crv", "x", and "y" members.',
|
|
138
|
+
{
|
|
139
|
+
hasCrv: Boolean(publicKeyJwk.crv),
|
|
140
|
+
hasX: Boolean(publicKeyJwk.x),
|
|
141
|
+
hasY: Boolean(publicKeyJwk.y)
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const canonicalJson = JSON.stringify({
|
|
146
|
+
crv: publicKeyJwk.crv,
|
|
147
|
+
kty: publicKeyJwk.kty,
|
|
148
|
+
x: publicKeyJwk.x,
|
|
149
|
+
y: publicKeyJwk.y
|
|
150
|
+
});
|
|
151
|
+
try {
|
|
152
|
+
const encoded = new TextEncoder().encode(canonicalJson);
|
|
153
|
+
const hashBuffer = await globalThis.crypto.subtle.digest("SHA-256", encoded);
|
|
154
|
+
return toBase64Url(hashBuffer);
|
|
155
|
+
} catch (cause) {
|
|
156
|
+
throw new DeviceIdentityError(
|
|
157
|
+
"Failed to compute SHA-256 thumbprint of the public key JWK.",
|
|
158
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export {
|
|
164
|
+
CryptoUnavailableError,
|
|
165
|
+
DeviceIdentityError,
|
|
166
|
+
toBase64Url,
|
|
167
|
+
fromBase64Url,
|
|
168
|
+
generateDeviceKeyPair,
|
|
169
|
+
exportPublicKeyJwk,
|
|
170
|
+
signChallenge,
|
|
171
|
+
verifyChallenge,
|
|
172
|
+
computePublicKeyThumbprint
|
|
173
|
+
};
|
|
174
|
+
//# sourceMappingURL=chunk-L554ZDPY.js.map
|