@cloudauth/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/LICENSE +21 -0
  2. package/dist/adapter.d.ts +20 -0
  3. package/dist/adapter.d.ts.map +1 -0
  4. package/dist/adapter.js +2 -0
  5. package/dist/adapter.js.map +1 -0
  6. package/dist/additional-fields.d.ts +31 -0
  7. package/dist/additional-fields.d.ts.map +1 -0
  8. package/dist/additional-fields.js +2 -0
  9. package/dist/additional-fields.js.map +1 -0
  10. package/dist/auth.d.ts +26 -0
  11. package/dist/auth.d.ts.map +1 -0
  12. package/dist/auth.js +280 -0
  13. package/dist/auth.js.map +1 -0
  14. package/dist/config.d.ts +55 -0
  15. package/dist/config.d.ts.map +1 -0
  16. package/dist/config.js +2 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/cookies.d.ts +29 -0
  19. package/dist/cookies.d.ts.map +1 -0
  20. package/dist/cookies.js +98 -0
  21. package/dist/cookies.js.map +1 -0
  22. package/dist/crypto.d.ts +23 -0
  23. package/dist/crypto.d.ts.map +1 -0
  24. package/dist/crypto.js +57 -0
  25. package/dist/crypto.js.map +1 -0
  26. package/dist/csrf.d.ts +6 -0
  27. package/dist/csrf.d.ts.map +1 -0
  28. package/dist/csrf.js +24 -0
  29. package/dist/csrf.js.map +1 -0
  30. package/dist/encryption.d.ts +10 -0
  31. package/dist/encryption.d.ts.map +1 -0
  32. package/dist/encryption.js +61 -0
  33. package/dist/encryption.js.map +1 -0
  34. package/dist/errors.d.ts +29 -0
  35. package/dist/errors.d.ts.map +1 -0
  36. package/dist/errors.js +57 -0
  37. package/dist/errors.js.map +1 -0
  38. package/dist/hooks.d.ts +45 -0
  39. package/dist/hooks.d.ts.map +1 -0
  40. package/dist/hooks.js +2 -0
  41. package/dist/hooks.js.map +1 -0
  42. package/dist/http.d.ts +23 -0
  43. package/dist/http.d.ts.map +1 -0
  44. package/dist/http.js +126 -0
  45. package/dist/http.js.map +1 -0
  46. package/dist/index.d.ts +23 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/index.js +53 -0
  49. package/dist/index.js.map +1 -0
  50. package/dist/provider.d.ts +19 -0
  51. package/dist/provider.d.ts.map +1 -0
  52. package/dist/provider.js +2 -0
  53. package/dist/provider.js.map +1 -0
  54. package/dist/rate-limiter.d.ts +33 -0
  55. package/dist/rate-limiter.d.ts.map +1 -0
  56. package/dist/rate-limiter.js +45 -0
  57. package/dist/rate-limiter.js.map +1 -0
  58. package/dist/test/adapter-conformance.d.ts +13 -0
  59. package/dist/test/adapter-conformance.d.ts.map +1 -0
  60. package/dist/test/adapter-conformance.js +212 -0
  61. package/dist/test/adapter-conformance.js.map +1 -0
  62. package/dist/test/cookies.test.d.ts +2 -0
  63. package/dist/test/cookies.test.d.ts.map +1 -0
  64. package/dist/test/cookies.test.js +83 -0
  65. package/dist/test/cookies.test.js.map +1 -0
  66. package/dist/test/crypto.test.d.ts +2 -0
  67. package/dist/test/crypto.test.d.ts.map +1 -0
  68. package/dist/test/crypto.test.js +84 -0
  69. package/dist/test/crypto.test.js.map +1 -0
  70. package/dist/test/encryption.test.d.ts +2 -0
  71. package/dist/test/encryption.test.d.ts.map +1 -0
  72. package/dist/test/encryption.test.js +34 -0
  73. package/dist/test/encryption.test.js.map +1 -0
  74. package/dist/test/factories.d.ts +11 -0
  75. package/dist/test/factories.d.ts.map +1 -0
  76. package/dist/test/factories.js +110 -0
  77. package/dist/test/factories.js.map +1 -0
  78. package/dist/test/index.d.ts +6 -0
  79. package/dist/test/index.d.ts.map +1 -0
  80. package/dist/test/index.js +4 -0
  81. package/dist/test/index.js.map +1 -0
  82. package/dist/test/mock-provider.d.ts +34 -0
  83. package/dist/test/mock-provider.d.ts.map +1 -0
  84. package/dist/test/mock-provider.js +99 -0
  85. package/dist/test/mock-provider.js.map +1 -0
  86. package/dist/test/rate-limiter.test.d.ts +2 -0
  87. package/dist/test/rate-limiter.test.d.ts.map +1 -0
  88. package/dist/test/rate-limiter.test.js +38 -0
  89. package/dist/test/rate-limiter.test.js.map +1 -0
  90. package/dist/test/url.test.d 2.ts +2 -0
  91. package/dist/test/url.test.d.ts +2 -0
  92. package/dist/test/url.test.d.ts 2.map +1 -0
  93. package/dist/test/url.test.d.ts.map +1 -0
  94. package/dist/test/url.test.js +40 -0
  95. package/dist/test/url.test.js 2.map +1 -0
  96. package/dist/test/url.test.js.map +1 -0
  97. package/dist/types.d.ts +109 -0
  98. package/dist/types.d.ts.map +1 -0
  99. package/dist/types.js +2 -0
  100. package/dist/types.js.map +1 -0
  101. package/dist/url.d.ts +11 -0
  102. package/dist/url.d.ts.map +1 -0
  103. package/dist/url.js +35 -0
  104. package/dist/url.js.map +1 -0
  105. package/package.json +27 -0
  106. package/src/adapter.ts +39 -0
  107. package/src/additional-fields.ts +53 -0
  108. package/src/auth.ts +355 -0
  109. package/src/config.ts +62 -0
  110. package/src/cookies.ts +123 -0
  111. package/src/crypto.ts +62 -0
  112. package/src/csrf.ts +27 -0
  113. package/src/encryption.ts +88 -0
  114. package/src/errors.ts +65 -0
  115. package/src/hooks.ts +54 -0
  116. package/src/http.ts +192 -0
  117. package/src/index.ts +161 -0
  118. package/src/provider.ts +21 -0
  119. package/src/rate-limiter.ts +64 -0
  120. package/src/test/adapter-conformance.ts +267 -0
  121. package/src/test/cookies.test.ts +99 -0
  122. package/src/test/crypto.test.ts +103 -0
  123. package/src/test/encryption.test.ts +40 -0
  124. package/src/test/factories.ts +139 -0
  125. package/src/test/index.ts +17 -0
  126. package/src/test/mock-provider.ts +130 -0
  127. package/src/test/rate-limiter.test.ts +49 -0
  128. package/src/test/url.test.ts +48 -0
  129. package/src/types.ts +122 -0
  130. package/src/url.ts +35 -0
  131. package/tsconfig 2.tsbuildinfo +1 -0
  132. package/tsconfig.json +11 -0
  133. package/tsconfig.tsbuildinfo +1 -0
  134. package/vitest.config.ts +14 -0
package/src/adapter.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type {
2
+ User,
3
+ Account,
4
+ Session,
5
+ SessionWithUser,
6
+ OAuthState,
7
+ CreateUserInput,
8
+ UpdateUserInput,
9
+ CreateAccountInput,
10
+ CreateSessionInput,
11
+ UpdateSessionInput,
12
+ CreateOAuthStateInput,
13
+ } from './types.js'
14
+
15
+ export interface AuthAdapter {
16
+ // User operations
17
+ createUser(data: CreateUserInput): Promise<User>
18
+ getUserById(id: string): Promise<User | null>
19
+ getUserByEmail(email: string): Promise<User | null>
20
+ updateUser(id: string, data: UpdateUserInput): Promise<User>
21
+
22
+ // Account operations
23
+ createAccount(data: CreateAccountInput): Promise<Account>
24
+ getAccount(provider: string, providerAccountId: string): Promise<Account | null>
25
+ getAccountsByUserId(userId: string): Promise<Account[]>
26
+ linkAccount(userId: string, data: CreateAccountInput): Promise<Account>
27
+ deleteAccount(accountId: string): Promise<void>
28
+
29
+ // Session operations
30
+ createSession(data: CreateSessionInput): Promise<Session>
31
+ getSessionByTokenHash(tokenHash: string): Promise<SessionWithUser | null>
32
+ updateSession(id: string, data: UpdateSessionInput): Promise<Session>
33
+ deleteSession(tokenHash: string): Promise<void>
34
+ deleteExpiredSessions(): Promise<void>
35
+
36
+ // OAuth state operations
37
+ createOAuthState(data: CreateOAuthStateInput): Promise<void>
38
+ consumeOAuthState(stateHash: string): Promise<OAuthState | null>
39
+ }
@@ -0,0 +1,53 @@
1
+ import type { User } from './types.js'
2
+
3
+ // --- Field configuration ---
4
+
5
+ export interface StringField {
6
+ type: 'string'
7
+ defaultValue?: string
8
+ input?: boolean
9
+ }
10
+
11
+ export interface BooleanField {
12
+ type: 'boolean'
13
+ defaultValue?: boolean
14
+ input?: boolean
15
+ }
16
+
17
+ export interface NumberField {
18
+ type: 'number'
19
+ defaultValue?: number
20
+ input?: boolean
21
+ }
22
+
23
+ export interface DateField {
24
+ type: 'date'
25
+ defaultValue?: Date
26
+ input?: boolean
27
+ }
28
+
29
+ export type AdditionalField = StringField | BooleanField | NumberField | DateField
30
+
31
+ export type AdditionalFieldsConfig = Record<string, AdditionalField>
32
+
33
+ // --- Type inference helpers ---
34
+
35
+ export type InferFieldType<T extends AdditionalField> = T extends StringField
36
+ ? string
37
+ : T extends BooleanField
38
+ ? boolean
39
+ : T extends NumberField
40
+ ? number
41
+ : T extends DateField
42
+ ? Date
43
+ : unknown
44
+
45
+ export type InferAdditionalFields<T extends AdditionalFieldsConfig> = {
46
+ [K in keyof T]: InferFieldType<T[K]>
47
+ } & {}
48
+
49
+ // --- $Infer utility ---
50
+
51
+ export interface InferTypes<TAdditional extends AdditionalFieldsConfig = Record<string, never>> {
52
+ User: User & InferAdditionalFields<TAdditional>
53
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,355 @@
1
+ import type { AuthConfig } from './config.js'
2
+ import type { AuthProvider } from './provider.js'
3
+ import type { SessionWithUser, User, Session } from './types.js'
4
+ import {
5
+ generateSessionToken,
6
+ hashToken,
7
+ generateCodeVerifier,
8
+ generateCodeChallenge,
9
+ } from './crypto.js'
10
+ import { signCookie, verifyCookie, serializeCookie } from './cookies.js'
11
+ import type { HooksConfig } from './hooks.js'
12
+ import { InvalidStateError } from './errors.js'
13
+
14
+ export interface AuthResponse {
15
+ session: Session
16
+ user: User
17
+ cookieHeader: string
18
+ }
19
+
20
+ /**
21
+ * Generate the authorization URL to redirect the user to the provider.
22
+ */
23
+ export async function createAuthUrl(
24
+ config: AuthConfig,
25
+ providerId: string,
26
+ _request?: Request,
27
+ ): Promise<URL> {
28
+ const provider = findProvider(config.providers, providerId)
29
+ const adapter = config.database
30
+
31
+ const codeVerifier = generateCodeVerifier()
32
+ const state = generateSessionToken()
33
+ const stateHash = await hashToken(state)
34
+ const codeChallenge = await generateCodeChallenge(codeVerifier)
35
+
36
+ const callbackUrl = buildCallbackUrl(config, providerId)
37
+ const redirectUri = buildRedirectUri(config, providerId)
38
+
39
+ // Persist OAuth state for callback validation
40
+ await adapter.createOAuthState({
41
+ stateHash,
42
+ codeVerifier,
43
+ provider: providerId,
44
+ callbackUrl,
45
+ errorUrl: undefined,
46
+ expiresAt: new Date(Date.now() + 600_000), // 10 minute expiry
47
+ })
48
+
49
+ const authUrl = provider.authorizationUrl({
50
+ state,
51
+ codeVerifier: codeChallenge,
52
+ redirectUri,
53
+ })
54
+
55
+ return authUrl
56
+ }
57
+
58
+ /**
59
+ * Handle the OAuth callback from the provider.
60
+ */
61
+ export async function handleCallback(
62
+ config: AuthConfig,
63
+ providerId: string,
64
+ request: Request,
65
+ hooks?: HooksConfig,
66
+ ): Promise<AuthResponse> {
67
+ const provider = findProvider(config.providers, providerId)
68
+ const adapter = config.database
69
+ const secret = config.security?.secret
70
+ const sessionConfig = config.session ?? {}
71
+
72
+ const url = new URL(request.url)
73
+ const code = url.searchParams.get('code')
74
+ const state = url.searchParams.get('state')
75
+ const error = url.searchParams.get('error')
76
+
77
+ // Handle provider error (user denied consent, etc.)
78
+ if (error) {
79
+ throw new Error(`Provider returned error: ${error}`)
80
+ }
81
+
82
+ if (!code || !state) {
83
+ throw new InvalidStateError('Missing code or state parameter')
84
+ }
85
+
86
+ // Validate and consume OAuth state (single-use)
87
+ const stateHash = await hashToken(state)
88
+ const storedState = await adapter.consumeOAuthState(stateHash)
89
+
90
+ if (!storedState) {
91
+ throw new InvalidStateError()
92
+ }
93
+
94
+ // Exchange code for tokens
95
+ const redirectUri = buildRedirectUri(config, providerId)
96
+ const tokenSet = await provider.exchangeCode({
97
+ code,
98
+ codeVerifier: storedState.codeVerifier,
99
+ redirectUri,
100
+ })
101
+
102
+ // Fetch provider profile
103
+ const profile = await provider.getProfile(tokenSet)
104
+
105
+ // Run beforeSignIn hook (if configured)
106
+ if (hooks?.beforeSignIn) {
107
+ await hooks.beforeSignIn({ adapter, profile, request })
108
+ }
109
+
110
+ // Find or create user
111
+ const existingAccount = await adapter.getAccount(providerId, profile.providerAccountId)
112
+ let user: User
113
+
114
+ if (existingAccount) {
115
+ // Existing account — get the user
116
+ const existingUser = await adapter.getUserById(existingAccount.userId)
117
+ if (!existingUser) {
118
+ throw new Error('User not found for existing account')
119
+ }
120
+ user = existingUser
121
+ } else {
122
+ // New account — find or create user by email
123
+ const existingUser = profile.email ? await adapter.getUserByEmail(profile.email) : null
124
+
125
+ if (existingUser) {
126
+ user = existingUser
127
+ // Link the new provider account
128
+ const linkedAccount = await adapter.linkAccount(user.id, {
129
+ userId: user.id,
130
+ provider: providerId,
131
+ providerAccountId: profile.providerAccountId,
132
+ accessToken: config.security?.storeProviderTokens ? tokenSet.accessToken : null,
133
+ refreshToken: config.security?.storeProviderTokens ? (tokenSet.refreshToken ?? null) : null,
134
+ idToken: config.security?.storeProviderTokens ? (tokenSet.idToken ?? null) : null,
135
+ scope: tokenSet.scope ?? null,
136
+ tokenType: tokenSet.tokenType ?? null,
137
+ expiresAt: tokenSet.expiresAt ? new Date(Date.now() + tokenSet.expiresAt * 1000) : null,
138
+ })
139
+
140
+ if (hooks?.onAccountLinked) {
141
+ await hooks.onAccountLinked({ adapter, user, account: linkedAccount })
142
+ }
143
+ } else {
144
+ // Create new user
145
+ user = await adapter.createUser({
146
+ email: profile.email ?? null,
147
+ emailVerified: profile.emailVerified ?? false,
148
+ name: profile.name ?? null,
149
+ image: profile.image ?? null,
150
+ })
151
+
152
+ // Create account
153
+ await adapter.createAccount({
154
+ userId: user.id,
155
+ provider: providerId,
156
+ providerAccountId: profile.providerAccountId,
157
+ accessToken: config.security?.storeProviderTokens ? tokenSet.accessToken : null,
158
+ refreshToken: config.security?.storeProviderTokens ? (tokenSet.refreshToken ?? null) : null,
159
+ idToken: config.security?.storeProviderTokens ? (tokenSet.idToken ?? null) : null,
160
+ scope: tokenSet.scope ?? null,
161
+ tokenType: tokenSet.tokenType ?? null,
162
+ expiresAt: tokenSet.expiresAt ? new Date(Date.now() + tokenSet.expiresAt * 1000) : null,
163
+ })
164
+
165
+ if (hooks?.onUserCreated) {
166
+ await hooks.onUserCreated({ adapter, user })
167
+ }
168
+ }
169
+ }
170
+
171
+ // Run afterSignIn hook
172
+ if (hooks?.afterSignIn) {
173
+ const fakeSession = {
174
+ id: '',
175
+ userId: user.id,
176
+ tokenHash: '',
177
+ expiresAt: new Date(),
178
+ ipAddress: null,
179
+ userAgent: null,
180
+ createdAt: new Date(),
181
+ updatedAt: new Date(),
182
+ }
183
+ await hooks.afterSignIn({ adapter, session: fakeSession, user, profile })
184
+ }
185
+
186
+ // Create session
187
+ const sessionToken = generateSessionToken()
188
+ const tokenHash = await hashToken(sessionToken)
189
+ const expiresInMs = (sessionConfig.expiresIn ?? 30 * 24 * 60 * 60) * 1000
190
+
191
+ if (hooks?.beforeSessionCreated) {
192
+ await hooks.beforeSessionCreated({ adapter, user })
193
+ }
194
+
195
+ const session = await adapter.createSession({
196
+ userId: user.id,
197
+ tokenHash,
198
+ expiresAt: new Date(Date.now() + expiresInMs),
199
+ ipAddress: request.headers.get('x-forwarded-for') ?? request.headers.get('x-real-ip') ?? null,
200
+ userAgent: request.headers.get('user-agent') ?? null,
201
+ })
202
+
203
+ if (hooks?.afterSessionCreated) {
204
+ await hooks.afterSessionCreated({ adapter, session, user })
205
+ }
206
+
207
+ // Sign the session cookie
208
+ const cookieName = sessionConfig.cookieName ?? 'cloud_auth_session'
209
+ const cookieSecret = secret
210
+ if (!cookieSecret) {
211
+ throw new Error('cloudAuth: security.secret is required for cookie signing')
212
+ }
213
+
214
+ const signedToken = await signCookie(sessionToken, cookieSecret)
215
+ const cookieHeader = serializeCookie(cookieName, signedToken, {
216
+ maxAge: sessionConfig.expiresIn ?? 30 * 24 * 60 * 60,
217
+ path: '/',
218
+ secure: sessionConfig.secure ?? true,
219
+ httpOnly: sessionConfig.httpOnly ?? true,
220
+ sameSite: sessionConfig.sameSite ?? 'lax',
221
+ })
222
+
223
+ return { session, user, cookieHeader }
224
+ }
225
+
226
+ /**
227
+ * Get the current session from a request.
228
+ * Validates the session cookie, checks expiry, and returns session with user.
229
+ */
230
+ export async function getSession(
231
+ config: AuthConfig,
232
+ request: Request,
233
+ ): Promise<SessionWithUser | null> {
234
+ const adapter = config.database
235
+ const secret = config.security?.secret
236
+ const sessionConfig = config.session ?? {}
237
+ const cookieName = sessionConfig.cookieName ?? 'cloud_auth_session'
238
+
239
+ if (!secret) {
240
+ throw new Error('cloudAuth: security.secret is required')
241
+ }
242
+
243
+ const cookieHeader = request.headers.get('cookie')
244
+ if (!cookieHeader) return null
245
+
246
+ const cookies = parseCookies(cookieHeader)
247
+ const signedToken = cookies[cookieName]
248
+ if (!signedToken) return null
249
+
250
+ const token = await verifyCookie(signedToken, secret)
251
+ if (!token) return null
252
+
253
+ const tokenHash = await hashToken(token)
254
+ const sessionWithUser = await adapter.getSessionByTokenHash(tokenHash)
255
+ if (!sessionWithUser) return null
256
+
257
+ // Check expiry
258
+ if (sessionWithUser.session.expiresAt < new Date()) {
259
+ await adapter.deleteSession(tokenHash)
260
+ return null
261
+ }
262
+
263
+ // Rolling expiration
264
+ const updateAge = sessionConfig.updateAge
265
+ if (updateAge && sessionWithUser.session.expiresAt.getTime() - Date.now() < updateAge * 1000) {
266
+ const newExpiresAt = new Date(
267
+ Date.now() + (sessionConfig.expiresIn ?? 30 * 24 * 60 * 60) * 1000,
268
+ )
269
+ await adapter.updateSession(sessionWithUser.session.id, { expiresAt: newExpiresAt })
270
+ sessionWithUser.session.expiresAt = newExpiresAt
271
+ }
272
+
273
+ return sessionWithUser
274
+ }
275
+
276
+ /**
277
+ * Sign out a user by deleting their session and returning a cleared cookie.
278
+ */
279
+ export async function signOut(
280
+ config: AuthConfig,
281
+ request: Request,
282
+ hooks?: HooksConfig,
283
+ ): Promise<string> {
284
+ const adapter = config.database
285
+ const secret = config.security?.secret
286
+ const sessionConfig = config.session ?? {}
287
+ const cookieName = sessionConfig.cookieName ?? 'cloud_auth_session'
288
+
289
+ if (!secret) {
290
+ throw new Error('cloudAuth: security.secret is required')
291
+ }
292
+
293
+ const cookieHeader = request.headers.get('cookie')
294
+ if (cookieHeader) {
295
+ const cookies = parseCookies(cookieHeader)
296
+ const signedToken = cookies[cookieName]
297
+
298
+ if (signedToken) {
299
+ const token = await verifyCookie(signedToken, secret)
300
+ if (token) {
301
+ const tokenHash = await hashToken(token)
302
+ const sessionWithUser = await adapter.getSessionByTokenHash(tokenHash)
303
+
304
+ if (sessionWithUser && hooks?.beforeLogout) {
305
+ await hooks.beforeLogout({ adapter, session: sessionWithUser.session })
306
+ }
307
+
308
+ await adapter.deleteSession(tokenHash)
309
+
310
+ if (sessionWithUser && hooks?.afterLogout) {
311
+ await hooks.afterLogout({ adapter, session: sessionWithUser.session })
312
+ }
313
+ }
314
+ }
315
+ }
316
+
317
+ return serializeCookie(cookieName, '', {
318
+ maxAge: 0,
319
+ path: '/',
320
+ secure: sessionConfig.secure ?? true,
321
+ httpOnly: sessionConfig.httpOnly ?? true,
322
+ sameSite: sessionConfig.sameSite ?? 'lax',
323
+ })
324
+ }
325
+
326
+ // --- Internal helpers ---
327
+
328
+ function findProvider(providers: AuthProvider[], id: string): AuthProvider {
329
+ const provider = providers.find((p) => p.id === id)
330
+ if (!provider) {
331
+ throw new Error(`Provider "${id}" not found`)
332
+ }
333
+ return provider
334
+ }
335
+
336
+ function buildCallbackUrl(config: AuthConfig, providerId: string): string {
337
+ const base = config.baseURL.replace(/\/+$/, '')
338
+ return `${base}/auth/${providerId}/callback`
339
+ }
340
+
341
+ function buildRedirectUri(config: AuthConfig, providerId: string): string {
342
+ return buildCallbackUrl(config, providerId)
343
+ }
344
+
345
+ function parseCookies(header: string): Record<string, string> {
346
+ const result: Record<string, string> = {}
347
+ for (const pair of header.split(';')) {
348
+ const idx = pair.indexOf('=')
349
+ if (idx === -1) continue
350
+ const name = pair.slice(0, idx).trim()
351
+ const value = pair.slice(idx + 1).trim()
352
+ if (name) result[decodeURIComponent(name)] = decodeURIComponent(value)
353
+ }
354
+ return result
355
+ }
package/src/config.ts ADDED
@@ -0,0 +1,62 @@
1
+ import type { AuthProvider } from './provider.js'
2
+ import type { AuthAdapter } from './adapter.js'
3
+ import type { AdditionalFieldsConfig } from './additional-fields.js'
4
+ import type { HooksConfig } from './hooks.js'
5
+
6
+ export interface SessionConfig {
7
+ cookieName?: string
8
+ expiresIn?: number
9
+ updateAge?: number
10
+ sameSite?: 'lax' | 'strict' | 'none'
11
+ secure?: boolean
12
+ httpOnly?: boolean
13
+ }
14
+
15
+ export interface UserConfig {
16
+ additionalFields?: AdditionalFieldsConfig
17
+ mapProfileToUser?: (
18
+ profile: import('./types.js').ProviderProfile,
19
+ ) => Promise<Record<string, unknown>>
20
+ }
21
+
22
+ export interface AccountLinkingConfig {
23
+ enabled?: boolean
24
+ trustedProviders?: string[]
25
+ requireVerifiedEmail?: boolean
26
+ providerTrust?: Record<string, 'high' | 'medium' | 'low'>
27
+ }
28
+
29
+ export interface SecurityConfig {
30
+ storeProviderTokens?: boolean
31
+ encryptProviderTokens?: boolean
32
+ pkce?: boolean
33
+ secret?: string
34
+ providerHttp?: {
35
+ timeout?: number
36
+ retries?: number
37
+ retryDelay?: number
38
+ }
39
+ }
40
+
41
+ export interface AuthConfig {
42
+ appName: string
43
+ baseURL: string
44
+ trustedOrigins?: string[]
45
+ providers: AuthProvider[]
46
+ database: AuthAdapter
47
+ session?: SessionConfig
48
+ user?: UserConfig
49
+ accountLinking?: AccountLinkingConfig
50
+ security?: SecurityConfig
51
+ hooks?: HooksConfig
52
+ }
53
+
54
+ export interface CloudAuthInstance {
55
+ $Infer: {
56
+ User: import('./types.js').User
57
+ }
58
+ createAuthUrl(providerId: string, request?: Request): Promise<URL>
59
+ handleCallback(providerId: string, request: Request): Promise<import('./auth.js').AuthResponse>
60
+ getSession(request: Request): Promise<import('./types.js').SessionWithUser | null>
61
+ signOut(request: Request): Promise<string>
62
+ }
package/src/cookies.ts ADDED
@@ -0,0 +1,123 @@
1
+ import { constantTimeEqual } from './crypto.js'
2
+
3
+ export interface CookieOptions {
4
+ maxAge?: number
5
+ expires?: Date
6
+ path?: string
7
+ domain?: string
8
+ secure?: boolean
9
+ httpOnly?: boolean
10
+ sameSite?: 'lax' | 'strict' | 'none'
11
+ }
12
+
13
+ /**
14
+ * Serialize a cookie name-value pair into a Set-Cookie header string.
15
+ */
16
+ export function serializeCookie(name: string, value: string, options?: CookieOptions): string {
17
+ let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`
18
+
19
+ if (options?.maxAge !== undefined) {
20
+ cookie += `; Max-Age=${String(options.maxAge)}`
21
+ }
22
+ if (options?.expires) {
23
+ cookie += `; Expires=${options.expires.toUTCString()}`
24
+ }
25
+ if (options?.path) {
26
+ cookie += `; Path=${options.path}`
27
+ }
28
+ if (options?.domain) {
29
+ cookie += `; Domain=${options.domain}`
30
+ }
31
+ if (options?.secure) {
32
+ cookie += `; Secure`
33
+ }
34
+ if (options?.httpOnly) {
35
+ cookie += `; HttpOnly`
36
+ }
37
+ if (options?.sameSite) {
38
+ cookie += `; SameSite=${options.sameSite}`
39
+ }
40
+
41
+ return cookie
42
+ }
43
+
44
+ /**
45
+ * Parse a Cookie header string into a key-value record.
46
+ * Ignores known cookie attribute names (Path, Domain, Secure, etc.).
47
+ */
48
+ export function parseCookie(header: string): Record<string, string> {
49
+ const result: Record<string, string> = {}
50
+ const knownAttributes = new Set([
51
+ 'path',
52
+ 'domain',
53
+ 'secure',
54
+ 'httponly',
55
+ 'samesite',
56
+ 'max-age',
57
+ 'expires',
58
+ ])
59
+
60
+ for (const pair of header.split(';')) {
61
+ const separatorIndex = pair.indexOf('=')
62
+ if (separatorIndex === -1) continue
63
+
64
+ const rawName = pair.slice(0, separatorIndex).trim()
65
+ const rawValue = pair.slice(separatorIndex + 1).trim()
66
+ if (!rawName) continue
67
+
68
+ const name = decodeURIComponent(rawName)
69
+ // Skip known cookie attribute names
70
+ if (knownAttributes.has(name.toLowerCase())) continue
71
+
72
+ result[name] = decodeURIComponent(rawValue)
73
+ }
74
+
75
+ return result
76
+ }
77
+
78
+ // Cache HMAC keys per secret to avoid re-importing on every sign/verify
79
+ const hmacKeyCache = new Map<string, Promise<CryptoKey>>()
80
+
81
+ async function getHmacKey(secret: string): Promise<CryptoKey> {
82
+ const cached = hmacKeyCache.get(secret)
83
+ if (cached) return cached
84
+
85
+ const keyPromise = crypto.subtle.importKey(
86
+ 'raw',
87
+ new TextEncoder().encode(secret),
88
+ { name: 'HMAC', hash: 'SHA-256' },
89
+ false,
90
+ ['sign', 'verify'],
91
+ )
92
+ hmacKeyCache.set(secret, keyPromise)
93
+ return keyPromise
94
+ }
95
+
96
+ /**
97
+ * Sign a cookie value using HMAC-SHA256.
98
+ * Returns a string in the format: `value.base64url-signature`.
99
+ */
100
+ export async function signCookie(value: string, secret: string): Promise<string> {
101
+ const key = await getHmacKey(secret)
102
+ const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(value))
103
+ const encoded = btoa(String.fromCharCode(...new Uint8Array(signature)))
104
+ .replace(/\+/g, '-')
105
+ .replace(/\//g, '_')
106
+ .replace(/=+$/, '')
107
+ return `${value}.${encoded}`
108
+ }
109
+
110
+ /**
111
+ * Verify a signed cookie value.
112
+ * Returns the original value if the signature is valid, or null if tampered.
113
+ */
114
+ export async function verifyCookie(signed: string, secret: string): Promise<string | null> {
115
+ const lastDot = signed.lastIndexOf('.')
116
+ if (lastDot === -1) return null
117
+
118
+ const value = signed.slice(0, lastDot)
119
+ if (!value) return null
120
+
121
+ const expected = await signCookie(value, secret)
122
+ return constantTimeEqual(signed, expected) ? value : null
123
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,62 @@
1
+ function base64url(bytes: Uint8Array): string {
2
+ return btoa(String.fromCharCode(...bytes))
3
+ .replace(/\+/g, '-')
4
+ .replace(/\//g, '_')
5
+ .replace(/=+$/, '')
6
+ }
7
+
8
+ function hex(bytes: Uint8Array): string {
9
+ return Array.from(bytes)
10
+ .map((b) => b.toString(16).padStart(2, '0'))
11
+ .join('')
12
+ }
13
+
14
+ /**
15
+ * Generate a cryptographically secure random session token (256-bit).
16
+ * Returns a base64url-encoded string.
17
+ */
18
+ export function generateSessionToken(): string {
19
+ const bytes = new Uint8Array(32)
20
+ crypto.getRandomValues(bytes)
21
+ return base64url(bytes)
22
+ }
23
+
24
+ /**
25
+ * Hash a session token using SHA-256.
26
+ * Returns a hex-encoded string for database storage.
27
+ */
28
+ export async function hashToken(token: string): Promise<string> {
29
+ const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token))
30
+ return hex(new Uint8Array(hash))
31
+ }
32
+
33
+ /**
34
+ * Generate a PKCE code verifier (64 random bytes, base64url encoded).
35
+ */
36
+ export function generateCodeVerifier(): string {
37
+ const bytes = new Uint8Array(64)
38
+ crypto.getRandomValues(bytes)
39
+ return base64url(bytes)
40
+ }
41
+
42
+ /**
43
+ * Generate an S256 PKCE code challenge from a code verifier.
44
+ */
45
+ export async function generateCodeChallenge(verifier: string): Promise<string> {
46
+ const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
47
+ return base64url(new Uint8Array(hash))
48
+ }
49
+
50
+ /**
51
+ * Compare two strings in constant time to prevent timing attacks.
52
+ */
53
+ export function constantTimeEqual(a: string, b: string): boolean {
54
+ if (a.length !== b.length) {
55
+ return false
56
+ }
57
+ let result = 0
58
+ for (let i = 0; i < a.length; i++) {
59
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i)
60
+ }
61
+ return result === 0
62
+ }