@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.
- package/LICENSE +21 -0
- package/dist/adapter.d.ts +20 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +2 -0
- package/dist/adapter.js.map +1 -0
- package/dist/additional-fields.d.ts +31 -0
- package/dist/additional-fields.d.ts.map +1 -0
- package/dist/additional-fields.js +2 -0
- package/dist/additional-fields.js.map +1 -0
- package/dist/auth.d.ts +26 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +280 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +55 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -0
- package/dist/cookies.d.ts +29 -0
- package/dist/cookies.d.ts.map +1 -0
- package/dist/cookies.js +98 -0
- package/dist/cookies.js.map +1 -0
- package/dist/crypto.d.ts +23 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +57 -0
- package/dist/crypto.js.map +1 -0
- package/dist/csrf.d.ts +6 -0
- package/dist/csrf.d.ts.map +1 -0
- package/dist/csrf.js +24 -0
- package/dist/csrf.js.map +1 -0
- package/dist/encryption.d.ts +10 -0
- package/dist/encryption.d.ts.map +1 -0
- package/dist/encryption.js +61 -0
- package/dist/encryption.js.map +1 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +57 -0
- package/dist/errors.js.map +1 -0
- package/dist/hooks.d.ts +45 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +2 -0
- package/dist/hooks.js.map +1 -0
- package/dist/http.d.ts +23 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +126 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +53 -0
- package/dist/index.js.map +1 -0
- package/dist/provider.d.ts +19 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +2 -0
- package/dist/provider.js.map +1 -0
- package/dist/rate-limiter.d.ts +33 -0
- package/dist/rate-limiter.d.ts.map +1 -0
- package/dist/rate-limiter.js +45 -0
- package/dist/rate-limiter.js.map +1 -0
- package/dist/test/adapter-conformance.d.ts +13 -0
- package/dist/test/adapter-conformance.d.ts.map +1 -0
- package/dist/test/adapter-conformance.js +212 -0
- package/dist/test/adapter-conformance.js.map +1 -0
- package/dist/test/cookies.test.d.ts +2 -0
- package/dist/test/cookies.test.d.ts.map +1 -0
- package/dist/test/cookies.test.js +83 -0
- package/dist/test/cookies.test.js.map +1 -0
- package/dist/test/crypto.test.d.ts +2 -0
- package/dist/test/crypto.test.d.ts.map +1 -0
- package/dist/test/crypto.test.js +84 -0
- package/dist/test/crypto.test.js.map +1 -0
- package/dist/test/encryption.test.d.ts +2 -0
- package/dist/test/encryption.test.d.ts.map +1 -0
- package/dist/test/encryption.test.js +34 -0
- package/dist/test/encryption.test.js.map +1 -0
- package/dist/test/factories.d.ts +11 -0
- package/dist/test/factories.d.ts.map +1 -0
- package/dist/test/factories.js +110 -0
- package/dist/test/factories.js.map +1 -0
- package/dist/test/index.d.ts +6 -0
- package/dist/test/index.d.ts.map +1 -0
- package/dist/test/index.js +4 -0
- package/dist/test/index.js.map +1 -0
- package/dist/test/mock-provider.d.ts +34 -0
- package/dist/test/mock-provider.d.ts.map +1 -0
- package/dist/test/mock-provider.js +99 -0
- package/dist/test/mock-provider.js.map +1 -0
- package/dist/test/rate-limiter.test.d.ts +2 -0
- package/dist/test/rate-limiter.test.d.ts.map +1 -0
- package/dist/test/rate-limiter.test.js +38 -0
- package/dist/test/rate-limiter.test.js.map +1 -0
- package/dist/test/url.test.d 2.ts +2 -0
- package/dist/test/url.test.d.ts +2 -0
- package/dist/test/url.test.d.ts 2.map +1 -0
- package/dist/test/url.test.d.ts.map +1 -0
- package/dist/test/url.test.js +40 -0
- package/dist/test/url.test.js 2.map +1 -0
- package/dist/test/url.test.js.map +1 -0
- package/dist/types.d.ts +109 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/url.d.ts +11 -0
- package/dist/url.d.ts.map +1 -0
- package/dist/url.js +35 -0
- package/dist/url.js.map +1 -0
- package/package.json +27 -0
- package/src/adapter.ts +39 -0
- package/src/additional-fields.ts +53 -0
- package/src/auth.ts +355 -0
- package/src/config.ts +62 -0
- package/src/cookies.ts +123 -0
- package/src/crypto.ts +62 -0
- package/src/csrf.ts +27 -0
- package/src/encryption.ts +88 -0
- package/src/errors.ts +65 -0
- package/src/hooks.ts +54 -0
- package/src/http.ts +192 -0
- package/src/index.ts +161 -0
- package/src/provider.ts +21 -0
- package/src/rate-limiter.ts +64 -0
- package/src/test/adapter-conformance.ts +267 -0
- package/src/test/cookies.test.ts +99 -0
- package/src/test/crypto.test.ts +103 -0
- package/src/test/encryption.test.ts +40 -0
- package/src/test/factories.ts +139 -0
- package/src/test/index.ts +17 -0
- package/src/test/mock-provider.ts +130 -0
- package/src/test/rate-limiter.test.ts +49 -0
- package/src/test/url.test.ts +48 -0
- package/src/types.ts +122 -0
- package/src/url.ts +35 -0
- package/tsconfig 2.tsbuildinfo +1 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
- 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
|
+
}
|