@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.14
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 +2880 -1667
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +810 -169
- package/dist/server.d.ts +810 -169
- package/dist/server.js +2848 -1646
- 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 +365 -0
- package/src/provider/built-in/quickstart-server.ts +760 -0
- package/src/provider/built-in/sqlite-user-store.ts +335 -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 +285 -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 +338 -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,760 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { KoraError } from '@korajs/core'
|
|
3
|
+
import {
|
|
4
|
+
InMemoryTokenRevocationStore,
|
|
5
|
+
TokenManager,
|
|
6
|
+
type TokenManagerConfig,
|
|
7
|
+
type TokenRevocationStore,
|
|
8
|
+
} from '../../tokens/token-manager'
|
|
9
|
+
import type { AuthTokens } from '../../types'
|
|
10
|
+
import {
|
|
11
|
+
DuplicateLinkedIdentityError,
|
|
12
|
+
InMemoryLinkedIdentityStore,
|
|
13
|
+
type LinkedIdentityStore,
|
|
14
|
+
} from '../oauth/linked-identity-store'
|
|
15
|
+
import { OAuthManager, type OAuthManagerConfig, generateOAuthBinding } from '../oauth/oauth-flow'
|
|
16
|
+
import {
|
|
17
|
+
type LinkedIdentity,
|
|
18
|
+
OAuthError,
|
|
19
|
+
type OAuthProviderConfig,
|
|
20
|
+
type OAuthUserInfo,
|
|
21
|
+
} from '../oauth/oauth-types'
|
|
22
|
+
import {
|
|
23
|
+
type AuthRevocationEvent,
|
|
24
|
+
type AuthRouteResponse,
|
|
25
|
+
type AuthRoutesConfig,
|
|
26
|
+
BuiltInAuthRoutes,
|
|
27
|
+
type ChallengeStore,
|
|
28
|
+
type MfaVerifier,
|
|
29
|
+
type RateLimiter,
|
|
30
|
+
type SignInResult,
|
|
31
|
+
} from './auth-routes'
|
|
32
|
+
import { hashPassword } from './password-hash'
|
|
33
|
+
import type { SyncAuthProvider, SyncScopeOptions } from './sync-scopes'
|
|
34
|
+
import {
|
|
35
|
+
type AuthUser,
|
|
36
|
+
DeviceOwnershipError,
|
|
37
|
+
InMemoryUserStore,
|
|
38
|
+
type StoredUser,
|
|
39
|
+
type UserStore,
|
|
40
|
+
} from './user-store'
|
|
41
|
+
|
|
42
|
+
type SignUpBody = Parameters<BuiltInAuthRoutes['handleSignUp']>[0]
|
|
43
|
+
type SignInBody = Parameters<BuiltInAuthRoutes['handleSignIn']>[0]
|
|
44
|
+
type RefreshBody = Parameters<BuiltInAuthRoutes['handleRefresh']>[0]
|
|
45
|
+
type SignOutBody = Parameters<BuiltInAuthRoutes['handleSignOut']>[1]
|
|
46
|
+
type DeviceRegisterBody = Parameters<BuiltInAuthRoutes['handleDeviceRegister']>[1]
|
|
47
|
+
type DeviceVerifyBody = Parameters<BuiltInAuthRoutes['handleDeviceVerify']>[0]
|
|
48
|
+
|
|
49
|
+
export interface KoraAuthHttpRequest {
|
|
50
|
+
method: string
|
|
51
|
+
path: string
|
|
52
|
+
body?: unknown
|
|
53
|
+
headers?: Record<string, string | string[] | undefined>
|
|
54
|
+
query?: Record<string, string | string[] | undefined>
|
|
55
|
+
ip?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface OAuthServerConfig extends Omit<OAuthManagerConfig, 'providers'> {
|
|
59
|
+
providers: OAuthProviderConfig[]
|
|
60
|
+
linkedIdentityStore?: LinkedIdentityStore
|
|
61
|
+
/** Create a Kora user on first OAuth sign-in. Defaults to true. */
|
|
62
|
+
createNewUsers?: boolean
|
|
63
|
+
/**
|
|
64
|
+
* Link OAuth identities to an existing Kora user with the same verified email.
|
|
65
|
+
* Defaults to false so apps choose this trust boundary explicitly.
|
|
66
|
+
*/
|
|
67
|
+
autoLinkVerifiedEmail?: boolean
|
|
68
|
+
/**
|
|
69
|
+
* Allow a user to unlink their last OAuth identity. Defaults to false to avoid
|
|
70
|
+
* locking out OAuth-created accounts that do not have a known password.
|
|
71
|
+
*/
|
|
72
|
+
allowUnlinkLastIdentity?: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface CreateKoraAuthServerOptions extends SyncScopeOptions {
|
|
76
|
+
/** Existing user store. Defaults to InMemoryUserStore for development. */
|
|
77
|
+
userStore?: UserStore
|
|
78
|
+
/**
|
|
79
|
+
* Token revocation store. Defaults to the user store's own revocation store
|
|
80
|
+
* (`userStore.getTokenRevocationStore()`), so revocations persist and are shared
|
|
81
|
+
* exactly as far as the users are.
|
|
82
|
+
*/
|
|
83
|
+
revocationStore?: TokenRevocationStore
|
|
84
|
+
/**
|
|
85
|
+
* Allow in-memory user or revocation stores when `NODE_ENV=production`.
|
|
86
|
+
* In-memory stores lose every account and every revocation on restart and are
|
|
87
|
+
* not shared between instances, so production refuses them by default.
|
|
88
|
+
*/
|
|
89
|
+
allowInMemory?: boolean
|
|
90
|
+
/** Existing token manager. Overrides `jwtSecret` and `tokenManager` options. */
|
|
91
|
+
tokenManager?: TokenManager
|
|
92
|
+
/** JWT secret. Required in production when `tokenManager` is not provided. */
|
|
93
|
+
jwtSecret?: string | string[]
|
|
94
|
+
/** Additional TokenManager options. */
|
|
95
|
+
tokenManagerOptions?: Omit<TokenManagerConfig, 'secret'>
|
|
96
|
+
/** Auth HTTP path prefix. Defaults to `/auth`. */
|
|
97
|
+
path?: string
|
|
98
|
+
/** OAuth provider routes and account-linking storage. */
|
|
99
|
+
oauth?: OAuthServerConfig
|
|
100
|
+
challengeStore?: ChallengeStore
|
|
101
|
+
rateLimiter?: RateLimiter
|
|
102
|
+
/**
|
|
103
|
+
* Second-factor verifier (for example a `TotpManager`). Users with MFA enabled
|
|
104
|
+
* then sign in in two steps: `{ mfaRequired, mfaToken }`, then
|
|
105
|
+
* `POST /auth/mfa/verify` with `{ mfaToken, code }`.
|
|
106
|
+
*/
|
|
107
|
+
mfa?: MfaVerifier
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The part of `KoraSyncServer` the auth server needs to end live sessions.
|
|
112
|
+
* Structural, so `@korajs/auth` does not depend on `@korajs/server`.
|
|
113
|
+
*/
|
|
114
|
+
export interface SyncSessionTerminator {
|
|
115
|
+
terminateSessions(filter: { userId?: string; deviceId?: string }): unknown
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface KoraAuthServer {
|
|
119
|
+
routes: BuiltInAuthRoutes
|
|
120
|
+
userStore: UserStore
|
|
121
|
+
tokenManager: TokenManager
|
|
122
|
+
oauth?: OAuthManager
|
|
123
|
+
linkedIdentityStore?: LinkedIdentityStore
|
|
124
|
+
/** Sync auth provider with server-derived scopes (pass to `KoraSyncServer`). */
|
|
125
|
+
auth: SyncAuthProvider
|
|
126
|
+
handleRequest(request: KoraAuthHttpRequest): Promise<AuthRouteResponse<unknown>>
|
|
127
|
+
/**
|
|
128
|
+
* Revoke every credential a user holds (call after a password change made
|
|
129
|
+
* outside these routes, an admin action or an account deletion).
|
|
130
|
+
*/
|
|
131
|
+
revokeAllForUser(userId: string): Promise<void>
|
|
132
|
+
/** Subscribe to credential revocations. Returns an unsubscribe function. */
|
|
133
|
+
onRevoke(listener: (event: AuthRevocationEvent) => void | Promise<void>): () => void
|
|
134
|
+
/**
|
|
135
|
+
* End live sync sessions when their credentials are revoked (AUTH-11).
|
|
136
|
+
* Requires a sync server exposing `terminateSessions({ userId, deviceId })`.
|
|
137
|
+
* A `KoraSyncServer` constructed with this server's `auth` provider already
|
|
138
|
+
* follows its revocations; call this for a server built with a wrapping or
|
|
139
|
+
* custom provider. Binding both is harmless. Returns an unsubscribe function.
|
|
140
|
+
*/
|
|
141
|
+
bindSyncServer(server: SyncSessionTerminator): () => void
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Thrown by `createKoraAuthServer` in production when a store would silently
|
|
146
|
+
* lose accounts or revocations on restart.
|
|
147
|
+
*/
|
|
148
|
+
export class InMemoryAuthStoreError extends KoraError {
|
|
149
|
+
constructor(which: 'userStore' | 'revocationStore') {
|
|
150
|
+
super(
|
|
151
|
+
`createKoraAuthServer refuses an in-memory ${which} in production: it loses every ${
|
|
152
|
+
which === 'userStore' ? 'account' : 'sign-out and revocation'
|
|
153
|
+
} on restart and is not shared between instances.`,
|
|
154
|
+
'IN_MEMORY_AUTH_STORE',
|
|
155
|
+
{
|
|
156
|
+
which,
|
|
157
|
+
fix: 'Pass a persistent store (createSqliteUserStore / createPostgresUserStore, whose getTokenRevocationStore() is used automatically), or set allowInMemory: true for a throwaway deployment.',
|
|
158
|
+
},
|
|
159
|
+
)
|
|
160
|
+
this.name = 'InMemoryAuthStoreError'
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface OAuthServerRuntime {
|
|
165
|
+
manager: OAuthManager
|
|
166
|
+
linkedIdentityStore: LinkedIdentityStore
|
|
167
|
+
createNewUsers: boolean
|
|
168
|
+
autoLinkVerifiedEmail: boolean
|
|
169
|
+
allowUnlinkLastIdentity: boolean
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Create the built-in Kora auth server with production-shaped defaults.
|
|
174
|
+
*
|
|
175
|
+
* Simple apps can use `handleRequest()` for all `/auth/*` HTTP endpoints and
|
|
176
|
+
* pass `auth` directly to `createProductionServer({ syncOptions: { auth } })`.
|
|
177
|
+
*/
|
|
178
|
+
export function createKoraAuthServer(options: CreateKoraAuthServerOptions = {}): KoraAuthServer {
|
|
179
|
+
const userStore = options.userStore ?? new InMemoryUserStore()
|
|
180
|
+
const revocationStore =
|
|
181
|
+
options.revocationStore ??
|
|
182
|
+
options.tokenManagerOptions?.revocationStore ??
|
|
183
|
+
userStore.getTokenRevocationStore?.() ??
|
|
184
|
+
new InMemoryTokenRevocationStore()
|
|
185
|
+
const tokenManager = options.tokenManager ?? createDefaultTokenManager(options, revocationStore)
|
|
186
|
+
if (isProduction() && !options.allowInMemory) {
|
|
187
|
+
if (userStore instanceof InMemoryUserStore) throw new InMemoryAuthStoreError('userStore')
|
|
188
|
+
const effectiveRevocation = tokenManager.getRevocationStore()
|
|
189
|
+
if (!effectiveRevocation || effectiveRevocation instanceof InMemoryTokenRevocationStore) {
|
|
190
|
+
throw new InMemoryAuthStoreError('revocationStore')
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const routes = new BuiltInAuthRoutes({
|
|
194
|
+
userStore,
|
|
195
|
+
tokenManager,
|
|
196
|
+
challengeStore: options.challengeStore,
|
|
197
|
+
rateLimiter: options.rateLimiter,
|
|
198
|
+
mfa: options.mfa,
|
|
199
|
+
})
|
|
200
|
+
const oauth = options.oauth ? createOAuthRuntime(options.oauth) : undefined
|
|
201
|
+
const path = normalizePath(options.path ?? '/auth')
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
routes,
|
|
205
|
+
userStore,
|
|
206
|
+
tokenManager,
|
|
207
|
+
oauth: oauth?.manager,
|
|
208
|
+
linkedIdentityStore: oauth?.linkedIdentityStore,
|
|
209
|
+
auth: routes.toSyncAuthProvider({
|
|
210
|
+
scopeValues: options.scopeValues,
|
|
211
|
+
resolveScopes: options.resolveScopes,
|
|
212
|
+
}),
|
|
213
|
+
handleRequest(request) {
|
|
214
|
+
return handleAuthRequest(routes, path, request, oauth, userStore, tokenManager)
|
|
215
|
+
},
|
|
216
|
+
revokeAllForUser(userId) {
|
|
217
|
+
return routes.revokeAllForUser(userId)
|
|
218
|
+
},
|
|
219
|
+
onRevoke(listener) {
|
|
220
|
+
return routes.onRevoke(listener)
|
|
221
|
+
},
|
|
222
|
+
bindSyncServer(server) {
|
|
223
|
+
return routes.onRevoke((event) => {
|
|
224
|
+
if (event.kind === 'user') {
|
|
225
|
+
server.terminateSessions({ userId: event.userId })
|
|
226
|
+
} else {
|
|
227
|
+
server.terminateSessions({ userId: event.userId, deviceId: event.deviceId })
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function createDefaultTokenManager(
|
|
235
|
+
options: CreateKoraAuthServerOptions,
|
|
236
|
+
revocationStore: TokenRevocationStore,
|
|
237
|
+
): TokenManager {
|
|
238
|
+
const secret = options.jwtSecret ?? readEnvSecret()
|
|
239
|
+
if (!secret && isProduction()) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
'createKoraAuthServer requires jwtSecret in production. Set KORA_AUTH_SECRET or pass jwtSecret.',
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!secret) {
|
|
246
|
+
// Outside production we fall back to an ephemeral random secret so local
|
|
247
|
+
// development works with zero setup. Warn loudly: this secret is
|
|
248
|
+
// regenerated on every process start, so every previously issued token is
|
|
249
|
+
// silently invalidated on restart. That is a confusing failure mode if it
|
|
250
|
+
// ever reaches a deployed environment where NODE_ENV was simply never set
|
|
251
|
+
// to "production" — the production guard above only fires when NODE_ENV
|
|
252
|
+
// explicitly equals "production".
|
|
253
|
+
console.warn(
|
|
254
|
+
'[kora] No JWT secret configured; using an ephemeral random secret. ' +
|
|
255
|
+
'Every token is invalidated when the process restarts. ' +
|
|
256
|
+
'Set KORA_AUTH_SECRET or pass jwtSecret to createKoraAuthServer for stable sessions.',
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return new TokenManager({
|
|
261
|
+
secret: secret ?? TokenManager.generateSecret(),
|
|
262
|
+
...options.tokenManagerOptions,
|
|
263
|
+
revocationStore,
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function createOAuthRuntime(config: OAuthServerConfig): OAuthServerRuntime {
|
|
268
|
+
return {
|
|
269
|
+
manager: new OAuthManager(config),
|
|
270
|
+
linkedIdentityStore: config.linkedIdentityStore ?? new InMemoryLinkedIdentityStore(),
|
|
271
|
+
createNewUsers: config.createNewUsers ?? true,
|
|
272
|
+
autoLinkVerifiedEmail: config.autoLinkVerifiedEmail ?? false,
|
|
273
|
+
allowUnlinkLastIdentity: config.allowUnlinkLastIdentity ?? false,
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function handleAuthRequest(
|
|
278
|
+
routes: BuiltInAuthRoutes,
|
|
279
|
+
pathPrefix: string,
|
|
280
|
+
request: KoraAuthHttpRequest,
|
|
281
|
+
oauth: OAuthServerRuntime | undefined,
|
|
282
|
+
userStore: UserStore,
|
|
283
|
+
tokenManager: TokenManager,
|
|
284
|
+
): Promise<AuthRouteResponse<unknown>> {
|
|
285
|
+
const path = normalizePath(request.path)
|
|
286
|
+
const relativePath = path === pathPrefix ? '/' : path.slice(pathPrefix.length)
|
|
287
|
+
const method = request.method.toUpperCase()
|
|
288
|
+
const body = isRecord(request.body) ? request.body : {}
|
|
289
|
+
const token = extractBearerToken(request.headers)
|
|
290
|
+
|
|
291
|
+
if (path !== pathPrefix && !path.startsWith(`${pathPrefix}/`)) {
|
|
292
|
+
return notFound()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (relativePath.startsWith('/oauth/')) {
|
|
296
|
+
return handleOAuthRequest({
|
|
297
|
+
oauth,
|
|
298
|
+
routes,
|
|
299
|
+
userStore,
|
|
300
|
+
tokenManager,
|
|
301
|
+
relativePath,
|
|
302
|
+
method,
|
|
303
|
+
body,
|
|
304
|
+
query: request.query,
|
|
305
|
+
token,
|
|
306
|
+
cookieBinding: readCookie(request.headers, OAUTH_BINDING_COOKIE),
|
|
307
|
+
pathPrefix,
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (method === 'POST' && relativePath === '/signup') {
|
|
312
|
+
return routes.handleSignUp(body as SignUpBody, request.ip)
|
|
313
|
+
}
|
|
314
|
+
if (method === 'POST' && relativePath === '/signin') {
|
|
315
|
+
return routes.handleSignIn(body as SignInBody, request.ip)
|
|
316
|
+
}
|
|
317
|
+
if (method === 'POST' && relativePath === '/mfa/verify') {
|
|
318
|
+
return routes.handleMfaVerify(body)
|
|
319
|
+
}
|
|
320
|
+
if (method === 'POST' && relativePath === '/refresh') {
|
|
321
|
+
return routes.handleRefresh(body as RefreshBody)
|
|
322
|
+
}
|
|
323
|
+
if (method === 'POST' && relativePath === '/signout') {
|
|
324
|
+
return routes.handleSignOut(token, body as SignOutBody)
|
|
325
|
+
}
|
|
326
|
+
if (method === 'GET' && relativePath === '/me') {
|
|
327
|
+
return routes.handleGetMe(token)
|
|
328
|
+
}
|
|
329
|
+
if (method === 'GET' && relativePath === '/devices') {
|
|
330
|
+
return routes.handleListDevices(token)
|
|
331
|
+
}
|
|
332
|
+
if (method === 'POST' && relativePath === '/device/register') {
|
|
333
|
+
return routes.handleDeviceRegister(token, body as DeviceRegisterBody)
|
|
334
|
+
}
|
|
335
|
+
if (method === 'POST' && relativePath === '/device/challenge') {
|
|
336
|
+
const deviceId = typeof body.deviceId === 'string' ? body.deviceId : ''
|
|
337
|
+
return routes.handleDeviceChallenge(token, deviceId)
|
|
338
|
+
}
|
|
339
|
+
if (method === 'POST' && relativePath === '/device/verify') {
|
|
340
|
+
return routes.handleDeviceVerify(body as DeviceVerifyBody)
|
|
341
|
+
}
|
|
342
|
+
if (method === 'DELETE' && relativePath.startsWith('/device/')) {
|
|
343
|
+
return routes.handleRevokeDevice(token, relativePath.slice('/device/'.length))
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return notFound()
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function handleOAuthRequest(params: {
|
|
350
|
+
oauth: OAuthServerRuntime | undefined
|
|
351
|
+
routes: BuiltInAuthRoutes
|
|
352
|
+
userStore: UserStore
|
|
353
|
+
tokenManager: TokenManager
|
|
354
|
+
relativePath: string
|
|
355
|
+
method: string
|
|
356
|
+
body: Record<string, unknown>
|
|
357
|
+
query: KoraAuthHttpRequest['query']
|
|
358
|
+
token: string
|
|
359
|
+
cookieBinding: string | undefined
|
|
360
|
+
pathPrefix: string
|
|
361
|
+
}): Promise<AuthRouteResponse<unknown>> {
|
|
362
|
+
const {
|
|
363
|
+
oauth,
|
|
364
|
+
routes,
|
|
365
|
+
userStore,
|
|
366
|
+
tokenManager,
|
|
367
|
+
relativePath,
|
|
368
|
+
method,
|
|
369
|
+
body,
|
|
370
|
+
query,
|
|
371
|
+
token,
|
|
372
|
+
cookieBinding,
|
|
373
|
+
pathPrefix,
|
|
374
|
+
} = params
|
|
375
|
+
if (!oauth) {
|
|
376
|
+
return notFound()
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
if (method === 'GET' && relativePath === '/oauth/links') {
|
|
381
|
+
const authUser = await requireAuthUser(routes, token)
|
|
382
|
+
if ('status' in authUser) return authUser
|
|
383
|
+
const identities = await oauth.linkedIdentityStore.findByUser(authUser.id)
|
|
384
|
+
return { status: 200, body: { data: identities } }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const match = /^\/oauth\/([^/]+)(?:\/(callback|link|link\/start))?$/.exec(relativePath)
|
|
388
|
+
if (!match) {
|
|
389
|
+
return notFound()
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const provider = decodeURIComponent(match[1] as string)
|
|
393
|
+
const action = match[2]
|
|
394
|
+
|
|
395
|
+
// Every flow is bound to its purpose and to the initiating client (AUTH-3):
|
|
396
|
+
// a random binding goes to the client as an HttpOnly cookie (web) and in the
|
|
397
|
+
// body (native/PKCE apps keep it in memory); only its hash is stored.
|
|
398
|
+
if (method === 'GET' && !action) {
|
|
399
|
+
const binding = generateOAuthBinding()
|
|
400
|
+
const { url, state } = await oauth.manager.getAuthorizationUrl(
|
|
401
|
+
provider,
|
|
402
|
+
metadataFromQuery(query),
|
|
403
|
+
{ purpose: 'signin', binding },
|
|
404
|
+
)
|
|
405
|
+
return flowStarted(url, state, binding, pathPrefix)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (method === 'POST' && action === 'link/start') {
|
|
409
|
+
const authUser = await requireAuthUser(routes, token)
|
|
410
|
+
if ('status' in authUser) return authUser
|
|
411
|
+
const binding = generateOAuthBinding()
|
|
412
|
+
const { url, state } = await oauth.manager.getAuthorizationUrl(provider, undefined, {
|
|
413
|
+
purpose: 'link',
|
|
414
|
+
userId: authUser.id,
|
|
415
|
+
binding,
|
|
416
|
+
})
|
|
417
|
+
return flowStarted(url, state, binding, pathPrefix)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if ((method === 'GET' || method === 'POST') && action === 'callback') {
|
|
421
|
+
const code = readString(method === 'GET' ? queryValue(query, 'code') : body.code)
|
|
422
|
+
const state = readString(method === 'GET' ? queryValue(query, 'state') : body.state)
|
|
423
|
+
if (!code || !state) {
|
|
424
|
+
return { status: 400, body: { error: 'OAuth callback requires code and state.' } }
|
|
425
|
+
}
|
|
426
|
+
return await completeOAuthSignIn({
|
|
427
|
+
oauth,
|
|
428
|
+
routes,
|
|
429
|
+
userStore,
|
|
430
|
+
provider,
|
|
431
|
+
code,
|
|
432
|
+
state,
|
|
433
|
+
binding: readString(body.binding) ?? cookieBinding,
|
|
434
|
+
deviceId: readString(body.deviceId),
|
|
435
|
+
devicePublicKey: readString(body.devicePublicKey),
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (method === 'POST' && action === 'link') {
|
|
440
|
+
const authUser = await requireAuthUser(routes, token)
|
|
441
|
+
if ('status' in authUser) return authUser
|
|
442
|
+
const code = readString(body.code)
|
|
443
|
+
const state = readString(body.state)
|
|
444
|
+
if (!code || !state) {
|
|
445
|
+
return { status: 400, body: { error: 'OAuth linking requires code and state.' } }
|
|
446
|
+
}
|
|
447
|
+
return await linkOAuthIdentity(
|
|
448
|
+
oauth,
|
|
449
|
+
authUser.id,
|
|
450
|
+
provider,
|
|
451
|
+
code,
|
|
452
|
+
state,
|
|
453
|
+
readString(body.binding) ?? cookieBinding,
|
|
454
|
+
)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (method === 'DELETE' && action === 'link') {
|
|
458
|
+
const authUser = await requireAuthUser(routes, token)
|
|
459
|
+
if ('status' in authUser) return authUser
|
|
460
|
+
const identities = await oauth.linkedIdentityStore.findByUser(authUser.id)
|
|
461
|
+
if (!oauth.allowUnlinkLastIdentity && identities.length <= 1) {
|
|
462
|
+
return {
|
|
463
|
+
status: 409,
|
|
464
|
+
body: {
|
|
465
|
+
error:
|
|
466
|
+
'Cannot unlink the last OAuth identity unless allowUnlinkLastIdentity is enabled.',
|
|
467
|
+
},
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
await oauth.linkedIdentityStore.delete(authUser.id, provider)
|
|
471
|
+
return { status: 200, body: { data: { ok: true } } }
|
|
472
|
+
}
|
|
473
|
+
} catch (error) {
|
|
474
|
+
return oauthErrorResponse(error)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return notFound()
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function completeOAuthSignIn(params: {
|
|
481
|
+
oauth: OAuthServerRuntime
|
|
482
|
+
routes: BuiltInAuthRoutes
|
|
483
|
+
userStore: UserStore
|
|
484
|
+
provider: string
|
|
485
|
+
code: string
|
|
486
|
+
state: string
|
|
487
|
+
binding: string | undefined
|
|
488
|
+
deviceId?: string
|
|
489
|
+
devicePublicKey?: string
|
|
490
|
+
}): Promise<AuthRouteResponse<SignInResult & { identity: LinkedIdentity }>> {
|
|
491
|
+
const { oauth, routes, userStore, provider, code, state, deviceId, devicePublicKey } = params
|
|
492
|
+
const { userInfo } = await oauth.manager.handleCallback(provider, code, state, {
|
|
493
|
+
purpose: 'signin',
|
|
494
|
+
binding: params.binding,
|
|
495
|
+
})
|
|
496
|
+
const linkedIdentity = await oauth.linkedIdentityStore.findByProvider(
|
|
497
|
+
userInfo.provider,
|
|
498
|
+
userInfo.providerId,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
let user: AuthUser
|
|
502
|
+
let identity: LinkedIdentity
|
|
503
|
+
if (linkedIdentity) {
|
|
504
|
+
const storedUser = await userStore.findById(linkedIdentity.userId)
|
|
505
|
+
if (!storedUser) {
|
|
506
|
+
return { status: 409, body: { error: 'Linked OAuth account has no matching user.' } }
|
|
507
|
+
}
|
|
508
|
+
user = toAuthUser(storedUser)
|
|
509
|
+
identity = linkedIdentity
|
|
510
|
+
} else {
|
|
511
|
+
const resolved = await resolveOAuthUser(userStore, oauth, userInfo)
|
|
512
|
+
if ('status' in resolved) return resolved
|
|
513
|
+
user = resolved
|
|
514
|
+
identity = await oauth.linkedIdentityStore.create({
|
|
515
|
+
userId: user.id,
|
|
516
|
+
provider: userInfo.provider,
|
|
517
|
+
providerUserId: userInfo.providerId,
|
|
518
|
+
email: userInfo.email,
|
|
519
|
+
})
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Never trust a device id carried in OAuth state metadata (it comes from the
|
|
523
|
+
// query string of whoever started the flow), and never default to a per-user
|
|
524
|
+
// id that every browser of the user would share (AUTH-3, AUTH-5).
|
|
525
|
+
const resolvedDeviceId = deviceId ?? `dev-${randomUUID()}`
|
|
526
|
+
try {
|
|
527
|
+
await userStore.registerDevice({
|
|
528
|
+
id: resolvedDeviceId,
|
|
529
|
+
userId: user.id,
|
|
530
|
+
publicKey: devicePublicKey ?? '',
|
|
531
|
+
name: deviceId ? 'Device' : 'Browser',
|
|
532
|
+
})
|
|
533
|
+
} catch (error) {
|
|
534
|
+
if (error instanceof DeviceOwnershipError) {
|
|
535
|
+
return {
|
|
536
|
+
status: 409,
|
|
537
|
+
body: {
|
|
538
|
+
error: 'This device id is registered to another account.',
|
|
539
|
+
code: 'DEVICE_OWNERSHIP_CONFLICT',
|
|
540
|
+
},
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
throw error
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Same rule as password sign-in: an MFA user gets a challenge, not tokens.
|
|
547
|
+
const result = await routes.completePrimaryAuthentication(user, resolvedDeviceId, ['oauth'])
|
|
548
|
+
if (!('data' in result.body)) return result as AuthRouteResponse<never>
|
|
549
|
+
return { status: result.status, body: { data: { ...result.body.data, identity } } }
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function resolveOAuthUser(
|
|
553
|
+
userStore: UserStore,
|
|
554
|
+
oauth: OAuthServerRuntime,
|
|
555
|
+
userInfo: OAuthUserInfo,
|
|
556
|
+
): Promise<AuthUser | AuthRouteResponse<never>> {
|
|
557
|
+
if (!userInfo.email) {
|
|
558
|
+
return { status: 400, body: { error: 'OAuth provider did not return an email address.' } }
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const existingUser = await userStore.findByEmail(userInfo.email)
|
|
562
|
+
if (existingUser) {
|
|
563
|
+
if (oauth.autoLinkVerifiedEmail && userInfo.emailVerified) {
|
|
564
|
+
return toAuthUser(existingUser)
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
status: 409,
|
|
568
|
+
body: { error: 'OAuth account is not linked. Sign in and link this provider first.' },
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (!oauth.createNewUsers) {
|
|
573
|
+
return { status: 403, body: { error: 'OAuth sign-up is disabled for this application.' } }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const credential = await hashPassword(randomUUID())
|
|
577
|
+
const user = await userStore.createUser({
|
|
578
|
+
email: userInfo.email,
|
|
579
|
+
passwordHash: credential.hash,
|
|
580
|
+
salt: credential.salt,
|
|
581
|
+
name: userInfo.name ?? userInfo.email.split('@')[0] ?? userInfo.email,
|
|
582
|
+
})
|
|
583
|
+
if (userInfo.emailVerified) {
|
|
584
|
+
await userStore.setEmailVerified(user.id, true)
|
|
585
|
+
return { ...user, emailVerified: true }
|
|
586
|
+
}
|
|
587
|
+
return user
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function linkOAuthIdentity(
|
|
591
|
+
oauth: OAuthServerRuntime,
|
|
592
|
+
userId: string,
|
|
593
|
+
provider: string,
|
|
594
|
+
code: string,
|
|
595
|
+
state: string,
|
|
596
|
+
binding: string | undefined,
|
|
597
|
+
): Promise<AuthRouteResponse<LinkedIdentity>> {
|
|
598
|
+
const { userInfo } = await oauth.manager.handleCallback(provider, code, state, {
|
|
599
|
+
purpose: 'link',
|
|
600
|
+
userId,
|
|
601
|
+
binding,
|
|
602
|
+
})
|
|
603
|
+
const existing = await oauth.linkedIdentityStore.findByProvider(
|
|
604
|
+
userInfo.provider,
|
|
605
|
+
userInfo.providerId,
|
|
606
|
+
)
|
|
607
|
+
if (existing && existing.userId !== userId) {
|
|
608
|
+
return { status: 409, body: { error: 'This OAuth account is already linked to another user.' } }
|
|
609
|
+
}
|
|
610
|
+
if (existing) {
|
|
611
|
+
return { status: 200, body: { data: existing } }
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const identity = await oauth.linkedIdentityStore.create({
|
|
615
|
+
userId,
|
|
616
|
+
provider: userInfo.provider,
|
|
617
|
+
providerUserId: userInfo.providerId,
|
|
618
|
+
email: userInfo.email,
|
|
619
|
+
})
|
|
620
|
+
return { status: 201, body: { data: identity } }
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function requireAuthUser(
|
|
624
|
+
routes: BuiltInAuthRoutes,
|
|
625
|
+
token: string,
|
|
626
|
+
): Promise<AuthUser | AuthRouteResponse<never>> {
|
|
627
|
+
if (!token) {
|
|
628
|
+
return {
|
|
629
|
+
status: 401,
|
|
630
|
+
body: { error: 'Authorization token required.', code: 'ACCESS_TOKEN_REQUIRED' },
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
// The same revocation-aware check every route uses (AUTH-2, AUTH-8).
|
|
634
|
+
const access = await routes.authenticateAccess(token)
|
|
635
|
+
if (!access) {
|
|
636
|
+
return {
|
|
637
|
+
status: 401,
|
|
638
|
+
body: { error: 'Invalid or expired token.', code: 'ACCESS_TOKEN_INVALID' },
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return toAuthUser(access.user)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const OAUTH_BINDING_COOKIE = 'kora_oauth_binding'
|
|
645
|
+
|
|
646
|
+
function flowStarted(
|
|
647
|
+
url: string,
|
|
648
|
+
state: string,
|
|
649
|
+
binding: string,
|
|
650
|
+
pathPrefix: string,
|
|
651
|
+
): AuthRouteResponse<{ url: string; state: string; binding: string }> {
|
|
652
|
+
return {
|
|
653
|
+
status: 200,
|
|
654
|
+
body: { data: { url, state, binding } },
|
|
655
|
+
headers: {
|
|
656
|
+
'Set-Cookie': `${OAUTH_BINDING_COOKIE}=${binding}; Path=${pathPrefix}/oauth; Max-Age=600; HttpOnly; Secure; SameSite=Lax`,
|
|
657
|
+
},
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function readCookie(headers: KoraAuthHttpRequest['headers'], name: string): string | undefined {
|
|
662
|
+
const raw = headers?.cookie ?? headers?.Cookie
|
|
663
|
+
const value = Array.isArray(raw) ? raw.join('; ') : raw
|
|
664
|
+
if (!value) return undefined
|
|
665
|
+
for (const part of value.split(';')) {
|
|
666
|
+
const [key, ...rest] = part.trim().split('=')
|
|
667
|
+
if (key === name) return rest.join('=')
|
|
668
|
+
}
|
|
669
|
+
return undefined
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function extractBearerToken(headers: KoraAuthHttpRequest['headers']): string {
|
|
673
|
+
const authorization = headers?.authorization ?? headers?.Authorization
|
|
674
|
+
const value = Array.isArray(authorization) ? authorization[0] : authorization
|
|
675
|
+
if (!value?.startsWith('Bearer ')) {
|
|
676
|
+
return ''
|
|
677
|
+
}
|
|
678
|
+
return value.slice('Bearer '.length).trim()
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function normalizePath(path: string): string {
|
|
682
|
+
const withoutQuery = path.split('?')[0] || '/'
|
|
683
|
+
const normalized = withoutQuery.startsWith('/') ? withoutQuery : `/${withoutQuery}`
|
|
684
|
+
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function metadataFromQuery(
|
|
688
|
+
query: KoraAuthHttpRequest['query'],
|
|
689
|
+
): Record<string, unknown> | undefined {
|
|
690
|
+
if (!query) return undefined
|
|
691
|
+
const metadata: Record<string, unknown> = {}
|
|
692
|
+
for (const [key, value] of Object.entries(query)) {
|
|
693
|
+
if (key === 'code' || key === 'state') continue
|
|
694
|
+
if (value !== undefined) {
|
|
695
|
+
metadata[key] = value
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return Object.keys(metadata).length > 0 ? metadata : undefined
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function queryValue(
|
|
702
|
+
query: KoraAuthHttpRequest['query'],
|
|
703
|
+
key: string,
|
|
704
|
+
): string | string[] | undefined {
|
|
705
|
+
return query?.[key]
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function readString(value: unknown): string | undefined {
|
|
709
|
+
if (typeof value === 'string' && value.length > 0) return value
|
|
710
|
+
if (Array.isArray(value) && typeof value[0] === 'string' && value[0].length > 0) {
|
|
711
|
+
return value[0]
|
|
712
|
+
}
|
|
713
|
+
return undefined
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function oauthErrorResponse(error: unknown): AuthRouteResponse<never> {
|
|
717
|
+
if (error instanceof DuplicateLinkedIdentityError) {
|
|
718
|
+
return { status: 409, body: { error: error.message } }
|
|
719
|
+
}
|
|
720
|
+
if (error instanceof OAuthError) {
|
|
721
|
+
const status = error.code === 'OAUTH_PROVIDER_NOT_FOUND' ? 404 : 400
|
|
722
|
+
return { status, body: { error: error.message } }
|
|
723
|
+
}
|
|
724
|
+
throw error
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function toAuthUser(user: StoredUser): AuthUser {
|
|
728
|
+
return {
|
|
729
|
+
id: user.id,
|
|
730
|
+
email: user.email,
|
|
731
|
+
name: user.name,
|
|
732
|
+
emailVerified: user.emailVerified,
|
|
733
|
+
createdAt: user.createdAt,
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
738
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function notFound(): AuthRouteResponse<never> {
|
|
742
|
+
return { status: 404, body: { error: 'Not found' } }
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function readEnvSecret(): string | undefined {
|
|
746
|
+
if (typeof process === 'undefined') {
|
|
747
|
+
return undefined
|
|
748
|
+
}
|
|
749
|
+
// Treat an explicitly empty or whitespace-only KORA_AUTH_SECRET as "not set"
|
|
750
|
+
// rather than as a real (invalid) secret. Otherwise an empty string slips past
|
|
751
|
+
// the nullish-coalescing fallback below and reaches TokenManager, which throws
|
|
752
|
+
// on secrets shorter than 32 chars — turning a blank env var into a hard crash
|
|
753
|
+
// instead of the intended dev fallback / production guard.
|
|
754
|
+
const value = process.env.KORA_AUTH_SECRET
|
|
755
|
+
return value && value.trim().length > 0 ? value : undefined
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function isProduction(): boolean {
|
|
759
|
+
return typeof process !== 'undefined' && process.env.NODE_ENV === 'production'
|
|
760
|
+
}
|