@avelonjs/neon 0.3.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/README.md +351 -0
- package/package.json +56 -0
- package/src/database/driver.ts +263 -0
- package/src/database/index.ts +8 -0
- package/src/identity/cookies.ts +54 -0
- package/src/identity/driver.ts +462 -0
- package/src/identity/errors.ts +206 -0
- package/src/identity/index.ts +15 -0
- package/src/identity/local-auth.ts +304 -0
- package/src/identity/types.ts +19 -0
- package/src/index.ts +6 -0
- package/src/queue/driver.ts +271 -0
- package/src/queue/index.ts +1 -0
- package/src/social/driver.ts +118 -0
- package/src/social/index.ts +8 -0
- package/src/social/local-auth.ts +55 -0
- package/src/social/types.ts +7 -0
- package/src/storage/driver.ts +182 -0
- package/src/storage/index.ts +7 -0
- package/src/storage/local-s3.ts +128 -0
- package/src/tokens/driver.ts +220 -0
- package/src/tokens/index.ts +6 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Invalid,
|
|
3
|
+
Unauthenticated,
|
|
4
|
+
type EmailVerificationIdentitySurface,
|
|
5
|
+
type IdentityDriver,
|
|
6
|
+
type MagicLinkIdentitySurface,
|
|
7
|
+
type MfaChallenge,
|
|
8
|
+
type MfaIdentitySurface,
|
|
9
|
+
type PasswordIdentitySurface,
|
|
10
|
+
type RequestCookies,
|
|
11
|
+
} from '@avelonjs/core'
|
|
12
|
+
import {
|
|
13
|
+
clearSessionCookie,
|
|
14
|
+
DEFAULT_SESSION_COOKIE,
|
|
15
|
+
readSessionCookie,
|
|
16
|
+
writeSessionCookie,
|
|
17
|
+
} from './cookies'
|
|
18
|
+
import { describeAuthFailure, mapAuthError, readAuthResponse } from './errors'
|
|
19
|
+
import type { NeonActor, NeonSession } from './types'
|
|
20
|
+
|
|
21
|
+
/** Exact capability declaration for the Neon identity driver. */
|
|
22
|
+
export const neonIdentityCapabilities = {
|
|
23
|
+
passwords: true,
|
|
24
|
+
magicLinks: true,
|
|
25
|
+
oauth: false,
|
|
26
|
+
organizations: false,
|
|
27
|
+
mfa: ['totp'] as const,
|
|
28
|
+
emailVerification: true,
|
|
29
|
+
} as const
|
|
30
|
+
|
|
31
|
+
type NeonMfaFactor = (typeof neonIdentityCapabilities.mfa)[number]
|
|
32
|
+
|
|
33
|
+
/** Construction options for {@link createNeonIdentity}. */
|
|
34
|
+
export interface NeonIdentityOptions {
|
|
35
|
+
/** Neon Auth / Better Auth base URL, e.g. `https://ep-xxx.neonauth.net`. */
|
|
36
|
+
authUrl?: string
|
|
37
|
+
/** Configured identity instance name. */
|
|
38
|
+
instance?: string
|
|
39
|
+
/** Session cookie name written into the request cookie jar. */
|
|
40
|
+
cookieName?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface AuthUser {
|
|
44
|
+
id?: string
|
|
45
|
+
email?: string | null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface AuthSessionBody {
|
|
49
|
+
user?: AuthUser
|
|
50
|
+
session?: { id?: string; expiresAt?: string; token?: string }
|
|
51
|
+
token?: string
|
|
52
|
+
error?: string
|
|
53
|
+
message?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Neon Auth identity driver.
|
|
58
|
+
*
|
|
59
|
+
* Talks to the Better Auth HTTP surface Neon Auth exposes. The config-time factory receives
|
|
60
|
+
* request-scoped cookies explicitly so SSR and route handlers share one request-local identity view.
|
|
61
|
+
*/
|
|
62
|
+
export class NeonIdentity
|
|
63
|
+
implements
|
|
64
|
+
IdentityDriver<typeof neonIdentityCapabilities, { authUrl: string }, NeonActor, NeonSession>,
|
|
65
|
+
PasswordIdentitySurface<NeonActor>,
|
|
66
|
+
MagicLinkIdentitySurface,
|
|
67
|
+
MfaIdentitySurface<NeonMfaFactor>,
|
|
68
|
+
EmailVerificationIdentitySurface
|
|
69
|
+
{
|
|
70
|
+
readonly name = 'neon'
|
|
71
|
+
readonly instance: string
|
|
72
|
+
readonly capabilities = neonIdentityCapabilities
|
|
73
|
+
|
|
74
|
+
readonly #cookies: RequestCookies
|
|
75
|
+
readonly #authUrl: string
|
|
76
|
+
readonly #cookieName: string
|
|
77
|
+
|
|
78
|
+
constructor(cookies: RequestCookies, options: Required<NeonIdentityOptions>) {
|
|
79
|
+
this.#cookies = cookies
|
|
80
|
+
this.#authUrl = options.authUrl.replace(/\/$/, '')
|
|
81
|
+
this.#cookieName = options.cookieName
|
|
82
|
+
this.instance = options.instance
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
raw(): { authUrl: string } {
|
|
86
|
+
return { authUrl: this.#authUrl }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async user(): Promise<NeonActor | null> {
|
|
90
|
+
const session = readSessionCookie(this.#cookies, this.#cookieName)
|
|
91
|
+
if (!session) return null
|
|
92
|
+
const response = await this.#request('/get-session', {
|
|
93
|
+
method: 'GET',
|
|
94
|
+
headers: this.#authHeaders(session.accessToken),
|
|
95
|
+
})
|
|
96
|
+
if (!response.ok) return null
|
|
97
|
+
const body = asSessionBody((await readAuthResponse(response)).json)
|
|
98
|
+
const user = body.user
|
|
99
|
+
if (typeof user?.id !== 'string' || typeof user.email !== 'string') return null
|
|
100
|
+
return { id: user.id, email: user.email }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async session(): Promise<NeonSession | null> {
|
|
104
|
+
return readSessionCookie(this.#cookies, this.#cookieName)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async signOut(): Promise<void> {
|
|
108
|
+
const session = readSessionCookie(this.#cookies, this.#cookieName)
|
|
109
|
+
if (!session) {
|
|
110
|
+
throw new Unauthenticated('No authenticated session is present.', {
|
|
111
|
+
metadata: { guard: 'identity' },
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
await this.#request('/sign-out', {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: this.#authHeaders(session.accessToken),
|
|
117
|
+
})
|
|
118
|
+
clearSessionCookie(this.#cookies, this.#cookieName)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async register(email: string, password: string): Promise<NeonActor> {
|
|
122
|
+
const response = await this.#request('/sign-up/email', {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: this.#jsonHeaders(),
|
|
125
|
+
body: JSON.stringify({ email, password }),
|
|
126
|
+
})
|
|
127
|
+
const body = await readAuthResponse(response)
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
mapAuthError(describeAuthFailure(response, body, 'Registration failed.'), {
|
|
130
|
+
operation: 'identity.register',
|
|
131
|
+
field: 'email',
|
|
132
|
+
rejection: 'unavailable',
|
|
133
|
+
cause: body.text,
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
const parsed = asSessionBody(body.json)
|
|
137
|
+
// Better Auth answers a signup that still needs email verification with the user and a null
|
|
138
|
+
// token. The actor exists; reporting that as a failed registration says the opposite of what
|
|
139
|
+
// happened, and the retry then collides with the account the first attempt created.
|
|
140
|
+
const pending = pendingVerificationActor(parsed)
|
|
141
|
+
if (pending !== undefined) return pending
|
|
142
|
+
return this.#commitSession(parsed)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async signInWithPassword(email: string, password: string): Promise<NeonActor> {
|
|
146
|
+
const response = await this.#request('/sign-in/email', {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: this.#jsonHeaders(),
|
|
149
|
+
body: JSON.stringify({ email, password }),
|
|
150
|
+
})
|
|
151
|
+
const body = await readAuthResponse(response)
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
mapAuthError(describeAuthFailure(response, body, 'Invalid login credentials.'), {
|
|
154
|
+
operation: 'identity.signInWithPassword',
|
|
155
|
+
field: 'email',
|
|
156
|
+
rejection: 'unauthenticated',
|
|
157
|
+
cause: body.text,
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
return this.#commitSession(asSessionBody(body.json))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Asks Neon Auth to email a recovery link.
|
|
165
|
+
*
|
|
166
|
+
* Better Auth answers 200 for an address it does not know, so reporting the failure statuses does
|
|
167
|
+
* not reveal whether an account exists. Discarding them does hide a throttled project behind a
|
|
168
|
+
* screen that claims the message was sent.
|
|
169
|
+
*/
|
|
170
|
+
async sendPasswordReset(email: string): Promise<void> {
|
|
171
|
+
const response = await this.#request('/forget-password', {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
headers: this.#jsonHeaders(),
|
|
174
|
+
body: JSON.stringify({ email }),
|
|
175
|
+
})
|
|
176
|
+
if (!response.ok) {
|
|
177
|
+
const body = await readAuthResponse(response)
|
|
178
|
+
mapAuthError(describeAuthFailure(response, body, 'Password reset could not be sent.'), {
|
|
179
|
+
operation: 'identity.sendPasswordReset',
|
|
180
|
+
field: 'email',
|
|
181
|
+
rejection: 'unavailable',
|
|
182
|
+
cause: body.text,
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async resetPassword(token: string, password: string): Promise<void> {
|
|
188
|
+
const response = await this.#request('/reset-password', {
|
|
189
|
+
method: 'POST',
|
|
190
|
+
headers: this.#jsonHeaders(),
|
|
191
|
+
body: JSON.stringify({ token, newPassword: password }),
|
|
192
|
+
})
|
|
193
|
+
if (!response.ok) {
|
|
194
|
+
const body = await readAuthResponse(response)
|
|
195
|
+
mapAuthError(describeAuthFailure(response, body, 'Invalid recovery token.'), {
|
|
196
|
+
operation: 'identity.resetPassword',
|
|
197
|
+
field: 'token',
|
|
198
|
+
rejection: 'unauthenticated',
|
|
199
|
+
cause: body.text,
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async updatePassword(password: string): Promise<void> {
|
|
205
|
+
const session = readSessionCookie(this.#cookies, this.#cookieName)
|
|
206
|
+
if (!session) {
|
|
207
|
+
throw new Unauthenticated('No authenticated session is present.', {
|
|
208
|
+
metadata: { guard: 'identity' },
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
const response = await this.#request('/change-password', {
|
|
212
|
+
method: 'POST',
|
|
213
|
+
headers: { ...this.#jsonHeaders(), ...this.#authHeaders(session.accessToken) },
|
|
214
|
+
body: JSON.stringify({ newPassword: password }),
|
|
215
|
+
})
|
|
216
|
+
if (!response.ok) {
|
|
217
|
+
throw new Unauthenticated('No authenticated session is present.', {
|
|
218
|
+
metadata: { guard: 'identity' },
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async sendMagicLink(email: string, redirectTo?: string): Promise<void> {
|
|
224
|
+
const response = await this.#request('/sign-in/magic-link', {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: this.#jsonHeaders(),
|
|
227
|
+
body: JSON.stringify({
|
|
228
|
+
email,
|
|
229
|
+
...(redirectTo === undefined ? {} : { callbackURL: redirectTo }),
|
|
230
|
+
}),
|
|
231
|
+
})
|
|
232
|
+
if (!response.ok) {
|
|
233
|
+
const body = await readAuthResponse(response)
|
|
234
|
+
mapAuthError(describeAuthFailure(response, body, 'Magic link could not be sent.'), {
|
|
235
|
+
operation: 'identity.sendMagicLink',
|
|
236
|
+
field: 'email',
|
|
237
|
+
rejection: 'unavailable',
|
|
238
|
+
cause: body.text,
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Completes a sign-in from the token the emailed link carried. */
|
|
244
|
+
async signInWithMagicLink(token: string): Promise<NeonActor> {
|
|
245
|
+
const response = await this.#request(`/magic-link/verify?token=${encodeURIComponent(token)}`, {
|
|
246
|
+
method: 'GET',
|
|
247
|
+
headers: this.#jsonHeaders(),
|
|
248
|
+
})
|
|
249
|
+
const body = await readAuthResponse(response)
|
|
250
|
+
if (!response.ok) {
|
|
251
|
+
mapAuthError(describeAuthFailure(response, body, 'Invalid sign-in link.'), {
|
|
252
|
+
operation: 'identity.signInWithMagicLink',
|
|
253
|
+
field: 'token',
|
|
254
|
+
rejection: 'unauthenticated',
|
|
255
|
+
cause: body.text,
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
return this.#commitSession(asSessionBody(body.json))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async challengeMfa(factor?: NeonMfaFactor): Promise<MfaChallenge<NeonMfaFactor>> {
|
|
262
|
+
const selected: string = factor ?? 'totp'
|
|
263
|
+
if (selected !== 'totp') {
|
|
264
|
+
throw invalidField('factor', 'Unsupported multi-factor method.')
|
|
265
|
+
}
|
|
266
|
+
const accessToken = this.#requireAccessToken()
|
|
267
|
+
if (!(await this.#isTotpEnrolled(accessToken))) {
|
|
268
|
+
throw invalidField('factor', 'No TOTP factor is enrolled.')
|
|
269
|
+
}
|
|
270
|
+
const expiresAt = new Date(Date.now() + 5 * 60 * 1000)
|
|
271
|
+
return {
|
|
272
|
+
id: `totp:${expiresAt.getTime()}`,
|
|
273
|
+
factor: 'totp',
|
|
274
|
+
expiresAt,
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async verifyMfa(challengeId: string, code: string): Promise<void> {
|
|
279
|
+
const accessToken = this.#requireAccessToken()
|
|
280
|
+
const encoded = parseEncodedTotpChallenge(challengeId)
|
|
281
|
+
if (encoded.expiresAt <= Date.now()) {
|
|
282
|
+
throw invalidField('challengeId', 'MFA challenge has expired.')
|
|
283
|
+
}
|
|
284
|
+
const verified = await this.#request('/two-factor/verify-totp', {
|
|
285
|
+
method: 'POST',
|
|
286
|
+
headers: { ...this.#jsonHeaders(), ...this.#authHeaders(accessToken) },
|
|
287
|
+
body: JSON.stringify({ code }),
|
|
288
|
+
})
|
|
289
|
+
if (!verified.ok) {
|
|
290
|
+
if (verified.status === 401) {
|
|
291
|
+
throw unauthenticated('No authenticated session is present.')
|
|
292
|
+
}
|
|
293
|
+
throw invalidField('code', 'Invalid multi-factor challenge.')
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async sendEmailVerification(email?: string): Promise<void> {
|
|
298
|
+
let target = email
|
|
299
|
+
if (target === undefined) {
|
|
300
|
+
const actor = await this.user()
|
|
301
|
+
if (!actor) throw invalidField('email', 'No email to verify.')
|
|
302
|
+
target = actor.email
|
|
303
|
+
}
|
|
304
|
+
const session = readSessionCookie(this.#cookies, this.#cookieName)
|
|
305
|
+
const response = await this.#request('/send-verification-email', {
|
|
306
|
+
method: 'POST',
|
|
307
|
+
headers: {
|
|
308
|
+
...this.#jsonHeaders(),
|
|
309
|
+
...(session ? this.#authHeaders(session.accessToken) : {}),
|
|
310
|
+
},
|
|
311
|
+
body: JSON.stringify({ email: target }),
|
|
312
|
+
})
|
|
313
|
+
if (!response.ok) {
|
|
314
|
+
const body = await readAuthResponse(response)
|
|
315
|
+
mapAuthError(describeAuthFailure(response, body, 'Verification email could not be sent.'), {
|
|
316
|
+
operation: 'identity.sendEmailVerification',
|
|
317
|
+
field: 'email',
|
|
318
|
+
rejection: 'unavailable',
|
|
319
|
+
cause: body.text,
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async verifyEmail(token: string): Promise<void> {
|
|
325
|
+
const verified = await this.#request(`/verify-email?token=${encodeURIComponent(token)}`, {
|
|
326
|
+
method: 'GET',
|
|
327
|
+
})
|
|
328
|
+
if (!verified.ok) {
|
|
329
|
+
throw invalidField('token', 'Invalid email verification token.')
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
#commitSession(body: AuthSessionBody): NeonActor {
|
|
334
|
+
const user = body.user
|
|
335
|
+
const token = body.token ?? body.session?.token
|
|
336
|
+
const sessionId = body.session?.id ?? token
|
|
337
|
+
if (
|
|
338
|
+
typeof user?.id !== 'string' ||
|
|
339
|
+
typeof user.email !== 'string' ||
|
|
340
|
+
typeof token !== 'string' ||
|
|
341
|
+
typeof sessionId !== 'string'
|
|
342
|
+
) {
|
|
343
|
+
throw new Invalid('Auth response was missing a session.', {
|
|
344
|
+
metadata: { fields: { session: ['Incomplete auth response'] } },
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
const expiresAt = body.session?.expiresAt
|
|
348
|
+
? new Date(body.session.expiresAt)
|
|
349
|
+
: new Date(Date.now() + 3600 * 1000)
|
|
350
|
+
const session: NeonSession = {
|
|
351
|
+
id: sessionId,
|
|
352
|
+
accessToken: token,
|
|
353
|
+
refreshToken: token,
|
|
354
|
+
expiresAt,
|
|
355
|
+
}
|
|
356
|
+
writeSessionCookie(this.#cookies, this.#cookieName, session)
|
|
357
|
+
return { id: user.id, email: user.email }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#jsonHeaders(): Record<string, string> {
|
|
361
|
+
return { 'Content-Type': 'application/json' }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
#authHeaders(accessToken: string): Record<string, string> {
|
|
365
|
+
return { Authorization: `Bearer ${accessToken}` }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
#requireAccessToken(): string {
|
|
369
|
+
const session = readSessionCookie(this.#cookies, this.#cookieName)
|
|
370
|
+
if (!session) throw unauthenticated('No authenticated session is present.')
|
|
371
|
+
return session.accessToken
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async #isTotpEnrolled(accessToken: string): Promise<boolean> {
|
|
375
|
+
const sessionResponse = await this.#request('/get-session', {
|
|
376
|
+
method: 'GET',
|
|
377
|
+
headers: this.#authHeaders(accessToken),
|
|
378
|
+
})
|
|
379
|
+
if (!sessionResponse.ok) {
|
|
380
|
+
throw unauthenticated('No authenticated session is present.')
|
|
381
|
+
}
|
|
382
|
+
if (twoFactorEnabled(await readVendorJson(sessionResponse))) return true
|
|
383
|
+
const statusResponse = await this.#request('/two-factor/status', {
|
|
384
|
+
method: 'GET',
|
|
385
|
+
headers: this.#authHeaders(accessToken),
|
|
386
|
+
})
|
|
387
|
+
if (!statusResponse.ok) {
|
|
388
|
+
if (statusResponse.status === 401) {
|
|
389
|
+
throw unauthenticated('No authenticated session is present.')
|
|
390
|
+
}
|
|
391
|
+
return false
|
|
392
|
+
}
|
|
393
|
+
return twoFactorEnabled(await readVendorJson(statusResponse))
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async #request(path: string, init: RequestInit): Promise<Response> {
|
|
397
|
+
return fetch(`${this.#authUrl}${path}`, init)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function unauthenticated(message: string): Unauthenticated {
|
|
402
|
+
return new Unauthenticated(message, { metadata: { guard: 'identity' } })
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function invalidField(field: string, message: string): Invalid {
|
|
406
|
+
return new Invalid(message, { metadata: { fields: { [field]: [message] } } })
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
410
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Reads a Better Auth JSON body at the vendor HTTP boundary. */
|
|
414
|
+
async function readVendorJson(response: Response): Promise<unknown> {
|
|
415
|
+
return (await readAuthResponse(response)).json
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function asSessionBody(json: unknown): AuthSessionBody {
|
|
419
|
+
return isRecord(json) ? (json as AuthSessionBody) : {}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The actor from a signup that succeeded but issued no session token. */
|
|
423
|
+
function pendingVerificationActor(body: AuthSessionBody): NeonActor | undefined {
|
|
424
|
+
if (body.token !== undefined && body.token !== null) return undefined
|
|
425
|
+
if (body.session?.token !== undefined && body.session.token !== null) return undefined
|
|
426
|
+
const user = body.user
|
|
427
|
+
if (typeof user?.id !== 'string' || typeof user.email !== 'string') return undefined
|
|
428
|
+
return { id: user.id, email: user.email }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function twoFactorEnabled(body: unknown): boolean {
|
|
432
|
+
if (!isRecord(body)) return false
|
|
433
|
+
if (body.enabled === true) return true
|
|
434
|
+
const user = body.user
|
|
435
|
+
return isRecord(user) && user.twoFactorEnabled === true
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function parseEncodedTotpChallenge(challengeId: string): { expiresAt: number } {
|
|
439
|
+
if (!challengeId.startsWith('totp:') || challengeId.length <= 'totp:'.length) {
|
|
440
|
+
throw invalidField('challengeId', 'MFA challenge id is malformed.')
|
|
441
|
+
}
|
|
442
|
+
const expiresAt = Number(challengeId.slice('totp:'.length))
|
|
443
|
+
if (!Number.isFinite(expiresAt)) {
|
|
444
|
+
throw invalidField('challengeId', 'MFA challenge id is malformed.')
|
|
445
|
+
}
|
|
446
|
+
return { expiresAt }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Creates the config-time identity factory pinned by conformance:
|
|
451
|
+
* `(cookies: RequestCookies) => NeonIdentity`.
|
|
452
|
+
*/
|
|
453
|
+
export function createNeonIdentity(
|
|
454
|
+
options: NeonIdentityOptions = {},
|
|
455
|
+
): (cookies: RequestCookies) => NeonIdentity {
|
|
456
|
+
const resolved: Required<NeonIdentityOptions> = {
|
|
457
|
+
authUrl: options.authUrl ?? process.env.NEON_AUTH_URL ?? 'http://127.0.0.1:3000/api/auth',
|
|
458
|
+
instance: options.instance ?? 'default',
|
|
459
|
+
cookieName: options.cookieName ?? DEFAULT_SESSION_COOKIE,
|
|
460
|
+
}
|
|
461
|
+
return (cookies) => new NeonIdentity(cookies, resolved)
|
|
462
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { Conflict, Invalid, RateLimited, Unauthenticated, Unavailable } from '@avelonjs/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A Better Auth response body read once, before anything decides whether it succeeded.
|
|
5
|
+
*
|
|
6
|
+
* Neon Auth answers a rejected route with HTML or an empty body, so `response.json()` on the
|
|
7
|
+
* failure path throws a `SyntaxError` that never reaches the error taxonomy. Reading the text
|
|
8
|
+
* first keeps the raw payload for `.cause`.
|
|
9
|
+
*/
|
|
10
|
+
export interface AuthResponseBody {
|
|
11
|
+
/** Parsed JSON, or `undefined` when the body was empty or not JSON. */
|
|
12
|
+
readonly json: unknown
|
|
13
|
+
/** Raw response text. */
|
|
14
|
+
readonly text: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Reads a Better Auth body at the vendor HTTP boundary without throwing on a non-JSON payload. */
|
|
18
|
+
export async function readAuthResponse(response: Response): Promise<AuthResponseBody> {
|
|
19
|
+
const text = await response.text()
|
|
20
|
+
if (text.length === 0) return { json: undefined, text }
|
|
21
|
+
try {
|
|
22
|
+
return { json: JSON.parse(text) as unknown, text }
|
|
23
|
+
} catch {
|
|
24
|
+
return { json: undefined, text }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
29
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stringOrUndefined(value: unknown): string | undefined {
|
|
33
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether a string is a machine code rather than prose.
|
|
38
|
+
*
|
|
39
|
+
* Better Auth puts a code in `error` on the routes it inherits from the OAuth shape and a sentence
|
|
40
|
+
* in `error` on the ones it does not. One field, two meanings, separated by whether it has a space.
|
|
41
|
+
*/
|
|
42
|
+
function isCodeLike(value: string): boolean {
|
|
43
|
+
return !/\s/.test(value)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The human-readable reason, across the flat and nested shapes Better Auth returns. */
|
|
47
|
+
export function authErrorMessage(json: unknown): string | undefined {
|
|
48
|
+
if (!isRecord(json)) return undefined
|
|
49
|
+
const nested = isRecord(json.error) ? json.error : undefined
|
|
50
|
+
const bare = stringOrUndefined(json.error)
|
|
51
|
+
return (
|
|
52
|
+
stringOrUndefined(json.message) ??
|
|
53
|
+
stringOrUndefined(nested?.message) ??
|
|
54
|
+
stringOrUndefined(json.statusText) ??
|
|
55
|
+
(bare !== undefined && !isCodeLike(bare) ? bare : undefined)
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The stable Better Auth error code, upper-cased so a shape change in casing does not matter. */
|
|
60
|
+
export function authErrorCode(json: unknown): string | undefined {
|
|
61
|
+
if (!isRecord(json)) return undefined
|
|
62
|
+
const nested = isRecord(json.error) ? json.error : undefined
|
|
63
|
+
const bare = stringOrUndefined(json.error)
|
|
64
|
+
const code =
|
|
65
|
+
stringOrUndefined(json.code) ??
|
|
66
|
+
stringOrUndefined(nested?.code) ??
|
|
67
|
+
(bare !== undefined && isCodeLike(bare) ? bare : undefined)
|
|
68
|
+
return code?.toUpperCase()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DUPLICATE_CODES = new Set([
|
|
72
|
+
'USER_ALREADY_EXISTS',
|
|
73
|
+
'USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL',
|
|
74
|
+
'EMAIL_ALREADY_EXISTS',
|
|
75
|
+
'USER_EXISTS',
|
|
76
|
+
])
|
|
77
|
+
|
|
78
|
+
const RATE_LIMIT_CODES = new Set(['TOO_MANY_REQUESTS', 'RATE_LIMITED'])
|
|
79
|
+
|
|
80
|
+
const DISABLED_CODES = new Set(['SIGN_UP_DISABLED', 'EMAIL_AND_PASSWORD_DISABLED'])
|
|
81
|
+
|
|
82
|
+
const CREDENTIAL_CODES = new Set([
|
|
83
|
+
'INVALID_EMAIL_OR_PASSWORD',
|
|
84
|
+
'INVALID_GRANT',
|
|
85
|
+
'NOT_AUTHENTICATED',
|
|
86
|
+
'INVALID_PASSWORD',
|
|
87
|
+
'EMAIL_NOT_VERIFIED',
|
|
88
|
+
'USER_NOT_FOUND',
|
|
89
|
+
'SESSION_EXPIRED',
|
|
90
|
+
'UNAUTHORIZED',
|
|
91
|
+
])
|
|
92
|
+
|
|
93
|
+
const WEAK_PASSWORD_CODES = new Set([
|
|
94
|
+
'PASSWORD_TOO_SHORT',
|
|
95
|
+
'PASSWORD_TOO_LONG',
|
|
96
|
+
'WEAK_PASSWORD',
|
|
97
|
+
'INVALID_PASSWORD_FORMAT',
|
|
98
|
+
])
|
|
99
|
+
|
|
100
|
+
/** How a Better Auth failure is described once the vendor shape has been read. */
|
|
101
|
+
export interface AuthFailure {
|
|
102
|
+
/** HTTP status the Auth surface returned. */
|
|
103
|
+
readonly status: number
|
|
104
|
+
/** Stable Better Auth error code when the body carried one. */
|
|
105
|
+
readonly code: string | undefined
|
|
106
|
+
/** Vendor message, already falling back to the caller's wording. */
|
|
107
|
+
readonly message: string
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Describes a failed Auth response without deciding which taxonomy member it belongs to.
|
|
112
|
+
*
|
|
113
|
+
* A body with no readable message leaves only the status to report, so the status is stated.
|
|
114
|
+
*/
|
|
115
|
+
export function describeAuthFailure(
|
|
116
|
+
response: Response,
|
|
117
|
+
body: AuthResponseBody,
|
|
118
|
+
fallback: string,
|
|
119
|
+
): AuthFailure {
|
|
120
|
+
const message = authErrorMessage(body.json)
|
|
121
|
+
return {
|
|
122
|
+
status: response.status,
|
|
123
|
+
code: authErrorCode(body.json),
|
|
124
|
+
message: message ?? `${fallback} Neon Auth returned HTTP ${response.status}.`,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Where a failure belongs in the form: the field that carries its message. */
|
|
129
|
+
export type AuthField = 'email' | 'password' | 'token' | 'code'
|
|
130
|
+
|
|
131
|
+
/** How a caller wants a 401 or 403 that is not a rejected key classified. */
|
|
132
|
+
export type AuthRejection = 'unauthenticated' | 'unavailable'
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Maps a failed Better Auth response onto the framework taxonomy, keeping the body as `.cause`.
|
|
136
|
+
*
|
|
137
|
+
* `rejection` exists because a 401 means different things per operation. Sign-in answers one with
|
|
138
|
+
* `Unauthenticated`, which the kernel turns into a redirect to the login page. Registration answers
|
|
139
|
+
* the same status with `Unavailable`: nobody was authenticating, so bouncing to a login page hides
|
|
140
|
+
* a project that is refusing the call.
|
|
141
|
+
*/
|
|
142
|
+
export function mapAuthError(
|
|
143
|
+
failure: AuthFailure,
|
|
144
|
+
options: {
|
|
145
|
+
readonly operation: string
|
|
146
|
+
readonly field: AuthField
|
|
147
|
+
readonly rejection: AuthRejection
|
|
148
|
+
readonly cause: unknown
|
|
149
|
+
},
|
|
150
|
+
): never {
|
|
151
|
+
const { message, code, status } = failure
|
|
152
|
+
const cause = { status, code, body: options.cause, operation: options.operation }
|
|
153
|
+
|
|
154
|
+
if (
|
|
155
|
+
DUPLICATE_CODES.has(code ?? '') ||
|
|
156
|
+
/already (registered|been registered|exists)/i.test(message)
|
|
157
|
+
) {
|
|
158
|
+
throw new Conflict(message, { metadata: { resource: 'actor', key: 'email' }, cause })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (RATE_LIMIT_CODES.has(code ?? '') || status === 429) {
|
|
162
|
+
throw new RateLimited(message, { metadata: { key: options.operation }, cause })
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (DISABLED_CODES.has(code ?? '')) {
|
|
166
|
+
throw new Unavailable(message, { metadata: { service: 'identity' }, cause })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Better Auth serves every documented path; a 404 or 405 means the configured URL is not one.
|
|
170
|
+
if (status === 404 || status === 405) {
|
|
171
|
+
throw new Unavailable(message, { metadata: { service: 'identity' }, cause })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (WEAK_PASSWORD_CODES.has(code ?? '')) {
|
|
175
|
+
throw new Invalid(message, { metadata: { fields: { password: [message] } }, cause })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (CREDENTIAL_CODES.has(code ?? '') || status === 401 || status === 403) {
|
|
179
|
+
if (options.rejection === 'unavailable') {
|
|
180
|
+
throw new Unavailable(message, { metadata: { service: 'identity' }, cause })
|
|
181
|
+
}
|
|
182
|
+
throw new Unauthenticated(message, { metadata: { guard: 'identity' }, cause })
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (status >= 500) {
|
|
186
|
+
throw new Unavailable(message, { metadata: { service: 'identity' }, cause })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
throw new Invalid(message, { metadata: { fields: { [options.field]: [message] } }, cause })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Better Auth error codes this driver maps, and the taxonomy member each becomes. */
|
|
193
|
+
export const NEON_IDENTITY_ERROR_MAP = [
|
|
194
|
+
{ code: 'USER_ALREADY_EXISTS', framework: 'Conflict', meaning: 'email is already registered' },
|
|
195
|
+
{ code: 'EMAIL_ALREADY_EXISTS', framework: 'Conflict', meaning: 'email is already registered' },
|
|
196
|
+
{ code: 'TOO_MANY_REQUESTS', framework: 'RateLimited', meaning: 'request throttled' },
|
|
197
|
+
{ code: 'SIGN_UP_DISABLED', framework: 'Unavailable', meaning: 'project refuses new signups' },
|
|
198
|
+
{
|
|
199
|
+
code: 'INVALID_EMAIL_OR_PASSWORD',
|
|
200
|
+
framework: 'Unauthenticated',
|
|
201
|
+
meaning: 'credentials did not match',
|
|
202
|
+
},
|
|
203
|
+
{ code: 'EMAIL_NOT_VERIFIED', framework: 'Unauthenticated', meaning: 'email is unverified' },
|
|
204
|
+
{ code: 'PASSWORD_TOO_SHORT', framework: 'Invalid', meaning: 'password fails the policy' },
|
|
205
|
+
{ code: 'WEAK_PASSWORD', framework: 'Invalid', meaning: 'password fails the policy' },
|
|
206
|
+
] as const
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export {
|
|
2
|
+
clearSessionCookie,
|
|
3
|
+
DEFAULT_SESSION_COOKIE,
|
|
4
|
+
readSessionCookie,
|
|
5
|
+
writeSessionCookie,
|
|
6
|
+
} from './cookies'
|
|
7
|
+
export { NEON_IDENTITY_ERROR_MAP } from './errors'
|
|
8
|
+
export {
|
|
9
|
+
createNeonIdentity,
|
|
10
|
+
NeonIdentity,
|
|
11
|
+
neonIdentityCapabilities,
|
|
12
|
+
type NeonIdentityOptions,
|
|
13
|
+
} from './driver'
|
|
14
|
+
export { LocalAuthServer } from './local-auth'
|
|
15
|
+
export type { NeonActor, NeonSession } from './types'
|