@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.
@@ -19,6 +19,19 @@ interface LocalReset {
19
19
  token: string
20
20
  }
21
21
 
22
+ interface LocalFactor {
23
+ id: string
24
+ userId: string
25
+ factorType: 'totp'
26
+ status: 'verified'
27
+ }
28
+
29
+ interface LocalChallenge {
30
+ id: string
31
+ factorId: string
32
+ expiresAt: number
33
+ }
34
+
22
35
  function hashPassword(password: string): string {
23
36
  return createHash('sha256').update(password).digest('hex')
24
37
  }
@@ -33,6 +46,9 @@ export class LocalAuthServer {
33
46
  readonly #users = new Map<string, LocalUser>()
34
47
  readonly #sessions = new Map<string, LocalSession>()
35
48
  readonly #resets = new Map<string, LocalReset>()
49
+ readonly #factors = new Map<string, LocalFactor>()
50
+ readonly #challenges = new Map<string, LocalChallenge>()
51
+ readonly #magicLinks = new Map<string, string>()
36
52
  #server: ReturnType<typeof Bun.serve> | undefined
37
53
  #url = ''
38
54
 
@@ -54,11 +70,14 @@ export class LocalAuthServer {
54
70
  return this.#url
55
71
  }
56
72
 
57
- /** Clears users, sessions, and reset tokens without restarting the listener. */
73
+ /** Clears users, sessions, reset tokens, factors, and challenges without restarting the listener. */
58
74
  reset(): void {
59
75
  this.#users.clear()
60
76
  this.#sessions.clear()
61
77
  this.#resets.clear()
78
+ this.#factors.clear()
79
+ this.#challenges.clear()
80
+ this.#magicLinks.clear()
62
81
  }
63
82
 
64
83
  /** Stops the server and clears fixture state. */
@@ -73,15 +92,22 @@ export class LocalAuthServer {
73
92
  return [...this.#resets.values()].find((entry) => entry.email === email)?.token
74
93
  }
75
94
 
95
+ /** Returns the magic-link token issued for an email, if any. */
96
+ magicLinkToken(email: string): string | undefined {
97
+ return [...this.#magicLinks.entries()].find(([, value]) => value === email)?.[0]
98
+ }
99
+
76
100
  async #handle(request: Request): Promise<Response> {
77
101
  const url = new URL(request.url)
78
102
  const path = url.pathname.replace(/^\/auth\/v1/, '')
79
103
 
80
104
  if (request.method === 'POST' && path === '/signup') {
81
105
  const body = (await request.json()) as { email?: string; password?: string }
82
- if (!body.email || !body.password) return json({ error: 'invalid' }, 400)
106
+ if (!body.email || !body.password) {
107
+ return fail(400, 'validation_failed', 'Unable to validate email address: invalid format')
108
+ }
83
109
  if ([...this.#users.values()].some((user) => user.email === body.email)) {
84
- return json({ error: 'user_exists' }, 422)
110
+ return fail(422, 'user_already_exists', 'User already registered')
85
111
  }
86
112
  const user: LocalUser = {
87
113
  id: randomUUID(),
@@ -89,6 +115,7 @@ export class LocalAuthServer {
89
115
  passwordHash: hashPassword(body.password),
90
116
  }
91
117
  this.#users.set(user.id, user)
118
+ this.#provisionVerifiedTotp(user.id)
92
119
  const session = this.#createSession(user.id)
93
120
  return json({
94
121
  user: { id: user.id, email: user.email },
@@ -99,11 +126,15 @@ export class LocalAuthServer {
99
126
  })
100
127
  }
101
128
 
102
- if (request.method === 'POST' && path === '/token' && url.searchParams.get('grant_type') === 'password') {
129
+ if (
130
+ request.method === 'POST' &&
131
+ path === '/token' &&
132
+ url.searchParams.get('grant_type') === 'password'
133
+ ) {
103
134
  const body = (await request.json()) as { email?: string; password?: string }
104
135
  const user = [...this.#users.values()].find((entry) => entry.email === body.email)
105
136
  if (!user || user.passwordHash !== hashPassword(body.password ?? '')) {
106
- return json({ error: 'invalid_grant', error_description: 'Invalid login credentials' }, 400)
137
+ return fail(400, 'invalid_credentials', 'Invalid login credentials')
107
138
  }
108
139
  const session = this.#createSession(user.id)
109
140
  return json({
@@ -117,26 +148,26 @@ export class LocalAuthServer {
117
148
 
118
149
  if (request.method === 'GET' && path === '/user') {
119
150
  const session = this.#sessionFromAuth(request)
120
- if (!session) return json({ error: 'not_authenticated' }, 401)
151
+ if (!session) return fail(401, 'bad_jwt', 'invalid claim: missing sub claim')
121
152
  const user = this.#users.get(session.userId)
122
- if (!user) return json({ error: 'not_authenticated' }, 401)
153
+ if (!user) return fail(401, 'bad_jwt', 'invalid claim: missing sub claim')
123
154
  return json({ id: user.id, email: user.email })
124
155
  }
125
156
 
126
157
  if (request.method === 'POST' && path === '/logout') {
127
158
  const session = this.#sessionFromAuth(request)
128
- if (!session) return json({ error: 'not_authenticated' }, 401)
159
+ if (!session) return fail(401, 'bad_jwt', 'invalid claim: missing sub claim')
129
160
  this.#sessions.delete(session.accessToken)
130
161
  return new Response(null, { status: 204 })
131
162
  }
132
163
 
133
164
  if (request.method === 'PUT' && path === '/user') {
134
165
  const session = this.#sessionFromAuth(request)
135
- if (!session) return json({ error: 'not_authenticated' }, 401)
166
+ if (!session) return fail(401, 'bad_jwt', 'invalid claim: missing sub claim')
136
167
  const user = this.#users.get(session.userId)
137
- if (!user) return json({ error: 'not_authenticated' }, 401)
168
+ if (!user) return fail(401, 'bad_jwt', 'invalid claim: missing sub claim')
138
169
  const body = (await request.json()) as { password?: string }
139
- if (!body.password) return json({ error: 'invalid' }, 400)
170
+ if (!body.password) return fail(400, 'validation_failed', 'Missing required fields')
140
171
  user.passwordHash = hashPassword(body.password)
141
172
  return json({ id: user.id, email: user.email })
142
173
  }
@@ -151,32 +182,112 @@ export class LocalAuthServer {
151
182
  }
152
183
 
153
184
  if (request.method === 'PUT' && path === '/user' && url.searchParams.has('token')) {
154
- return json({ error: 'use verify' }, 400)
185
+ return fail(400, 'validation_failed', 'Use the verify endpoint for recovery tokens')
155
186
  }
156
187
 
188
+ // GoTrue's `/verify` exchanges a recovery or magic-link token for a session. It has no password
189
+ // parameter: a caller that sends one gets a session back and an unchanged password.
157
190
  if (request.method === 'POST' && path === '/verify') {
158
191
  const body = (await request.json()) as {
159
192
  type?: string
160
193
  token?: string
161
- password?: string
194
+ token_hash?: string
162
195
  }
163
- if (body.type === 'recovery' && body.token && body.password) {
164
- const reset = this.#resets.get(body.token)
165
- if (!reset) return json({ error: 'invalid_token' }, 400)
166
- const user = [...this.#users.values()].find((entry) => entry.email === reset.email)
167
- if (!user) return json({ error: 'invalid_token' }, 400)
168
- user.passwordHash = hashPassword(body.password)
169
- this.#resets.delete(body.token)
170
- return json({ user: { id: user.id, email: user.email } })
196
+ const token = body.token_hash ?? body.token
197
+ if ((body.type !== 'recovery' && body.type !== 'magiclink') || !token) {
198
+ return fail(400, 'validation_failed', 'Missing required fields')
171
199
  }
172
- return json({ error: 'invalid' }, 400)
200
+ const email =
201
+ body.type === 'recovery' ? this.#resets.get(token)?.email : this.#magicLinks.get(token)
202
+ if (!email) return fail(403, 'otp_expired', 'Email link is invalid or has expired')
203
+ const user = [...this.#users.values()].find((entry) => entry.email === email)
204
+ if (!user) return fail(403, 'otp_expired', 'Email link is invalid or has expired')
205
+ if (body.type === 'recovery') this.#resets.delete(token)
206
+ else this.#magicLinks.delete(token)
207
+ const session = this.#createSession(user.id)
208
+ return json({
209
+ user: { id: user.id, email: user.email },
210
+ access_token: session.accessToken,
211
+ refresh_token: session.refreshToken,
212
+ expires_in: 3600,
213
+ token_type: 'bearer',
214
+ })
173
215
  }
174
216
 
175
217
  if (request.method === 'POST' && path === '/otp') {
218
+ const body = (await request.json()) as { email?: string }
219
+ if (body.email) this.#magicLinks.set(`assay-magic-${randomUUID()}`, body.email)
176
220
  return json({})
177
221
  }
178
222
 
179
- return json({ error: 'not_found' }, 404)
223
+ if (request.method === 'GET' && path === '/factors') {
224
+ const session = this.#sessionFromAuth(request)
225
+ if (!session) return fail(401, 'bad_jwt', 'invalid claim: missing sub claim')
226
+ return json({
227
+ factors: [...this.#factors.values()]
228
+ .filter((factor) => factor.userId === session.userId)
229
+ .map((factor) => ({
230
+ id: factor.id,
231
+ factor_type: factor.factorType,
232
+ status: factor.status,
233
+ })),
234
+ })
235
+ }
236
+
237
+ const challengeMatch = /^\/factors\/([^/]+)\/challenge$/.exec(path)
238
+ if (request.method === 'POST' && challengeMatch) {
239
+ const session = this.#sessionFromAuth(request)
240
+ if (!session) return fail(401, 'bad_jwt', 'invalid claim: missing sub claim')
241
+ const factorId = challengeMatch[1]
242
+ const factor = factorId === undefined ? undefined : this.#factors.get(factorId)
243
+ if (!factor || factor.userId !== session.userId)
244
+ return fail(404, 'mfa_factor_not_found', 'MFA factor not found')
245
+ const challenge: LocalChallenge = {
246
+ id: randomUUID(),
247
+ factorId: factor.id,
248
+ expiresAt: Date.now() + 5 * 60 * 1000,
249
+ }
250
+ this.#challenges.set(challenge.id, challenge)
251
+ return json({
252
+ id: challenge.id,
253
+ expires_at: Math.floor(challenge.expiresAt / 1000),
254
+ })
255
+ }
256
+
257
+ const verifyMatch = /^\/factors\/([^/]+)\/verify$/.exec(path)
258
+ if (request.method === 'POST' && verifyMatch) {
259
+ const session = this.#sessionFromAuth(request)
260
+ if (!session) return fail(401, 'bad_jwt', 'invalid claim: missing sub claim')
261
+ const factorId = verifyMatch[1]
262
+ const factor = factorId === undefined ? undefined : this.#factors.get(factorId)
263
+ if (!factor || factor.userId !== session.userId)
264
+ return fail(404, 'mfa_factor_not_found', 'MFA factor not found')
265
+ const body = (await request.json()) as { challenge_id?: string; code?: string }
266
+ const challenge =
267
+ typeof body.challenge_id === 'string' ? this.#challenges.get(body.challenge_id) : undefined
268
+ if (
269
+ !challenge ||
270
+ challenge.factorId !== factor.id ||
271
+ challenge.expiresAt <= Date.now() ||
272
+ body.code !== '123456'
273
+ ) {
274
+ return fail(400, 'mfa_verification_failed', 'Invalid TOTP code entered')
275
+ }
276
+ this.#challenges.delete(challenge.id)
277
+ return json({})
278
+ }
279
+
280
+ return fail(404, 'validation_failed', 'Requested path is invalid')
281
+ }
282
+
283
+ #provisionVerifiedTotp(userId: string): void {
284
+ const factor: LocalFactor = {
285
+ id: randomUUID(),
286
+ userId,
287
+ factorType: 'totp',
288
+ status: 'verified',
289
+ }
290
+ this.#factors.set(factor.id, factor)
180
291
  }
181
292
 
182
293
  #createSession(userId: string): LocalSession {
@@ -201,6 +312,17 @@ export class LocalAuthServer {
201
312
  }
202
313
  }
203
314
 
315
+ /**
316
+ * A GoTrue error envelope.
317
+ *
318
+ * Supabase Auth answers a failure with a numeric `code`, a stable `error_code`, and a `msg`
319
+ * sentence. The driver classifies on that envelope, so this server emits it rather than a shape
320
+ * invented to match the driver.
321
+ */
322
+ function fail(status: number, errorCode: string, msg: string): Response {
323
+ return json({ code: status, error_code: errorCode, msg }, status)
324
+ }
325
+
204
326
  function json(body: unknown, status = 200): Response {
205
327
  return new Response(JSON.stringify(body), {
206
328
  status,
@@ -102,7 +102,7 @@ CREATE TABLE IF NOT EXISTS avelon_failed_jobs (
102
102
  /**
103
103
  * Postgres queue driver using `FOR UPDATE SKIP LOCKED`.
104
104
  *
105
- * The Wave A package names pgmq. This environment does not ship that extension, so the durable
105
+ * This package names pgmq. This environment does not ship that extension, so the durable
106
106
  * semantics are implemented with skip-locked tables rather than silently declaring the capability
107
107
  * false. Swap the SQL dialect for pgmq when the extension is present in CI.
108
108
  */
@@ -179,17 +179,21 @@ export class SupabaseQueue
179
179
  await this.#sql.unsafe(`DELETE FROM avelon_jobs WHERE id = $1`, [row.id])
180
180
  } catch (error: unknown) {
181
181
  if (this.capabilities.retries && row.attempt < this.#maxTries) {
182
- await this.#sql.unsafe(
183
- `UPDATE avelon_jobs SET locked_at = NULL WHERE id = $1`,
184
- [row.id],
185
- )
182
+ await this.#sql.unsafe(`UPDATE avelon_jobs SET locked_at = NULL WHERE id = $1`, [row.id])
186
183
  continue
187
184
  }
188
185
  if (this.capabilities.deadLetter) {
189
186
  await this.#sql.unsafe(
190
187
  `INSERT INTO avelon_failed_jobs (id, queue, name, payload, attempt, error, failed_at)
191
188
  VALUES ($1, $2, $3, $4::jsonb, $5, $6, now())`,
192
- [row.id, row.queue, row.name, encodePayload(row.payload), row.attempt, failureMessage(error)],
189
+ [
190
+ row.id,
191
+ row.queue,
192
+ row.name,
193
+ encodePayload(row.payload),
194
+ row.attempt,
195
+ failureMessage(error),
196
+ ],
193
197
  )
194
198
  }
195
199
  await this.#sql.unsafe(`DELETE FROM avelon_jobs WHERE id = $1`, [row.id])
@@ -1,9 +1,4 @@
1
- import {
2
- Invalid,
3
- Unauthenticated,
4
- type SocialDriver,
5
- type SocialIdentity,
6
- } from '@avelonjs/core'
1
+ import { Invalid, Unauthenticated, type SocialDriver, type SocialIdentity } from '@avelonjs/core'
7
2
  import type { SupabaseSocialProfile } from './types'
8
3
 
9
4
  /** Exact capability declaration for the Supabase social driver. */
@@ -41,9 +36,11 @@ function unauthenticated(message: string): never {
41
36
  * `redirect()` builds a GoTrue authorize URL and records CSRF state on the driver instance.
42
37
  * `callback()` verifies that state, then exchanges the authorization code over HTTP.
43
38
  */
44
- export class SupabaseSocial
45
- implements SocialDriver<typeof supabaseSocialCapabilities, { authUrl: string }, SupabaseSocialProfile>
46
- {
39
+ export class SupabaseSocial implements SocialDriver<
40
+ typeof supabaseSocialCapabilities,
41
+ { authUrl: string },
42
+ SupabaseSocialProfile
43
+ > {
47
44
  readonly name = 'supabase'
48
45
  readonly instance: string
49
46
  readonly capabilities = supabaseSocialCapabilities
@@ -80,9 +80,7 @@ CREATE TABLE IF NOT EXISTS avelon_storage_objects (
80
80
  * environment has no Supabase Storage service.
81
81
  */
82
82
  export class SupabaseStorage
83
- implements
84
- StorageDriver<typeof supabaseStorageCapabilities, SQL>,
85
- SignedUrlStorageSurface
83
+ implements StorageDriver<typeof supabaseStorageCapabilities, SQL>, SignedUrlStorageSurface
86
84
  {
87
85
  readonly name = 'supabase'
88
86
  readonly instance: string
@@ -223,6 +221,7 @@ export function createSupabaseStorage(options: SupabaseStorageOptions = {}): Sup
223
221
  return new SupabaseStorage({
224
222
  url: options.url ?? defaultUrl(),
225
223
  instance: options.instance ?? 'default',
226
- signingSecret: options.signingSecret ?? process.env.AVELON_STORAGE_SIGNING_SECRET ?? 'avelon-storage-secret',
224
+ signingSecret:
225
+ options.signingSecret ?? process.env.AVELON_STORAGE_SIGNING_SECRET ?? 'avelon-storage-secret',
227
226
  })
228
227
  }
@@ -97,10 +97,12 @@ export class SupabaseTokens implements TokenDriver<typeof supabaseTokenCapabilit
97
97
  readonly #sql: SQL
98
98
  readonly #subject: string
99
99
 
100
- constructor(options: Required<Omit<SupabaseTokenOptions, 'url' | 'subject'>> & {
101
- url: string
102
- subject: string
103
- }) {
100
+ constructor(
101
+ options: Required<Omit<SupabaseTokenOptions, 'url' | 'subject'>> & {
102
+ url: string
103
+ subject: string
104
+ },
105
+ ) {
104
106
  this.instance = options.instance
105
107
  this.#subject = options.subject
106
108
  this.#sql = new SQL(options.url)
@@ -129,7 +131,8 @@ export class SupabaseTokens implements TokenDriver<typeof supabaseTokenCapabilit
129
131
  [candidateHash],
130
132
  )) as StoredRow[]
131
133
  const row = rows[0]
132
- const expiresAt = row?.expires_at === null || row?.expires_at === undefined ? null : asDate(row.expires_at)
134
+ const expiresAt =
135
+ row?.expires_at === null || row?.expires_at === undefined ? null : asDate(row.expires_at)
133
136
  if (
134
137
  row === undefined ||
135
138
  row.revoked ||
@@ -192,7 +195,8 @@ export class SupabaseTokens implements TokenDriver<typeof supabaseTokenCapabilit
192
195
  }
193
196
 
194
197
  function normalizeAbilities(value: unknown): string[] {
195
- if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === 'string')
198
+ if (Array.isArray(value))
199
+ return value.filter((entry): entry is string => typeof entry === 'string')
196
200
  if (typeof value === 'string') {
197
201
  try {
198
202
  const parsed: unknown = JSON.parse(value)