@avelonjs/supabase 0.1.0 → 0.3.1
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
|
@@ -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,
|
|
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)
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
194
|
+
token_hash?: string
|
|
162
195
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/queue/driver.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
[
|
|
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])
|
package/src/social/driver.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/storage/driver.ts
CHANGED
|
@@ -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:
|
|
224
|
+
signingSecret:
|
|
225
|
+
options.signingSecret ?? process.env.AVELON_STORAGE_SIGNING_SECRET ?? 'avelon-storage-secret',
|
|
227
226
|
})
|
|
228
227
|
}
|
package/src/tokens/driver.ts
CHANGED
|
@@ -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(
|
|
101
|
-
|
|
102
|
-
|
|
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 =
|
|
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))
|
|
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)
|