@avelonjs/supabase 0.1.0 → 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/README.md +130 -93
- package/package.json +4 -2
- package/src/database/bun.ts +33 -0
- package/src/database/compile.ts +15 -9
- package/src/database/driver.ts +55 -29
- package/src/database/errors.ts +2 -7
- package/src/database/fixtures.ts +3 -3
- package/src/database/index.ts +2 -1
- package/src/database/normalize.ts +1 -1
- package/src/database/schema-rest.ts +54 -0
- package/src/database/wards.ts +10 -10
- package/src/identity/driver.ts +298 -19
- package/src/identity/errors.ts +230 -0
- package/src/identity/index.ts +1 -0
- package/src/identity/local-auth.ts +145 -23
- package/src/queue/driver.ts +10 -6
- package/src/social/driver.ts +6 -9
- package/src/storage/driver.ts +3 -4
- package/src/tokens/driver.ts +10 -6
package/src/identity/driver.ts
CHANGED
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
Unauthenticated,
|
|
4
4
|
type IdentityDriver,
|
|
5
5
|
type MagicLinkIdentitySurface,
|
|
6
|
+
type MfaChallenge,
|
|
7
|
+
type MfaIdentitySurface,
|
|
6
8
|
type PasswordIdentitySurface,
|
|
7
9
|
type RequestCookies,
|
|
8
10
|
} from '@avelonjs/core'
|
|
@@ -12,6 +14,7 @@ import {
|
|
|
12
14
|
readSessionCookie,
|
|
13
15
|
writeSessionCookie,
|
|
14
16
|
} from './cookies'
|
|
17
|
+
import { describeAuthFailure, mapAuthError, readAuthResponse } from './errors'
|
|
15
18
|
import type { SupabaseActor, SupabaseSession } from './types'
|
|
16
19
|
|
|
17
20
|
/** Exact capability declaration for the Supabase identity driver. */
|
|
@@ -20,9 +23,12 @@ export const supabaseIdentityCapabilities = {
|
|
|
20
23
|
magicLinks: true,
|
|
21
24
|
oauth: false,
|
|
22
25
|
organizations: false,
|
|
23
|
-
mfa: [] as const,
|
|
26
|
+
mfa: ['totp'] as const,
|
|
27
|
+
emailVerification: false,
|
|
24
28
|
} as const
|
|
25
29
|
|
|
30
|
+
type SupabaseMfaFactor = (typeof supabaseIdentityCapabilities.mfa)[number]
|
|
31
|
+
|
|
26
32
|
/** Construction options for {@link createSupabaseIdentity}. */
|
|
27
33
|
export interface SupabaseIdentityOptions {
|
|
28
34
|
/** GoTrue Auth base URL, e.g. `http://127.0.0.1:54321/auth/v1`. */
|
|
@@ -60,7 +66,8 @@ export class SupabaseIdentity
|
|
|
60
66
|
SupabaseSession
|
|
61
67
|
>,
|
|
62
68
|
PasswordIdentitySurface<SupabaseActor>,
|
|
63
|
-
MagicLinkIdentitySurface
|
|
69
|
+
MagicLinkIdentitySurface,
|
|
70
|
+
MfaIdentitySurface<SupabaseMfaFactor>
|
|
64
71
|
{
|
|
65
72
|
readonly name = 'supabase'
|
|
66
73
|
readonly instance: string
|
|
@@ -91,7 +98,8 @@ export class SupabaseIdentity
|
|
|
91
98
|
headers: this.#authHeaders(session.accessToken),
|
|
92
99
|
})
|
|
93
100
|
if (!response.ok) return null
|
|
94
|
-
const body = (await response.json
|
|
101
|
+
const body = (await readAuthResponse(response)).json
|
|
102
|
+
if (!isRecord(body)) return null
|
|
95
103
|
if (typeof body.id !== 'string' || typeof body.email !== 'string') return null
|
|
96
104
|
return { id: body.id, email: body.email }
|
|
97
105
|
}
|
|
@@ -120,13 +128,22 @@ export class SupabaseIdentity
|
|
|
120
128
|
headers: this.#jsonHeaders(),
|
|
121
129
|
body: JSON.stringify({ email, password }),
|
|
122
130
|
})
|
|
123
|
-
const body =
|
|
131
|
+
const body = await readAuthResponse(response)
|
|
124
132
|
if (!response.ok) {
|
|
125
|
-
|
|
126
|
-
|
|
133
|
+
mapAuthError(describeAuthFailure(response, body, 'Registration failed.'), {
|
|
134
|
+
operation: 'identity.register',
|
|
135
|
+
field: 'email',
|
|
136
|
+
rejection: 'unavailable',
|
|
137
|
+
cause: body.text,
|
|
127
138
|
})
|
|
128
139
|
}
|
|
129
|
-
|
|
140
|
+
const parsed = isTokenResponse(body.json) ? body.json : {}
|
|
141
|
+
// A project with "Confirm email" on answers a successful signup with the user and no session.
|
|
142
|
+
// The actor exists; treating a missing session as a failed registration reports the opposite of
|
|
143
|
+
// what happened, and the second attempt then collides with the account the first one created.
|
|
144
|
+
const pending = pendingConfirmationActor(body.json)
|
|
145
|
+
if (pending !== undefined && parsed.access_token === undefined) return pending
|
|
146
|
+
return this.#commitSession(parsed)
|
|
130
147
|
}
|
|
131
148
|
|
|
132
149
|
async signInWithPassword(email: string, password: string): Promise<SupabaseActor> {
|
|
@@ -135,33 +152,87 @@ export class SupabaseIdentity
|
|
|
135
152
|
headers: this.#jsonHeaders(),
|
|
136
153
|
body: JSON.stringify({ email, password }),
|
|
137
154
|
})
|
|
138
|
-
const body =
|
|
155
|
+
const body = await readAuthResponse(response)
|
|
139
156
|
if (!response.ok) {
|
|
140
|
-
|
|
141
|
-
|
|
157
|
+
mapAuthError(describeAuthFailure(response, body, 'Invalid login credentials.'), {
|
|
158
|
+
operation: 'identity.signInWithPassword',
|
|
159
|
+
field: 'email',
|
|
160
|
+
rejection: 'unauthenticated',
|
|
161
|
+
cause: body.text,
|
|
142
162
|
})
|
|
143
163
|
}
|
|
144
|
-
return this.#commitSession(body)
|
|
164
|
+
return this.#commitSession(isTokenResponse(body.json) ? body.json : {})
|
|
145
165
|
}
|
|
146
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Asks GoTrue to email a recovery link.
|
|
169
|
+
*
|
|
170
|
+
* GoTrue answers 200 for an address it does not know, so reporting the failure statuses does not
|
|
171
|
+
* reveal whether an account exists. Discarding them does hide a throttled project or a disabled
|
|
172
|
+
* email provider behind a screen that claims the message was sent.
|
|
173
|
+
*/
|
|
147
174
|
async sendPasswordReset(email: string): Promise<void> {
|
|
148
|
-
await this.#request('/recover', {
|
|
175
|
+
const response = await this.#request('/recover', {
|
|
149
176
|
method: 'POST',
|
|
150
177
|
headers: this.#jsonHeaders(),
|
|
151
178
|
body: JSON.stringify({ email }),
|
|
152
179
|
})
|
|
180
|
+
if (!response.ok) {
|
|
181
|
+
const body = await readAuthResponse(response)
|
|
182
|
+
mapAuthError(describeAuthFailure(response, body, 'Password reset could not be sent.'), {
|
|
183
|
+
operation: 'identity.sendPasswordReset',
|
|
184
|
+
field: 'email',
|
|
185
|
+
rejection: 'unavailable',
|
|
186
|
+
cause: body.text,
|
|
187
|
+
})
|
|
188
|
+
}
|
|
153
189
|
}
|
|
154
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Consumes a recovery token and writes the new password.
|
|
193
|
+
*
|
|
194
|
+
* GoTrue's `/verify` has no password parameter: it exchanges the token for a session and nothing
|
|
195
|
+
* else. Sending a password there returns 200 with the old password still in place, so recovery
|
|
196
|
+
* has to be two calls, and `token` is the `{{ .TokenHash }}` from the recovery email template.
|
|
197
|
+
* The session is deliberately not written to the cookie jar; the actor signs in with the password
|
|
198
|
+
* they just chose.
|
|
199
|
+
*/
|
|
155
200
|
async resetPassword(token: string, password: string): Promise<void> {
|
|
156
|
-
const
|
|
201
|
+
const verified = await this.#request('/verify', {
|
|
157
202
|
method: 'POST',
|
|
158
203
|
headers: this.#jsonHeaders(),
|
|
159
|
-
body: JSON.stringify({ type: 'recovery', token
|
|
204
|
+
body: JSON.stringify({ type: 'recovery', token_hash: token }),
|
|
160
205
|
})
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
206
|
+
const verifiedBody = await readAuthResponse(verified)
|
|
207
|
+
if (!verified.ok) {
|
|
208
|
+
mapAuthError(describeAuthFailure(verified, verifiedBody, 'Invalid recovery token.'), {
|
|
209
|
+
operation: 'identity.resetPassword',
|
|
210
|
+
field: 'token',
|
|
211
|
+
rejection: 'unauthenticated',
|
|
212
|
+
cause: verifiedBody.text,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
const accessToken = isTokenResponse(verifiedBody.json)
|
|
216
|
+
? verifiedBody.json.access_token
|
|
217
|
+
: undefined
|
|
218
|
+
if (typeof accessToken !== 'string') {
|
|
219
|
+
throw new Invalid('Recovery did not return a session.', {
|
|
164
220
|
metadata: { fields: { token: ['Invalid recovery token'] } },
|
|
221
|
+
cause: verifiedBody.text,
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
const updated = await this.#request('/user', {
|
|
225
|
+
method: 'PUT',
|
|
226
|
+
headers: { ...this.#jsonHeaders(), ...this.#authHeaders(accessToken) },
|
|
227
|
+
body: JSON.stringify({ password }),
|
|
228
|
+
})
|
|
229
|
+
if (!updated.ok) {
|
|
230
|
+
const body = await readAuthResponse(updated)
|
|
231
|
+
mapAuthError(describeAuthFailure(updated, body, 'The new password was rejected.'), {
|
|
232
|
+
operation: 'identity.resetPassword',
|
|
233
|
+
field: 'password',
|
|
234
|
+
rejection: 'unauthenticated',
|
|
235
|
+
cause: body.text,
|
|
165
236
|
})
|
|
166
237
|
}
|
|
167
238
|
}
|
|
@@ -186,11 +257,111 @@ export class SupabaseIdentity
|
|
|
186
257
|
}
|
|
187
258
|
|
|
188
259
|
async sendMagicLink(email: string, redirectTo?: string): Promise<void> {
|
|
189
|
-
await this.#request('/otp', {
|
|
260
|
+
const response = await this.#request('/otp', {
|
|
190
261
|
method: 'POST',
|
|
191
262
|
headers: this.#jsonHeaders(),
|
|
192
|
-
body: JSON.stringify({
|
|
263
|
+
body: JSON.stringify({
|
|
264
|
+
email,
|
|
265
|
+
create_user: true,
|
|
266
|
+
gotrue_meta_security: {},
|
|
267
|
+
...(redirectTo ? { email_redirect_to: redirectTo } : {}),
|
|
268
|
+
}),
|
|
193
269
|
})
|
|
270
|
+
if (!response.ok) {
|
|
271
|
+
const body = await readAuthResponse(response)
|
|
272
|
+
mapAuthError(describeAuthFailure(response, body, 'Magic link could not be sent.'), {
|
|
273
|
+
operation: 'identity.sendMagicLink',
|
|
274
|
+
field: 'email',
|
|
275
|
+
rejection: 'unavailable',
|
|
276
|
+
cause: body.text,
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Completes a sign-in from the token the emailed link carried.
|
|
283
|
+
*
|
|
284
|
+
* `token` is the `{{ .TokenHash }}` from the Magic Link email template. GoTrue's stock template
|
|
285
|
+
* links to its own `/verify`, which redeems the token itself and returns the session in the URL
|
|
286
|
+
* fragment, where a server never sees it.
|
|
287
|
+
*/
|
|
288
|
+
async signInWithMagicLink(token: string): Promise<SupabaseActor> {
|
|
289
|
+
const response = await this.#request('/verify', {
|
|
290
|
+
method: 'POST',
|
|
291
|
+
headers: this.#jsonHeaders(),
|
|
292
|
+
body: JSON.stringify({ type: 'magiclink', token_hash: token }),
|
|
293
|
+
})
|
|
294
|
+
const body = await readAuthResponse(response)
|
|
295
|
+
if (!response.ok) {
|
|
296
|
+
mapAuthError(describeAuthFailure(response, body, 'Invalid sign-in link.'), {
|
|
297
|
+
operation: 'identity.signInWithMagicLink',
|
|
298
|
+
field: 'token',
|
|
299
|
+
rejection: 'unauthenticated',
|
|
300
|
+
cause: body.text,
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
return this.#commitSession(isTokenResponse(body.json) ? body.json : {})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async challengeMfa(factor?: SupabaseMfaFactor): Promise<MfaChallenge<SupabaseMfaFactor>> {
|
|
307
|
+
const selected: string = factor ?? 'totp'
|
|
308
|
+
if (selected !== 'totp') {
|
|
309
|
+
throw invalidField('factor', 'Unsupported multi-factor method.')
|
|
310
|
+
}
|
|
311
|
+
const session = readSessionCookie(this.#cookies, this.#cookieName)
|
|
312
|
+
if (!session) {
|
|
313
|
+
throw unauthenticated('No authenticated session is present.')
|
|
314
|
+
}
|
|
315
|
+
const listed = await this.#request('/factors', {
|
|
316
|
+
method: 'GET',
|
|
317
|
+
headers: this.#authHeaders(session.accessToken),
|
|
318
|
+
})
|
|
319
|
+
if (!listed.ok) {
|
|
320
|
+
throw unauthenticated('No authenticated session is present.')
|
|
321
|
+
}
|
|
322
|
+
const totp = pickTotpFactor(parseFactors(await readVendorJson(listed)))
|
|
323
|
+
if (!totp) {
|
|
324
|
+
throw invalidField('factor', 'No TOTP factor is enrolled.')
|
|
325
|
+
}
|
|
326
|
+
const challenged = await this.#request(`/factors/${encodeURIComponent(totp.id)}/challenge`, {
|
|
327
|
+
method: 'POST',
|
|
328
|
+
headers: { ...this.#jsonHeaders(), ...this.#authHeaders(session.accessToken) },
|
|
329
|
+
})
|
|
330
|
+
if (!challenged.ok) {
|
|
331
|
+
if (challenged.status === 401) {
|
|
332
|
+
throw unauthenticated('No authenticated session is present.')
|
|
333
|
+
}
|
|
334
|
+
throw invalidField('factor', 'No TOTP factor is enrolled.')
|
|
335
|
+
}
|
|
336
|
+
const challenge = parseChallenge(await readVendorJson(challenged))
|
|
337
|
+
return {
|
|
338
|
+
id: `${totp.id}:${challenge.id}`,
|
|
339
|
+
factor: 'totp',
|
|
340
|
+
expiresAt: challenge.expiresAt,
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async verifyMfa(challengeId: string, code: string): Promise<void> {
|
|
345
|
+
const session = readSessionCookie(this.#cookies, this.#cookieName)
|
|
346
|
+
if (!session) {
|
|
347
|
+
throw unauthenticated('No authenticated session is present.')
|
|
348
|
+
}
|
|
349
|
+
const encoded = splitEncodedChallengeId(challengeId)
|
|
350
|
+
const verified = await this.#request(
|
|
351
|
+
`/factors/${encodeURIComponent(encoded.factorId)}/verify`,
|
|
352
|
+
{
|
|
353
|
+
method: 'POST',
|
|
354
|
+
headers: { ...this.#jsonHeaders(), ...this.#authHeaders(session.accessToken) },
|
|
355
|
+
body: JSON.stringify({ challenge_id: encoded.gotrueChallengeId, code }),
|
|
356
|
+
},
|
|
357
|
+
)
|
|
358
|
+
if (!verified.ok) {
|
|
359
|
+
throw unauthenticated('Invalid multi-factor challenge.')
|
|
360
|
+
}
|
|
361
|
+
const body = await readVendorJson(verified)
|
|
362
|
+
if (isTokenResponse(body)) {
|
|
363
|
+
this.#commitSession(body)
|
|
364
|
+
}
|
|
194
365
|
}
|
|
195
366
|
|
|
196
367
|
#commitSession(body: TokenResponse): SupabaseActor {
|
|
@@ -249,3 +420,111 @@ export function createSupabaseIdentity(
|
|
|
249
420
|
}
|
|
250
421
|
return (cookies) => new SupabaseIdentity(cookies, resolved)
|
|
251
422
|
}
|
|
423
|
+
|
|
424
|
+
function unauthenticated(message: string): Unauthenticated {
|
|
425
|
+
return new Unauthenticated(message, { metadata: { guard: 'identity' } })
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function invalidField(field: string, message: string): Invalid {
|
|
429
|
+
return new Invalid(message, { metadata: { fields: { [field]: [message] } } })
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
433
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Reads a GoTrue JSON body at the vendor HTTP boundary. */
|
|
437
|
+
async function readVendorJson(response: Response): Promise<unknown> {
|
|
438
|
+
return (await readAuthResponse(response)).json
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The actor from a signup that succeeded but issued no session.
|
|
443
|
+
*
|
|
444
|
+
* GoTrue returns the user at the top level for a confirmation-required signup, and nests it under
|
|
445
|
+
* `user` when it also issues a session.
|
|
446
|
+
*/
|
|
447
|
+
function pendingConfirmationActor(json: unknown): SupabaseActor | undefined {
|
|
448
|
+
if (!isRecord(json)) return undefined
|
|
449
|
+
const source = isRecord(json.user) ? json.user : json
|
|
450
|
+
const id = source.id
|
|
451
|
+
const email = source.email
|
|
452
|
+
if (typeof id !== 'string' || typeof email !== 'string') return undefined
|
|
453
|
+
return { id, email }
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
interface GoTrueFactor {
|
|
457
|
+
id: string
|
|
458
|
+
kind: string
|
|
459
|
+
status: string | undefined
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function parseFactors(body: unknown): readonly GoTrueFactor[] {
|
|
463
|
+
if (!isRecord(body) || !Array.isArray(body.factors)) return []
|
|
464
|
+
const factors: GoTrueFactor[] = []
|
|
465
|
+
for (const entry of body.factors) {
|
|
466
|
+
if (!isRecord(entry) || typeof entry.id !== 'string') continue
|
|
467
|
+
const kind = entry.factor_type ?? entry.type
|
|
468
|
+
if (typeof kind !== 'string') continue
|
|
469
|
+
factors.push({
|
|
470
|
+
id: entry.id,
|
|
471
|
+
kind,
|
|
472
|
+
status: typeof entry.status === 'string' ? entry.status : undefined,
|
|
473
|
+
})
|
|
474
|
+
}
|
|
475
|
+
return factors
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function pickTotpFactor(factors: readonly GoTrueFactor[]): GoTrueFactor | undefined {
|
|
479
|
+
const totp = factors.filter((factor) => factor.kind === 'totp')
|
|
480
|
+
return (
|
|
481
|
+
totp.find((factor) => factor.status === 'verified' || factor.status === 'active') ?? totp[0]
|
|
482
|
+
)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function parseChallenge(body: unknown): { id: string; expiresAt: Date } {
|
|
486
|
+
if (!isRecord(body) || typeof body.id !== 'string' || body.id.length === 0) {
|
|
487
|
+
throw invalidField('factor', 'Auth response was missing an MFA challenge.')
|
|
488
|
+
}
|
|
489
|
+
return { id: body.id, expiresAt: parseExpiresAt(body.expires_at) }
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function parseExpiresAt(value: unknown): Date {
|
|
493
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
494
|
+
return new Date(value * 1000)
|
|
495
|
+
}
|
|
496
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
497
|
+
if (/^\d+(\.\d+)?$/.test(value)) {
|
|
498
|
+
return new Date(Number(value) * 1000)
|
|
499
|
+
}
|
|
500
|
+
const parsed = new Date(value)
|
|
501
|
+
if (!Number.isNaN(parsed.getTime())) return parsed
|
|
502
|
+
}
|
|
503
|
+
return new Date(Date.now() + 5 * 60 * 1000)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function splitEncodedChallengeId(challengeId: string): {
|
|
507
|
+
factorId: string
|
|
508
|
+
gotrueChallengeId: string
|
|
509
|
+
} {
|
|
510
|
+
const separator = challengeId.indexOf(':')
|
|
511
|
+
if (separator <= 0 || separator === challengeId.length - 1) {
|
|
512
|
+
throw invalidField('challengeId', 'MFA challenge id is malformed.')
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
factorId: challengeId.slice(0, separator),
|
|
516
|
+
gotrueChallengeId: challengeId.slice(separator + 1),
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function isTokenResponse(value: unknown): value is TokenResponse {
|
|
521
|
+
if (!isRecord(value)) return false
|
|
522
|
+
const user = value.user
|
|
523
|
+
return (
|
|
524
|
+
isRecord(user) &&
|
|
525
|
+
typeof user.id === 'string' &&
|
|
526
|
+
typeof user.email === 'string' &&
|
|
527
|
+
typeof value.access_token === 'string' &&
|
|
528
|
+
typeof value.refresh_token === 'string'
|
|
529
|
+
)
|
|
530
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { Conflict, Invalid, RateLimited, Unauthenticated, Unavailable } from '@avelonjs/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A GoTrue response body read once, before anything decides whether it succeeded.
|
|
5
|
+
*
|
|
6
|
+
* Supabase serves Auth behind an API gateway that answers a rejected key with HTML or an empty
|
|
7
|
+
* body, so `response.json()` on the failure path throws a `SyntaxError` that never reaches the
|
|
8
|
+
* error taxonomy. Reading the text 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 GoTrue 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
|
+
* GoTrue's OAuth-shaped bodies put the code in `error` (`invalid_grant`) and the sentence in
|
|
40
|
+
* `error_description`, while its gateway puts a sentence in `error`. One field, two meanings,
|
|
41
|
+
* separated by whether it contains a space.
|
|
42
|
+
*/
|
|
43
|
+
function isCodeLike(value: string): boolean {
|
|
44
|
+
return !/\s/.test(value)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The human-readable reason, across every shape Supabase Auth has shipped.
|
|
49
|
+
*
|
|
50
|
+
* GoTrue uses `msg`, its newer error envelope uses `message`, the OAuth-shaped endpoints use
|
|
51
|
+
* `error_description`, and the API gateway in front of the project uses `message` alone. Reading
|
|
52
|
+
* only the first of those is what turns "Invalid API key" into a blank failure.
|
|
53
|
+
*/
|
|
54
|
+
export function authErrorMessage(json: unknown): string | undefined {
|
|
55
|
+
if (!isRecord(json)) return undefined
|
|
56
|
+
const nested = isRecord(json.error) ? json.error : undefined
|
|
57
|
+
const bare = stringOrUndefined(json.error)
|
|
58
|
+
return (
|
|
59
|
+
stringOrUndefined(json.error_description) ??
|
|
60
|
+
stringOrUndefined(json.msg) ??
|
|
61
|
+
stringOrUndefined(json.message) ??
|
|
62
|
+
stringOrUndefined(nested?.message) ??
|
|
63
|
+
stringOrUndefined(nested?.msg) ??
|
|
64
|
+
(bare !== undefined && !isCodeLike(bare) ? bare : undefined)
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The stable GoTrue error code, ignoring the numeric `code` that older bodies set to the status. */
|
|
69
|
+
export function authErrorCode(json: unknown): string | undefined {
|
|
70
|
+
if (!isRecord(json)) return undefined
|
|
71
|
+
const nested = isRecord(json.error) ? json.error : undefined
|
|
72
|
+
const bare = stringOrUndefined(json.error)
|
|
73
|
+
return (
|
|
74
|
+
stringOrUndefined(json.error_code) ??
|
|
75
|
+
stringOrUndefined(nested?.error_code) ??
|
|
76
|
+
stringOrUndefined(json.code) ??
|
|
77
|
+
stringOrUndefined(nested?.code) ??
|
|
78
|
+
(bare !== undefined && isCodeLike(bare) ? bare : undefined)
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const DUPLICATE_CODES = new Set([
|
|
83
|
+
'user_already_exists',
|
|
84
|
+
'user_exists',
|
|
85
|
+
'email_exists',
|
|
86
|
+
'phone_exists',
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
const RATE_LIMIT_CODES = new Set([
|
|
90
|
+
'over_request_rate_limit',
|
|
91
|
+
'over_email_send_rate_limit',
|
|
92
|
+
'over_sms_send_rate_limit',
|
|
93
|
+
])
|
|
94
|
+
|
|
95
|
+
const DISABLED_CODES = new Set([
|
|
96
|
+
'signup_disabled',
|
|
97
|
+
'email_provider_disabled',
|
|
98
|
+
'phone_provider_disabled',
|
|
99
|
+
'provider_disabled',
|
|
100
|
+
'anonymous_provider_disabled',
|
|
101
|
+
])
|
|
102
|
+
|
|
103
|
+
const CREDENTIAL_CODES = new Set([
|
|
104
|
+
'invalid_credentials',
|
|
105
|
+
'invalid_grant',
|
|
106
|
+
'not_authenticated',
|
|
107
|
+
'email_not_confirmed',
|
|
108
|
+
'user_banned',
|
|
109
|
+
'no_authorization',
|
|
110
|
+
'bad_jwt',
|
|
111
|
+
'session_expired',
|
|
112
|
+
'session_not_found',
|
|
113
|
+
])
|
|
114
|
+
|
|
115
|
+
/** How a GoTrue failure is described once the vendor shape has been read. */
|
|
116
|
+
export interface AuthFailure {
|
|
117
|
+
/** HTTP status the Auth surface returned. */
|
|
118
|
+
readonly status: number
|
|
119
|
+
/** Stable GoTrue error code when the body carried one. */
|
|
120
|
+
readonly code: string | undefined
|
|
121
|
+
/** Vendor message, already falling back to the caller's wording. */
|
|
122
|
+
readonly message: string
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Describes a failed Auth response without deciding which taxonomy member it belongs to.
|
|
127
|
+
*
|
|
128
|
+
* A body with no readable message leaves only the status to report, so the status is stated. A
|
|
129
|
+
* bare "Registration failed." in front of an operator reading a 404 tells them nothing.
|
|
130
|
+
*/
|
|
131
|
+
export function describeAuthFailure(
|
|
132
|
+
response: Response,
|
|
133
|
+
body: AuthResponseBody,
|
|
134
|
+
fallback: string,
|
|
135
|
+
): AuthFailure {
|
|
136
|
+
const message = authErrorMessage(body.json)
|
|
137
|
+
return {
|
|
138
|
+
status: response.status,
|
|
139
|
+
code: authErrorCode(body.json),
|
|
140
|
+
message: message ?? `${fallback} Supabase Auth returned HTTP ${response.status}.`,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isGatewayKeyFailure(failure: AuthFailure): boolean {
|
|
145
|
+
if (failure.status !== 401 && failure.status !== 403) return false
|
|
146
|
+
if (failure.code !== undefined) return false
|
|
147
|
+
return /api key|apikey/i.test(failure.message)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Where a failure belongs in the form: the field that carries its message. */
|
|
151
|
+
export type AuthField = 'email' | 'password' | 'token' | 'code'
|
|
152
|
+
|
|
153
|
+
/** How a caller wants a 401 or 403 that is not a rejected API key classified. */
|
|
154
|
+
export type AuthRejection = 'unauthenticated' | 'unavailable'
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Maps a failed GoTrue response onto the framework taxonomy, keeping the vendor body as `.cause`.
|
|
158
|
+
*
|
|
159
|
+
* `field` names the input the caller was acting on. A weak password reports on `password` whatever
|
|
160
|
+
* the caller passed, because that is the input the actor has to change.
|
|
161
|
+
*
|
|
162
|
+
* `rejection` exists because a 401 means different things per operation. Sign-in answers one with
|
|
163
|
+
* `Unauthenticated`, which the kernel turns into a redirect to the login page. Registration answers
|
|
164
|
+
* the same status with `Unavailable`: nobody was authenticating, so bouncing to a login page hides
|
|
165
|
+
* a project that is refusing the call.
|
|
166
|
+
*/
|
|
167
|
+
export function mapAuthError(
|
|
168
|
+
failure: AuthFailure,
|
|
169
|
+
options: {
|
|
170
|
+
readonly operation: string
|
|
171
|
+
readonly field: AuthField
|
|
172
|
+
readonly rejection: AuthRejection
|
|
173
|
+
readonly cause: unknown
|
|
174
|
+
},
|
|
175
|
+
): never {
|
|
176
|
+
const { message, code, status } = failure
|
|
177
|
+
const cause = { status, code, body: options.cause, operation: options.operation }
|
|
178
|
+
|
|
179
|
+
if (
|
|
180
|
+
DUPLICATE_CODES.has(code ?? '') ||
|
|
181
|
+
/already (registered|been registered|exists)/i.test(message)
|
|
182
|
+
) {
|
|
183
|
+
throw new Conflict(message, { metadata: { resource: 'actor', key: 'email' }, cause })
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (RATE_LIMIT_CODES.has(code ?? '') || status === 429) {
|
|
187
|
+
throw new RateLimited(message, { metadata: { key: options.operation }, cause })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (isGatewayKeyFailure(failure)) {
|
|
191
|
+
throw new Unavailable(message, { metadata: { service: 'identity' }, cause })
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// GoTrue serves every documented path; a 404 or 405 means the configured auth URL is not one.
|
|
195
|
+
if (status === 404 || status === 405) {
|
|
196
|
+
throw new Unavailable(message, { metadata: { service: 'identity' }, cause })
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (DISABLED_CODES.has(code ?? '')) {
|
|
200
|
+
throw new Unavailable(message, { metadata: { service: 'identity' }, cause })
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (CREDENTIAL_CODES.has(code ?? '') || status === 401 || status === 403) {
|
|
204
|
+
if (options.rejection === 'unavailable') {
|
|
205
|
+
throw new Unavailable(message, { metadata: { service: 'identity' }, cause })
|
|
206
|
+
}
|
|
207
|
+
throw new Unauthenticated(message, { metadata: { guard: 'identity' }, cause })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (status >= 500) {
|
|
211
|
+
throw new Unavailable(message, { metadata: { service: 'identity' }, cause })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const field = code === 'weak_password' ? 'password' : options.field
|
|
215
|
+
throw new Invalid(message, { metadata: { fields: { [field]: [message] } }, cause })
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** GoTrue error codes this driver maps, and the taxonomy member each becomes. */
|
|
219
|
+
export const SUPABASE_IDENTITY_ERROR_MAP = [
|
|
220
|
+
{ code: 'user_already_exists', framework: 'Conflict', meaning: 'email is already registered' },
|
|
221
|
+
{ code: 'email_exists', framework: 'Conflict', meaning: 'email is already registered' },
|
|
222
|
+
{ code: 'over_email_send_rate_limit', framework: 'RateLimited', meaning: 'email send throttled' },
|
|
223
|
+
{ code: 'over_request_rate_limit', framework: 'RateLimited', meaning: 'request throttled' },
|
|
224
|
+
{ code: 'signup_disabled', framework: 'Unavailable', meaning: 'project refuses new signups' },
|
|
225
|
+
{ code: 'email_provider_disabled', framework: 'Unavailable', meaning: 'email provider is off' },
|
|
226
|
+
{ code: 'invalid_credentials', framework: 'Unauthenticated', meaning: 'password did not match' },
|
|
227
|
+
{ code: 'email_not_confirmed', framework: 'Unauthenticated', meaning: 'email is unconfirmed' },
|
|
228
|
+
{ code: 'weak_password', framework: 'Invalid', meaning: 'password fails the project policy' },
|
|
229
|
+
{ code: 'validation_failed', framework: 'Invalid', meaning: 'input rejected by the Auth server' },
|
|
230
|
+
] as const
|