@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,108 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { KoraError } from '@korajs/core'
|
|
3
|
+
import type { LinkedIdentity } from './oauth-types'
|
|
4
|
+
|
|
5
|
+
export interface LinkedIdentityStore {
|
|
6
|
+
findByProvider(provider: string, providerUserId: string): Promise<LinkedIdentity | null>
|
|
7
|
+
findByUser(userId: string): Promise<LinkedIdentity[]>
|
|
8
|
+
create(params: {
|
|
9
|
+
userId: string
|
|
10
|
+
provider: string
|
|
11
|
+
providerUserId: string
|
|
12
|
+
email: string | null
|
|
13
|
+
}): Promise<LinkedIdentity>
|
|
14
|
+
delete(userId: string, provider: string): Promise<void>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class DuplicateLinkedIdentityError extends KoraError {
|
|
18
|
+
constructor(provider: string) {
|
|
19
|
+
super(`This ${provider} account is already linked.`, 'DUPLICATE_LINKED_IDENTITY', {
|
|
20
|
+
provider,
|
|
21
|
+
})
|
|
22
|
+
this.name = 'DuplicateLinkedIdentityError'
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class InMemoryLinkedIdentityStore implements LinkedIdentityStore {
|
|
27
|
+
private readonly identitiesById = new Map<string, LinkedIdentity>()
|
|
28
|
+
private readonly identityIdByProvider = new Map<string, string>()
|
|
29
|
+
private readonly identityIdsByUser = new Map<string, Set<string>>()
|
|
30
|
+
private readonly identityIdByUserProvider = new Map<string, string>()
|
|
31
|
+
|
|
32
|
+
async findByProvider(provider: string, providerUserId: string): Promise<LinkedIdentity | null> {
|
|
33
|
+
const id = this.identityIdByProvider.get(providerIdentityKey(provider, providerUserId))
|
|
34
|
+
return id ? (this.identitiesById.get(id) ?? null) : null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async findByUser(userId: string): Promise<LinkedIdentity[]> {
|
|
38
|
+
const ids = this.identityIdsByUser.get(userId)
|
|
39
|
+
if (!ids) return []
|
|
40
|
+
return [...ids]
|
|
41
|
+
.map((id) => this.identitiesById.get(id))
|
|
42
|
+
.filter((identity): identity is LinkedIdentity => identity !== undefined)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async create(params: {
|
|
46
|
+
userId: string
|
|
47
|
+
provider: string
|
|
48
|
+
providerUserId: string
|
|
49
|
+
email: string | null
|
|
50
|
+
}): Promise<LinkedIdentity> {
|
|
51
|
+
const providerKey = providerIdentityKey(params.provider, params.providerUserId)
|
|
52
|
+
const userProviderKeyValue = userProviderKey(params.userId, params.provider)
|
|
53
|
+
if (
|
|
54
|
+
this.identityIdByProvider.has(providerKey) ||
|
|
55
|
+
this.identityIdByUserProvider.has(userProviderKeyValue)
|
|
56
|
+
) {
|
|
57
|
+
throw new DuplicateLinkedIdentityError(params.provider)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const identity: LinkedIdentity = {
|
|
61
|
+
id: randomUUID(),
|
|
62
|
+
userId: params.userId,
|
|
63
|
+
provider: params.provider,
|
|
64
|
+
providerUserId: params.providerUserId,
|
|
65
|
+
email: params.email,
|
|
66
|
+
linkedAt: Date.now(),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.identitiesById.set(identity.id, identity)
|
|
70
|
+
this.identityIdByProvider.set(providerKey, identity.id)
|
|
71
|
+
this.identityIdByUserProvider.set(userProviderKeyValue, identity.id)
|
|
72
|
+
|
|
73
|
+
const userIdentities = this.identityIdsByUser.get(params.userId) ?? new Set<string>()
|
|
74
|
+
userIdentities.add(identity.id)
|
|
75
|
+
this.identityIdsByUser.set(params.userId, userIdentities)
|
|
76
|
+
|
|
77
|
+
return identity
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async delete(userId: string, provider: string): Promise<void> {
|
|
81
|
+
const userProviderKeyValue = userProviderKey(userId, provider)
|
|
82
|
+
const id = this.identityIdByUserProvider.get(userProviderKeyValue)
|
|
83
|
+
if (!id) return
|
|
84
|
+
|
|
85
|
+
const identity = this.identitiesById.get(id)
|
|
86
|
+
this.identitiesById.delete(id)
|
|
87
|
+
this.identityIdByUserProvider.delete(userProviderKeyValue)
|
|
88
|
+
if (identity) {
|
|
89
|
+
this.identityIdByProvider.delete(
|
|
90
|
+
providerIdentityKey(identity.provider, identity.providerUserId),
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const userIdentities = this.identityIdsByUser.get(userId)
|
|
95
|
+
userIdentities?.delete(id)
|
|
96
|
+
if (userIdentities?.size === 0) {
|
|
97
|
+
this.identityIdsByUser.delete(userId)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function providerIdentityKey(provider: string, providerUserId: string): string {
|
|
103
|
+
return `${provider}:${providerUserId}`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function userProviderKey(userId: string, provider: string): string {
|
|
107
|
+
return `${userId}:${provider}`
|
|
108
|
+
}
|
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
OAuthProviderConfig,
|
|
3
|
+
OAuthState,
|
|
4
|
+
OAuthStateStore,
|
|
5
|
+
OAuthTokens,
|
|
6
|
+
OAuthUserInfo,
|
|
7
|
+
} from './oauth-types'
|
|
8
|
+
import {
|
|
9
|
+
OAuthCodeExchangeError,
|
|
10
|
+
OAuthProviderNotFoundError,
|
|
11
|
+
OAuthStateMismatchError,
|
|
12
|
+
OAuthUserInfoError,
|
|
13
|
+
} from './oauth-types'
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// InMemoryOAuthStateStore
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reserved metadata key holding the flow binding. Stored inside the state's
|
|
21
|
+
* metadata so every persisted state store round-trips it without a migration,
|
|
22
|
+
* and stripped from the metadata handed back to callers.
|
|
23
|
+
*/
|
|
24
|
+
const BINDING_METADATA_KEY = '__kora_oauth_binding'
|
|
25
|
+
|
|
26
|
+
/** What an OAuth flow is for. A state minted for one purpose is useless for the other. */
|
|
27
|
+
export type OAuthFlowPurpose = 'signin' | 'link'
|
|
28
|
+
|
|
29
|
+
/** Binding requested when an authorization URL is created (AUTH-3). */
|
|
30
|
+
export interface OAuthFlowBinding {
|
|
31
|
+
/** The flow purpose. Defaults to `'signin'`. */
|
|
32
|
+
purpose?: OAuthFlowPurpose
|
|
33
|
+
/** For `'link'`: the authenticated Kora user starting the flow. */
|
|
34
|
+
userId?: string
|
|
35
|
+
/**
|
|
36
|
+
* A random secret held only by the initiating client (an HttpOnly cookie on
|
|
37
|
+
* the web, memory for native/PKCE). Only its SHA-256 is stored server-side.
|
|
38
|
+
*/
|
|
39
|
+
binding?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface StoredBinding {
|
|
43
|
+
purpose: OAuthFlowPurpose
|
|
44
|
+
userId?: string
|
|
45
|
+
bindingHash?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Default state TTL: 10 minutes */
|
|
49
|
+
const DEFAULT_STATE_TTL_MS = 10 * 60 * 1000
|
|
50
|
+
const PKCE_VERIFIER_BYTES = 32
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* In-memory OAuth state store for development.
|
|
54
|
+
* Use Redis or a database in production for multi-server deployments.
|
|
55
|
+
*/
|
|
56
|
+
export class InMemoryOAuthStateStore implements OAuthStateStore {
|
|
57
|
+
private readonly states = new Map<string, OAuthState>()
|
|
58
|
+
|
|
59
|
+
async store(state: OAuthState): Promise<void> {
|
|
60
|
+
this.states.set(state.state, state)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async consume(stateValue: string): Promise<OAuthState | null> {
|
|
64
|
+
const state = this.states.get(stateValue)
|
|
65
|
+
if (!state) return null
|
|
66
|
+
|
|
67
|
+
// Single-use
|
|
68
|
+
this.states.delete(stateValue)
|
|
69
|
+
|
|
70
|
+
// Check expiry
|
|
71
|
+
if (Date.now() > state.expiresAt) return null
|
|
72
|
+
|
|
73
|
+
return state
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async cleanExpired(): Promise<number> {
|
|
77
|
+
const now = Date.now()
|
|
78
|
+
let count = 0
|
|
79
|
+
for (const [key, state] of this.states) {
|
|
80
|
+
if (now > state.expiresAt) {
|
|
81
|
+
this.states.delete(key)
|
|
82
|
+
count++
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return count
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ============================================================================
|
|
90
|
+
// OAuthManager
|
|
91
|
+
// ============================================================================
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Configuration for the OAuth manager.
|
|
95
|
+
*/
|
|
96
|
+
export interface OAuthManagerConfig {
|
|
97
|
+
/** Registered OAuth providers */
|
|
98
|
+
providers: OAuthProviderConfig[]
|
|
99
|
+
/** State store. Defaults to InMemoryOAuthStateStore. */
|
|
100
|
+
stateStore?: OAuthStateStore
|
|
101
|
+
/** State TTL in milliseconds. Defaults to 10 minutes. */
|
|
102
|
+
stateTtlMs?: number
|
|
103
|
+
/**
|
|
104
|
+
* Custom fetch function. Defaults to global fetch.
|
|
105
|
+
* Useful for testing or custom HTTP clients.
|
|
106
|
+
*/
|
|
107
|
+
fetch?: typeof globalThis.fetch
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Manages the OAuth 2.0 authorization code flow.
|
|
112
|
+
*
|
|
113
|
+
* Supports any standard OAuth 2.0 / OpenID Connect provider.
|
|
114
|
+
* Pre-built configurations available for Google, GitHub, and Microsoft.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ```typescript
|
|
118
|
+
* const oauth = new OAuthManager({
|
|
119
|
+
* providers: [
|
|
120
|
+
* googleProvider({ clientId: '...', clientSecret: '...', redirectUri: '...' }),
|
|
121
|
+
* githubProvider({ clientId: '...', clientSecret: '...', redirectUri: '...' }),
|
|
122
|
+
* ],
|
|
123
|
+
* })
|
|
124
|
+
*
|
|
125
|
+
* // Step 1: Generate authorization URL
|
|
126
|
+
* const { url, state } = await oauth.getAuthorizationUrl('google')
|
|
127
|
+
* // Redirect user to url...
|
|
128
|
+
*
|
|
129
|
+
* // Step 2: Handle callback
|
|
130
|
+
* const { tokens, userInfo } = await oauth.handleCallback('google', code, stateParam)
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
export class OAuthManager {
|
|
134
|
+
private readonly providers = new Map<string, OAuthProviderConfig>()
|
|
135
|
+
private readonly stateStore: OAuthStateStore
|
|
136
|
+
private readonly stateTtlMs: number
|
|
137
|
+
private readonly fetchFn: typeof globalThis.fetch
|
|
138
|
+
|
|
139
|
+
constructor(config: OAuthManagerConfig) {
|
|
140
|
+
for (const provider of config.providers) {
|
|
141
|
+
this.providers.set(provider.providerId, provider)
|
|
142
|
+
}
|
|
143
|
+
this.stateStore = config.stateStore ?? new InMemoryOAuthStateStore()
|
|
144
|
+
this.stateTtlMs = config.stateTtlMs ?? DEFAULT_STATE_TTL_MS
|
|
145
|
+
this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Generate an authorization URL for the user to visit.
|
|
150
|
+
* Returns the URL and the state parameter for CSRF validation.
|
|
151
|
+
*/
|
|
152
|
+
async getAuthorizationUrl(
|
|
153
|
+
providerId: string,
|
|
154
|
+
metadata?: Record<string, unknown>,
|
|
155
|
+
flow: OAuthFlowBinding = {},
|
|
156
|
+
): Promise<{ url: string; state: string }> {
|
|
157
|
+
const provider = this.getProvider(providerId)
|
|
158
|
+
const purpose = flow.purpose ?? 'signin'
|
|
159
|
+
if (purpose === 'link' && !flow.userId) {
|
|
160
|
+
throw new OAuthStateMismatchError()
|
|
161
|
+
}
|
|
162
|
+
const stored: StoredBinding = {
|
|
163
|
+
purpose,
|
|
164
|
+
...(flow.userId ? { userId: flow.userId } : {}),
|
|
165
|
+
...(flow.binding ? { bindingHash: await sha256Base64Url(flow.binding) } : {}),
|
|
166
|
+
}
|
|
167
|
+
const userMetadata = { ...(metadata ?? {}) }
|
|
168
|
+
delete userMetadata[BINDING_METADATA_KEY]
|
|
169
|
+
|
|
170
|
+
const state = generateState()
|
|
171
|
+
const codeVerifier = provider.pkce ? generateCodeVerifier() : undefined
|
|
172
|
+
const codeChallenge = codeVerifier ? await createCodeChallenge(codeVerifier) : undefined
|
|
173
|
+
const now = Date.now()
|
|
174
|
+
|
|
175
|
+
const oauthState: OAuthState = {
|
|
176
|
+
state,
|
|
177
|
+
provider: providerId,
|
|
178
|
+
redirectUri: provider.redirectUri,
|
|
179
|
+
createdAt: now,
|
|
180
|
+
expiresAt: now + this.stateTtlMs,
|
|
181
|
+
metadata: { ...userMetadata, [BINDING_METADATA_KEY]: stored },
|
|
182
|
+
codeVerifier,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
await this.stateStore.store(oauthState)
|
|
186
|
+
|
|
187
|
+
const params = new URLSearchParams({
|
|
188
|
+
client_id: provider.clientId,
|
|
189
|
+
redirect_uri: provider.redirectUri,
|
|
190
|
+
response_type: 'code',
|
|
191
|
+
scope: provider.scopes.join(' '),
|
|
192
|
+
state,
|
|
193
|
+
})
|
|
194
|
+
if (codeChallenge) {
|
|
195
|
+
params.set('code_challenge', codeChallenge)
|
|
196
|
+
params.set('code_challenge_method', 'S256')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const url = `${provider.authorizationUrl}?${params.toString()}`
|
|
200
|
+
return { url, state }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Handle the OAuth callback after the user authorizes.
|
|
205
|
+
* Validates the state parameter, exchanges the code for tokens,
|
|
206
|
+
* and fetches user info.
|
|
207
|
+
*
|
|
208
|
+
* The state is single-use and is redeemable only for the purpose it was
|
|
209
|
+
* minted for, by the same user (for linking) and the same client binding.
|
|
210
|
+
* A mismatch is rejected before the code is exchanged.
|
|
211
|
+
*
|
|
212
|
+
* @param providerId - The OAuth provider
|
|
213
|
+
* @param code - The authorization code from the callback
|
|
214
|
+
* @param state - The state parameter from the callback
|
|
215
|
+
* @param expected - The purpose, user and client binding of this callback
|
|
216
|
+
* @returns Tokens and user info from the provider
|
|
217
|
+
*/
|
|
218
|
+
async handleCallback(
|
|
219
|
+
providerId: string,
|
|
220
|
+
code: string,
|
|
221
|
+
state: string,
|
|
222
|
+
expected: OAuthFlowBinding = {},
|
|
223
|
+
): Promise<{
|
|
224
|
+
tokens: OAuthTokens
|
|
225
|
+
userInfo: OAuthUserInfo
|
|
226
|
+
stateMetadata?: Record<string, unknown>
|
|
227
|
+
}> {
|
|
228
|
+
const provider = this.getProvider(providerId)
|
|
229
|
+
|
|
230
|
+
// Validate state (CSRF protection)
|
|
231
|
+
const oauthState = await this.stateStore.consume(state)
|
|
232
|
+
if (!oauthState || oauthState.provider !== providerId) {
|
|
233
|
+
throw new OAuthStateMismatchError()
|
|
234
|
+
}
|
|
235
|
+
await assertBindingMatches(oauthState, expected)
|
|
236
|
+
|
|
237
|
+
// Exchange code for tokens
|
|
238
|
+
const tokens = await this.exchangeCodeForTokens(provider, code, oauthState)
|
|
239
|
+
|
|
240
|
+
// Fetch user info
|
|
241
|
+
const userInfo = await this.fetchUserInfo(provider, tokens.accessToken)
|
|
242
|
+
|
|
243
|
+
return { tokens, userInfo, stateMetadata: withoutBinding(oauthState.metadata) }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Get a registered provider by ID.
|
|
248
|
+
*/
|
|
249
|
+
getProvider(providerId: string): OAuthProviderConfig {
|
|
250
|
+
const provider = this.providers.get(providerId)
|
|
251
|
+
if (!provider) {
|
|
252
|
+
throw new OAuthProviderNotFoundError(providerId)
|
|
253
|
+
}
|
|
254
|
+
return provider
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* List all registered provider IDs.
|
|
259
|
+
*/
|
|
260
|
+
getProviderIds(): string[] {
|
|
261
|
+
return [...this.providers.keys()]
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// --- Private ---
|
|
265
|
+
|
|
266
|
+
private async exchangeCodeForTokens(
|
|
267
|
+
provider: OAuthProviderConfig,
|
|
268
|
+
code: string,
|
|
269
|
+
oauthState: OAuthState,
|
|
270
|
+
): Promise<OAuthTokens> {
|
|
271
|
+
const body = new URLSearchParams({
|
|
272
|
+
grant_type: 'authorization_code',
|
|
273
|
+
code,
|
|
274
|
+
redirect_uri: provider.redirectUri,
|
|
275
|
+
client_id: provider.clientId,
|
|
276
|
+
})
|
|
277
|
+
if (provider.clientSecret) {
|
|
278
|
+
body.set('client_secret', provider.clientSecret)
|
|
279
|
+
}
|
|
280
|
+
if (oauthState.codeVerifier) {
|
|
281
|
+
body.set('code_verifier', oauthState.codeVerifier)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let response: Response
|
|
285
|
+
try {
|
|
286
|
+
response = await this.fetchFn(provider.tokenUrl, {
|
|
287
|
+
method: 'POST',
|
|
288
|
+
headers: {
|
|
289
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
290
|
+
Accept: 'application/json',
|
|
291
|
+
},
|
|
292
|
+
body: body.toString(),
|
|
293
|
+
})
|
|
294
|
+
} catch (err) {
|
|
295
|
+
throw new OAuthCodeExchangeError(err instanceof Error ? err.message : 'Network error')
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!response.ok) {
|
|
299
|
+
let details = `HTTP ${response.status}`
|
|
300
|
+
try {
|
|
301
|
+
const errorBody = await response.text()
|
|
302
|
+
details += `: ${errorBody}`
|
|
303
|
+
} catch {
|
|
304
|
+
// ignore
|
|
305
|
+
}
|
|
306
|
+
throw new OAuthCodeExchangeError(details)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const data = (await response.json()) as Record<string, unknown>
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
accessToken: data.access_token as string,
|
|
313
|
+
tokenType: (data.token_type as string) ?? 'Bearer',
|
|
314
|
+
expiresIn: data.expires_in as number | undefined,
|
|
315
|
+
refreshToken: data.refresh_token as string | undefined,
|
|
316
|
+
idToken: data.id_token as string | undefined,
|
|
317
|
+
scope: data.scope as string | undefined,
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private async fetchUserInfo(
|
|
322
|
+
provider: OAuthProviderConfig,
|
|
323
|
+
accessToken: string,
|
|
324
|
+
): Promise<OAuthUserInfo> {
|
|
325
|
+
let response: Response
|
|
326
|
+
try {
|
|
327
|
+
response = await this.fetchFn(provider.userInfoUrl, {
|
|
328
|
+
headers: {
|
|
329
|
+
Authorization: `Bearer ${accessToken}`,
|
|
330
|
+
Accept: 'application/json',
|
|
331
|
+
},
|
|
332
|
+
})
|
|
333
|
+
} catch (err) {
|
|
334
|
+
throw new OAuthUserInfoError(err instanceof Error ? err.message : 'Network error')
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (!response.ok) {
|
|
338
|
+
throw new OAuthUserInfoError(`HTTP ${response.status}`)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const profile = (await response.json()) as Record<string, unknown>
|
|
342
|
+
|
|
343
|
+
// Normalize user info based on provider
|
|
344
|
+
return normalizeUserInfo(provider.providerId, profile)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ============================================================================
|
|
349
|
+
// User Info Normalization
|
|
350
|
+
// ============================================================================
|
|
351
|
+
|
|
352
|
+
function normalizeUserInfo(providerId: string, profile: Record<string, unknown>): OAuthUserInfo {
|
|
353
|
+
switch (providerId) {
|
|
354
|
+
case 'google':
|
|
355
|
+
return {
|
|
356
|
+
providerId: profile.sub as string,
|
|
357
|
+
provider: 'google',
|
|
358
|
+
email: (profile.email as string) ?? null,
|
|
359
|
+
emailVerified: (profile.email_verified as boolean) ?? false,
|
|
360
|
+
name: (profile.name as string) ?? null,
|
|
361
|
+
avatarUrl: (profile.picture as string) ?? null,
|
|
362
|
+
rawProfile: profile,
|
|
363
|
+
}
|
|
364
|
+
case 'github':
|
|
365
|
+
return {
|
|
366
|
+
providerId: String(profile.id),
|
|
367
|
+
provider: 'github',
|
|
368
|
+
email: (profile.email as string) ?? null,
|
|
369
|
+
emailVerified: false, // GitHub doesn't confirm in the profile response
|
|
370
|
+
name: (profile.name as string) ?? (profile.login as string) ?? null,
|
|
371
|
+
avatarUrl: (profile.avatar_url as string) ?? null,
|
|
372
|
+
rawProfile: profile,
|
|
373
|
+
}
|
|
374
|
+
case 'microsoft':
|
|
375
|
+
return {
|
|
376
|
+
providerId: profile.id as string,
|
|
377
|
+
provider: 'microsoft',
|
|
378
|
+
email: (profile.mail as string) ?? (profile.userPrincipalName as string) ?? null,
|
|
379
|
+
emailVerified: false,
|
|
380
|
+
name: (profile.displayName as string) ?? null,
|
|
381
|
+
avatarUrl: null,
|
|
382
|
+
rawProfile: profile,
|
|
383
|
+
}
|
|
384
|
+
default:
|
|
385
|
+
// Generic normalization
|
|
386
|
+
return {
|
|
387
|
+
providerId: String(profile.id ?? profile.sub ?? ''),
|
|
388
|
+
provider: providerId,
|
|
389
|
+
email: (profile.email as string) ?? null,
|
|
390
|
+
emailVerified: (profile.email_verified as boolean) ?? false,
|
|
391
|
+
name: (profile.name as string) ?? null,
|
|
392
|
+
avatarUrl: (profile.picture as string) ?? (profile.avatar_url as string) ?? null,
|
|
393
|
+
rawProfile: profile,
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ============================================================================
|
|
399
|
+
// Provider Factories
|
|
400
|
+
// ============================================================================
|
|
401
|
+
|
|
402
|
+
interface ProviderFactoryConfig {
|
|
403
|
+
clientId: string
|
|
404
|
+
clientSecret?: string
|
|
405
|
+
redirectUri: string
|
|
406
|
+
scopes?: string[]
|
|
407
|
+
pkce?: boolean
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Create a Google OAuth provider configuration.
|
|
412
|
+
*/
|
|
413
|
+
export function googleProvider(config: ProviderFactoryConfig): OAuthProviderConfig {
|
|
414
|
+
return {
|
|
415
|
+
providerId: 'google',
|
|
416
|
+
clientId: config.clientId,
|
|
417
|
+
clientSecret: config.clientSecret,
|
|
418
|
+
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
419
|
+
tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
420
|
+
userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo',
|
|
421
|
+
scopes: config.scopes ?? ['openid', 'email', 'profile'],
|
|
422
|
+
redirectUri: config.redirectUri,
|
|
423
|
+
pkce: config.pkce,
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Create a GitHub OAuth provider configuration.
|
|
429
|
+
*/
|
|
430
|
+
export function githubProvider(config: ProviderFactoryConfig): OAuthProviderConfig {
|
|
431
|
+
return {
|
|
432
|
+
providerId: 'github',
|
|
433
|
+
clientId: config.clientId,
|
|
434
|
+
clientSecret: config.clientSecret,
|
|
435
|
+
authorizationUrl: 'https://github.com/login/oauth/authorize',
|
|
436
|
+
tokenUrl: 'https://github.com/login/oauth/access_token',
|
|
437
|
+
userInfoUrl: 'https://api.github.com/user',
|
|
438
|
+
scopes: config.scopes ?? ['read:user', 'user:email'],
|
|
439
|
+
redirectUri: config.redirectUri,
|
|
440
|
+
pkce: config.pkce,
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Create a Microsoft OAuth provider configuration.
|
|
446
|
+
*/
|
|
447
|
+
export function microsoftProvider(
|
|
448
|
+
config: ProviderFactoryConfig & { tenantId?: string },
|
|
449
|
+
): OAuthProviderConfig {
|
|
450
|
+
const tenant = config.tenantId ?? 'common'
|
|
451
|
+
return {
|
|
452
|
+
providerId: 'microsoft',
|
|
453
|
+
clientId: config.clientId,
|
|
454
|
+
clientSecret: config.clientSecret,
|
|
455
|
+
authorizationUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`,
|
|
456
|
+
tokenUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
|
|
457
|
+
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
|
|
458
|
+
scopes: config.scopes ?? ['openid', 'email', 'profile', 'User.Read'],
|
|
459
|
+
redirectUri: config.redirectUri,
|
|
460
|
+
pkce: config.pkce,
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ============================================================================
|
|
465
|
+
// Helpers
|
|
466
|
+
// ============================================================================
|
|
467
|
+
|
|
468
|
+
function readStoredBinding(state: OAuthState): StoredBinding {
|
|
469
|
+
const raw = state.metadata?.[BINDING_METADATA_KEY]
|
|
470
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
471
|
+
const value = raw as Record<string, unknown>
|
|
472
|
+
return {
|
|
473
|
+
purpose: value.purpose === 'link' ? 'link' : 'signin',
|
|
474
|
+
...(typeof value.userId === 'string' ? { userId: value.userId } : {}),
|
|
475
|
+
...(typeof value.bindingHash === 'string' ? { bindingHash: value.bindingHash } : {}),
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// States minted before beta.13 carry no binding: they are sign-in states.
|
|
479
|
+
return { purpose: 'signin' }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function assertBindingMatches(state: OAuthState, expected: OAuthFlowBinding): Promise<void> {
|
|
483
|
+
const stored = readStoredBinding(state)
|
|
484
|
+
const purpose = expected.purpose ?? 'signin'
|
|
485
|
+
if (stored.purpose !== purpose) throw new OAuthStateMismatchError()
|
|
486
|
+
if (purpose === 'link' && (!expected.userId || stored.userId !== expected.userId)) {
|
|
487
|
+
throw new OAuthStateMismatchError()
|
|
488
|
+
}
|
|
489
|
+
if (stored.bindingHash !== undefined) {
|
|
490
|
+
if (!expected.binding) throw new OAuthStateMismatchError()
|
|
491
|
+
const presented = await sha256Base64Url(expected.binding)
|
|
492
|
+
if (!constantTimeEqual(presented, stored.bindingHash)) throw new OAuthStateMismatchError()
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function withoutBinding(
|
|
497
|
+
metadata: Record<string, unknown> | undefined,
|
|
498
|
+
): Record<string, unknown> | undefined {
|
|
499
|
+
if (!metadata) return undefined
|
|
500
|
+
const copy = { ...metadata }
|
|
501
|
+
delete copy[BINDING_METADATA_KEY]
|
|
502
|
+
return Object.keys(copy).length > 0 ? copy : undefined
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function sha256Base64Url(value: string): Promise<string> {
|
|
506
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
|
|
507
|
+
return toBase64Url(new Uint8Array(digest))
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function constantTimeEqual(a: string, b: string): boolean {
|
|
511
|
+
if (a.length !== b.length) return false
|
|
512
|
+
let diff = 0
|
|
513
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
|
514
|
+
return diff === 0
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Generate a random client binding for an OAuth flow (32 bytes, base64url).
|
|
519
|
+
*/
|
|
520
|
+
export function generateOAuthBinding(): string {
|
|
521
|
+
const bytes = new Uint8Array(32)
|
|
522
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
523
|
+
return toBase64Url(bytes)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function generateState(): string {
|
|
527
|
+
const bytes = new Uint8Array(32)
|
|
528
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
529
|
+
return toBase64Url(bytes)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function generateCodeVerifier(): string {
|
|
533
|
+
const bytes = new Uint8Array(PKCE_VERIFIER_BYTES)
|
|
534
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
535
|
+
return toBase64Url(bytes)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function createCodeChallenge(codeVerifier: string): Promise<string> {
|
|
539
|
+
const data = new TextEncoder().encode(codeVerifier)
|
|
540
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', data)
|
|
541
|
+
return toBase64Url(new Uint8Array(digest))
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function toBase64Url(bytes: Uint8Array): string {
|
|
545
|
+
let binary = ''
|
|
546
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
547
|
+
binary += String.fromCharCode(bytes[i] as number)
|
|
548
|
+
}
|
|
549
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
550
|
+
}
|