@korajs/auth 1.0.0-beta.12 → 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,184 @@
|
|
|
1
|
+
import { KoraError } from '@korajs/core'
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// OAuth Provider Configuration
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Configuration for an OAuth 2.0 provider.
|
|
9
|
+
*/
|
|
10
|
+
export interface OAuthProviderConfig {
|
|
11
|
+
/** Provider identifier (e.g., 'google', 'github', 'microsoft') */
|
|
12
|
+
providerId: string
|
|
13
|
+
/** OAuth client ID */
|
|
14
|
+
clientId: string
|
|
15
|
+
/** OAuth client secret. Required for confidential server-side clients. */
|
|
16
|
+
clientSecret?: string
|
|
17
|
+
/** Authorization endpoint URL */
|
|
18
|
+
authorizationUrl: string
|
|
19
|
+
/** Token exchange endpoint URL */
|
|
20
|
+
tokenUrl: string
|
|
21
|
+
/** User info endpoint URL */
|
|
22
|
+
userInfoUrl: string
|
|
23
|
+
/** OAuth scopes to request */
|
|
24
|
+
scopes: string[]
|
|
25
|
+
/** Redirect URI for the callback */
|
|
26
|
+
redirectUri: string
|
|
27
|
+
/**
|
|
28
|
+
* Enable PKCE (Proof Key for Code Exchange).
|
|
29
|
+
*
|
|
30
|
+
* Required for public clients such as desktop and mobile apps, and recommended
|
|
31
|
+
* whenever the provider supports it.
|
|
32
|
+
*/
|
|
33
|
+
pkce?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// OAuth Tokens
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Tokens returned by the OAuth provider after code exchange.
|
|
42
|
+
*/
|
|
43
|
+
export interface OAuthTokens {
|
|
44
|
+
/** OAuth access token */
|
|
45
|
+
accessToken: string
|
|
46
|
+
/** Token type (usually 'Bearer') */
|
|
47
|
+
tokenType: string
|
|
48
|
+
/** Access token expiry in seconds (if provided) */
|
|
49
|
+
expiresIn?: number
|
|
50
|
+
/** Refresh token (if provided) */
|
|
51
|
+
refreshToken?: string
|
|
52
|
+
/** ID token (if provided, e.g., OpenID Connect) */
|
|
53
|
+
idToken?: string
|
|
54
|
+
/** Granted scopes (may differ from requested scopes) */
|
|
55
|
+
scope?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// OAuth User Info
|
|
60
|
+
// ============================================================================
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* User information from the OAuth provider.
|
|
64
|
+
*/
|
|
65
|
+
export interface OAuthUserInfo {
|
|
66
|
+
/** Provider-specific user ID */
|
|
67
|
+
providerId: string
|
|
68
|
+
/** Provider name (e.g., 'google', 'github') */
|
|
69
|
+
provider: string
|
|
70
|
+
/** User's email address (may be null if not granted) */
|
|
71
|
+
email: string | null
|
|
72
|
+
/** Whether the email is verified by the provider */
|
|
73
|
+
emailVerified: boolean
|
|
74
|
+
/** User's display name */
|
|
75
|
+
name: string | null
|
|
76
|
+
/** URL to the user's avatar/profile picture */
|
|
77
|
+
avatarUrl: string | null
|
|
78
|
+
/** Raw profile data from the provider */
|
|
79
|
+
rawProfile: Record<string, unknown>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// OAuth State
|
|
84
|
+
// ============================================================================
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* State stored during the OAuth flow for CSRF protection.
|
|
88
|
+
*/
|
|
89
|
+
export interface OAuthState {
|
|
90
|
+
/** Random state parameter for CSRF protection */
|
|
91
|
+
state: string
|
|
92
|
+
/** Provider ID */
|
|
93
|
+
provider: string
|
|
94
|
+
/** Redirect URI used for this flow */
|
|
95
|
+
redirectUri: string
|
|
96
|
+
/** When this state was created (ms since epoch) */
|
|
97
|
+
createdAt: number
|
|
98
|
+
/** When this state expires (ms since epoch) */
|
|
99
|
+
expiresAt: number
|
|
100
|
+
/** Optional: user-defined data to pass through the flow */
|
|
101
|
+
metadata?: Record<string, unknown>
|
|
102
|
+
/** PKCE code verifier, stored server-side until the callback is consumed. */
|
|
103
|
+
codeVerifier?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Store for OAuth state parameters.
|
|
108
|
+
*/
|
|
109
|
+
export interface OAuthStateStore {
|
|
110
|
+
/** Store a state parameter for later validation. */
|
|
111
|
+
store(state: OAuthState): Promise<void>
|
|
112
|
+
/** Consume a state parameter (single-use). Returns null if not found or expired. */
|
|
113
|
+
consume(stateValue: string): Promise<OAuthState | null>
|
|
114
|
+
/** Clean up expired states. */
|
|
115
|
+
cleanExpired(): Promise<number>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ============================================================================
|
|
119
|
+
// Linked Identity
|
|
120
|
+
// ============================================================================
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A linked OAuth identity for a user.
|
|
124
|
+
* Users can have multiple linked identities (e.g., Google + GitHub).
|
|
125
|
+
*/
|
|
126
|
+
export interface LinkedIdentity {
|
|
127
|
+
/** Unique ID of this link */
|
|
128
|
+
id: string
|
|
129
|
+
/** Kora user ID */
|
|
130
|
+
userId: string
|
|
131
|
+
/** OAuth provider name */
|
|
132
|
+
provider: string
|
|
133
|
+
/** Provider-specific user ID */
|
|
134
|
+
providerUserId: string
|
|
135
|
+
/** Provider email (at time of linking) */
|
|
136
|
+
email: string | null
|
|
137
|
+
/** When this identity was linked */
|
|
138
|
+
linkedAt: number
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ============================================================================
|
|
142
|
+
// Errors
|
|
143
|
+
// ============================================================================
|
|
144
|
+
|
|
145
|
+
export class OAuthError extends KoraError {
|
|
146
|
+
constructor(message: string, code: string, context?: Record<string, unknown>) {
|
|
147
|
+
super(message, code, context)
|
|
148
|
+
this.name = 'OAuthError'
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export class OAuthStateMismatchError extends OAuthError {
|
|
153
|
+
constructor() {
|
|
154
|
+
super('OAuth state parameter does not match. Possible CSRF attack.', 'OAUTH_STATE_MISMATCH')
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export class OAuthCodeExchangeError extends OAuthError {
|
|
159
|
+
constructor(details?: string) {
|
|
160
|
+
super(
|
|
161
|
+
`Failed to exchange authorization code for tokens.${details ? ` ${details}` : ''}`,
|
|
162
|
+
'OAUTH_CODE_EXCHANGE_FAILED',
|
|
163
|
+
details ? { details } : undefined,
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export class OAuthUserInfoError extends OAuthError {
|
|
169
|
+
constructor(details?: string) {
|
|
170
|
+
super(
|
|
171
|
+
`Failed to fetch user info from OAuth provider.${details ? ` ${details}` : ''}`,
|
|
172
|
+
'OAUTH_USER_INFO_FAILED',
|
|
173
|
+
details ? { details } : undefined,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export class OAuthProviderNotFoundError extends OAuthError {
|
|
179
|
+
constructor(provider: string) {
|
|
180
|
+
super(`OAuth provider "${provider}" is not configured.`, 'OAUTH_PROVIDER_NOT_FOUND', {
|
|
181
|
+
provider,
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { ensurePostgresSchema } from '../../postgres/ensure-schema'
|
|
3
|
+
import { DuplicateLinkedIdentityError, type LinkedIdentityStore } from './linked-identity-store'
|
|
4
|
+
import type { LinkedIdentity, OAuthState, OAuthStateStore } from './oauth-types'
|
|
5
|
+
|
|
6
|
+
interface PostgresClient {
|
|
7
|
+
begin<T>(fn: (sql: PostgresClient) => Promise<T>): Promise<T>
|
|
8
|
+
(template: TemplateStringsArray, ...args: unknown[]): Promise<Record<string, unknown>[]>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface OAuthStateRow {
|
|
12
|
+
state: string
|
|
13
|
+
provider: string
|
|
14
|
+
redirect_uri: string
|
|
15
|
+
created_at: string | number
|
|
16
|
+
expires_at: string | number
|
|
17
|
+
metadata_json: string | null
|
|
18
|
+
code_verifier: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface LinkedIdentityRow {
|
|
22
|
+
id: string
|
|
23
|
+
user_id: string
|
|
24
|
+
provider: string
|
|
25
|
+
provider_user_id: string
|
|
26
|
+
email: string | null
|
|
27
|
+
linked_at: string | number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class PostgresOAuthStateStore implements OAuthStateStore {
|
|
31
|
+
private readonly sql: PostgresClient
|
|
32
|
+
private readonly ready: Promise<void>
|
|
33
|
+
|
|
34
|
+
constructor(sql: PostgresClient) {
|
|
35
|
+
this.sql = sql
|
|
36
|
+
this.ready = this.ensureTables()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async store(state: OAuthState): Promise<void> {
|
|
40
|
+
await this.ready
|
|
41
|
+
await this.sql`
|
|
42
|
+
INSERT INTO auth_oauth_states
|
|
43
|
+
(state, provider, redirect_uri, created_at, expires_at, metadata_json, code_verifier)
|
|
44
|
+
VALUES (
|
|
45
|
+
${state.state},
|
|
46
|
+
${state.provider},
|
|
47
|
+
${state.redirectUri},
|
|
48
|
+
${state.createdAt},
|
|
49
|
+
${state.expiresAt},
|
|
50
|
+
${state.metadata ? JSON.stringify(state.metadata) : null},
|
|
51
|
+
${state.codeVerifier ?? null}
|
|
52
|
+
)
|
|
53
|
+
ON CONFLICT (state) DO UPDATE SET
|
|
54
|
+
provider = EXCLUDED.provider,
|
|
55
|
+
redirect_uri = EXCLUDED.redirect_uri,
|
|
56
|
+
created_at = EXCLUDED.created_at,
|
|
57
|
+
expires_at = EXCLUDED.expires_at,
|
|
58
|
+
metadata_json = EXCLUDED.metadata_json,
|
|
59
|
+
code_verifier = EXCLUDED.code_verifier
|
|
60
|
+
`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async consume(stateValue: string): Promise<OAuthState | null> {
|
|
64
|
+
await this.ready
|
|
65
|
+
return this.sql.begin(async (tx) => {
|
|
66
|
+
const rows = (await tx`
|
|
67
|
+
DELETE FROM auth_oauth_states
|
|
68
|
+
WHERE state = ${stateValue}
|
|
69
|
+
RETURNING *
|
|
70
|
+
`) as unknown as OAuthStateRow[]
|
|
71
|
+
|
|
72
|
+
const row = rows[0]
|
|
73
|
+
if (!row || Date.now() > Number(row.expires_at)) {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
return rowToOAuthState(row)
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async cleanExpired(): Promise<number> {
|
|
81
|
+
await this.ready
|
|
82
|
+
const rows = await this.sql`
|
|
83
|
+
DELETE FROM auth_oauth_states
|
|
84
|
+
WHERE expires_at < ${Date.now()}
|
|
85
|
+
RETURNING state
|
|
86
|
+
`
|
|
87
|
+
return rows.length
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private async ensureTables(): Promise<void> {
|
|
91
|
+
// Concurrency-safe on an empty database shared by several instances.
|
|
92
|
+
await ensurePostgresSchema(this.sql, async (sql) => {
|
|
93
|
+
await sql`
|
|
94
|
+
CREATE TABLE IF NOT EXISTS auth_oauth_states (
|
|
95
|
+
state TEXT PRIMARY KEY,
|
|
96
|
+
provider TEXT NOT NULL,
|
|
97
|
+
redirect_uri TEXT NOT NULL,
|
|
98
|
+
created_at BIGINT NOT NULL,
|
|
99
|
+
expires_at BIGINT NOT NULL,
|
|
100
|
+
metadata_json TEXT,
|
|
101
|
+
code_verifier TEXT
|
|
102
|
+
)
|
|
103
|
+
`
|
|
104
|
+
|
|
105
|
+
await sql`
|
|
106
|
+
CREATE INDEX IF NOT EXISTS idx_auth_oauth_states_expires_at
|
|
107
|
+
ON auth_oauth_states(expires_at)
|
|
108
|
+
`
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class PostgresLinkedIdentityStore implements LinkedIdentityStore {
|
|
114
|
+
private readonly sql: PostgresClient
|
|
115
|
+
private readonly ready: Promise<void>
|
|
116
|
+
|
|
117
|
+
constructor(sql: PostgresClient) {
|
|
118
|
+
this.sql = sql
|
|
119
|
+
this.ready = this.ensureTables()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async findByProvider(provider: string, providerUserId: string): Promise<LinkedIdentity | null> {
|
|
123
|
+
await this.ready
|
|
124
|
+
const rows = (await this.sql`
|
|
125
|
+
SELECT * FROM auth_linked_identities
|
|
126
|
+
WHERE provider = ${provider} AND provider_user_id = ${providerUserId}
|
|
127
|
+
`) as unknown as LinkedIdentityRow[]
|
|
128
|
+
|
|
129
|
+
return rows[0] ? rowToLinkedIdentity(rows[0]) : null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async findByUser(userId: string): Promise<LinkedIdentity[]> {
|
|
133
|
+
await this.ready
|
|
134
|
+
const rows = (await this.sql`
|
|
135
|
+
SELECT * FROM auth_linked_identities
|
|
136
|
+
WHERE user_id = ${userId}
|
|
137
|
+
ORDER BY linked_at ASC
|
|
138
|
+
`) as unknown as LinkedIdentityRow[]
|
|
139
|
+
|
|
140
|
+
return rows.map(rowToLinkedIdentity)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async create(params: {
|
|
144
|
+
userId: string
|
|
145
|
+
provider: string
|
|
146
|
+
providerUserId: string
|
|
147
|
+
email: string | null
|
|
148
|
+
}): Promise<LinkedIdentity> {
|
|
149
|
+
await this.ready
|
|
150
|
+
const identity: LinkedIdentity = {
|
|
151
|
+
id: randomUUID(),
|
|
152
|
+
userId: params.userId,
|
|
153
|
+
provider: params.provider,
|
|
154
|
+
providerUserId: params.providerUserId,
|
|
155
|
+
email: params.email,
|
|
156
|
+
linkedAt: Date.now(),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
await this.sql`
|
|
161
|
+
INSERT INTO auth_linked_identities
|
|
162
|
+
(id, user_id, provider, provider_user_id, email, linked_at)
|
|
163
|
+
VALUES (
|
|
164
|
+
${identity.id},
|
|
165
|
+
${identity.userId},
|
|
166
|
+
${identity.provider},
|
|
167
|
+
${identity.providerUserId},
|
|
168
|
+
${identity.email},
|
|
169
|
+
${identity.linkedAt}
|
|
170
|
+
)
|
|
171
|
+
`
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (isUniqueViolation(error)) {
|
|
174
|
+
throw new DuplicateLinkedIdentityError(params.provider)
|
|
175
|
+
}
|
|
176
|
+
throw error
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return identity
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async delete(userId: string, provider: string): Promise<void> {
|
|
183
|
+
await this.ready
|
|
184
|
+
await this.sql`
|
|
185
|
+
DELETE FROM auth_linked_identities
|
|
186
|
+
WHERE user_id = ${userId} AND provider = ${provider}
|
|
187
|
+
`
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private async ensureTables(): Promise<void> {
|
|
191
|
+
// Concurrency-safe on an empty database shared by several instances.
|
|
192
|
+
await ensurePostgresSchema(this.sql, async (sql) => {
|
|
193
|
+
await sql`
|
|
194
|
+
CREATE TABLE IF NOT EXISTS auth_linked_identities (
|
|
195
|
+
id TEXT PRIMARY KEY,
|
|
196
|
+
user_id TEXT NOT NULL,
|
|
197
|
+
provider TEXT NOT NULL,
|
|
198
|
+
provider_user_id TEXT NOT NULL,
|
|
199
|
+
email TEXT,
|
|
200
|
+
linked_at BIGINT NOT NULL,
|
|
201
|
+
UNIQUE(provider, provider_user_id),
|
|
202
|
+
UNIQUE(user_id, provider)
|
|
203
|
+
)
|
|
204
|
+
`
|
|
205
|
+
|
|
206
|
+
await sql`
|
|
207
|
+
CREATE INDEX IF NOT EXISTS idx_auth_linked_identities_user_id
|
|
208
|
+
ON auth_linked_identities(user_id)
|
|
209
|
+
`
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function createPostgresOAuthStateStore(options: {
|
|
215
|
+
connectionString: string
|
|
216
|
+
}): Promise<PostgresOAuthStateStore> {
|
|
217
|
+
const postgresClient = await loadPostgresDeps()
|
|
218
|
+
const sql = postgresClient(options.connectionString) as unknown as PostgresClient
|
|
219
|
+
return new PostgresOAuthStateStore(sql)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function createPostgresLinkedIdentityStore(options: {
|
|
223
|
+
connectionString: string
|
|
224
|
+
}): Promise<PostgresLinkedIdentityStore> {
|
|
225
|
+
const postgresClient = await loadPostgresDeps()
|
|
226
|
+
const sql = postgresClient(options.connectionString) as unknown as PostgresClient
|
|
227
|
+
return new PostgresLinkedIdentityStore(sql)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function createPostgresOAuthStores(options: { connectionString: string }): Promise<{
|
|
231
|
+
stateStore: PostgresOAuthStateStore
|
|
232
|
+
linkedIdentityStore: PostgresLinkedIdentityStore
|
|
233
|
+
}> {
|
|
234
|
+
const postgresClient = await loadPostgresDeps()
|
|
235
|
+
const sql = postgresClient(options.connectionString) as unknown as PostgresClient
|
|
236
|
+
return {
|
|
237
|
+
stateStore: new PostgresOAuthStateStore(sql),
|
|
238
|
+
linkedIdentityStore: new PostgresLinkedIdentityStore(sql),
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function loadPostgresDeps(): Promise<(connectionString: string) => unknown> {
|
|
243
|
+
try {
|
|
244
|
+
const dynamicImport = new Function('specifier', 'return import(specifier)') as (
|
|
245
|
+
specifier: string,
|
|
246
|
+
) => Promise<unknown>
|
|
247
|
+
|
|
248
|
+
const postgresMod = (await dynamicImport('postgres')) as { default: (cs: string) => unknown }
|
|
249
|
+
return postgresMod.default
|
|
250
|
+
} catch {
|
|
251
|
+
throw new Error(
|
|
252
|
+
'PostgreSQL OAuth stores require the "postgres" package. Install it in your project dependencies.',
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function rowToOAuthState(row: OAuthStateRow): OAuthState {
|
|
258
|
+
return {
|
|
259
|
+
state: row.state,
|
|
260
|
+
provider: row.provider,
|
|
261
|
+
redirectUri: row.redirect_uri,
|
|
262
|
+
createdAt: Number(row.created_at),
|
|
263
|
+
expiresAt: Number(row.expires_at),
|
|
264
|
+
metadata: parseMetadata(row.metadata_json),
|
|
265
|
+
codeVerifier: row.code_verifier ?? undefined,
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function parseMetadata(value: string | null): Record<string, unknown> | undefined {
|
|
270
|
+
if (!value) return undefined
|
|
271
|
+
const parsed = JSON.parse(value) as unknown
|
|
272
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
273
|
+
? (parsed as Record<string, unknown>)
|
|
274
|
+
: undefined
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function rowToLinkedIdentity(row: LinkedIdentityRow): LinkedIdentity {
|
|
278
|
+
return {
|
|
279
|
+
id: row.id,
|
|
280
|
+
userId: row.user_id,
|
|
281
|
+
provider: row.provider,
|
|
282
|
+
providerUserId: row.provider_user_id,
|
|
283
|
+
email: row.email,
|
|
284
|
+
linkedAt: Number(row.linked_at),
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function isUniqueViolation(error: unknown): boolean {
|
|
289
|
+
if (!(error instanceof Error)) return false
|
|
290
|
+
const code = (error as Error & { code?: string }).code
|
|
291
|
+
return (
|
|
292
|
+
code === '23505' ||
|
|
293
|
+
error.message.includes('unique constraint') ||
|
|
294
|
+
error.message.includes('duplicate key')
|
|
295
|
+
)
|
|
296
|
+
}
|