@plutocms/supabase 0.11.3 → 0.12.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/.env.example +14 -0
- package/CHANGELOG.md +7 -0
- package/FEATURES.md +5 -1
- package/README.md +66 -0
- package/app/components/access/CopyButton.vue +24 -0
- package/app/components/access/DeleteRoleModal.vue +73 -0
- package/app/components/access/EditRolesSlideover.vue +134 -0
- package/app/components/access/InviteModal.vue +288 -0
- package/app/components/access/LinkField.vue +19 -0
- package/app/components/access/LinkModal.vue +29 -0
- package/app/components/access/RevokeModal.vue +83 -0
- package/app/components/access/RoleSlideover.vue +251 -0
- package/app/components/mail/AuthEmailsPanel.vue +108 -0
- package/app/pages/admin/(auth)/accept-invite.vue +155 -0
- package/app/pages/admin/settings/roles.vue +187 -0
- package/app/pages/admin/settings/users.vue +372 -0
- package/app/plugins/pluto-extension.ts +38 -1
- package/app/utils/access-ui.ts +100 -0
- package/app/utils/auth-driver.ts +3 -0
- package/app/utils/invite-link.ts +70 -0
- package/app/utils/supabase-project.ts +54 -0
- package/db/migrations/008_users_and_roles_admin.sql +462 -0
- package/i18n/locales/en.json +194 -1
- package/i18n/locales/pt-BR.json +194 -1
- package/package.json +3 -2
- package/server/api/access/capabilities.get.ts +17 -0
- package/server/api/access/roles/[key].delete.ts +18 -0
- package/server/api/access/roles/[key].put.ts +26 -0
- package/server/api/access/roles.get.ts +17 -0
- package/server/api/access/roles.post.ts +25 -0
- package/server/api/access/users/[id]/resend.post.ts +172 -0
- package/server/api/access/users/[id]/roles.delete.ts +22 -0
- package/server/api/access/users/[id]/roles.put.ts +24 -0
- package/server/api/access/users/invite-link.post.ts +50 -0
- package/server/api/access/users/invite.post.ts +201 -0
- package/server/api/access/users.get.ts +17 -0
- package/server/utils/access-errors.ts +148 -0
- package/server/utils/access-invite.ts +66 -0
- package/server/utils/access-mail.ts +354 -0
- package/server/utils/admin-guard.ts +4 -3
- package/server/utils/admin-role-guard.ts +44 -0
- package/server/utils/rate-limit.ts +84 -0
- package/server/utils/service-client.ts +18 -0
- package/server/utils/sign-in-link.ts +129 -0
- package/shared/types/access.d.ts +41 -0
- package/shared/types/supabase.ts +50 -9
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import type { H3Event } from 'h3'
|
|
2
|
+
import type { serverSupabaseClient } from '#supabase/server'
|
|
3
|
+
import type { ServiceClient } from './service-client'
|
|
4
|
+
import { mapAccessError } from './access-errors'
|
|
5
|
+
import { INVITE_ACCEPT_PATH } from './access-invite'
|
|
6
|
+
import { emailInvalidError, isEmailInvalidError } from './sign-in-link'
|
|
7
|
+
|
|
8
|
+
type UserClient = Awaited<ReturnType<typeof serverSupabaseClient<Database>>>
|
|
9
|
+
|
|
10
|
+
/** The link types that the accept-invite page can verify. */
|
|
11
|
+
export type AcceptLinkType = 'invite' | 'magiclink' | 'signup'
|
|
12
|
+
|
|
13
|
+
const ACCEPT_LINK_TYPES: readonly string[] = ['invite', 'magiclink', 'signup']
|
|
14
|
+
|
|
15
|
+
function inviteFailed() {
|
|
16
|
+
return createPlutoError({
|
|
17
|
+
statusCode: 502,
|
|
18
|
+
message: 'The invite email could not be sent.',
|
|
19
|
+
key: 'supabase.access.errors.inviteFailed',
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Finds the id of the account with this email, or null. */
|
|
24
|
+
export async function findUserIdByEmail(client: UserClient, email: string): Promise<string | null> {
|
|
25
|
+
const { data, error } = await client.rpc('find_user_id_by_email', { p_email: email })
|
|
26
|
+
|
|
27
|
+
if (error) {
|
|
28
|
+
throw mapAccessError(error)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return data ?? null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Adds roles to a user. It keeps the roles the user has. */
|
|
35
|
+
export async function addRoles(client: UserClient, target: string, roleKeys: string[]): Promise<void> {
|
|
36
|
+
const { error } = await client.rpc('add_user_roles', { target, role_keys: roleKeys })
|
|
37
|
+
|
|
38
|
+
if (error) {
|
|
39
|
+
throw mapAccessError(error)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The URL that the user opens to accept an invite. The accept-invite page
|
|
45
|
+
* reads `token_hash` and `type`, then calls `verifyOtp`.
|
|
46
|
+
*/
|
|
47
|
+
export function acceptInviteUrl(origin: string, hashedToken: string, type: AcceptLinkType): string {
|
|
48
|
+
const url = new URL(INVITE_ACCEPT_PATH, origin)
|
|
49
|
+
|
|
50
|
+
url.searchParams.set('token_hash', hashedToken)
|
|
51
|
+
url.searchParams.set('type', type)
|
|
52
|
+
|
|
53
|
+
return url.href
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface GenerateLinkParams {
|
|
57
|
+
email: string
|
|
58
|
+
type: 'invite' | 'magiclink'
|
|
59
|
+
redirectTo: string
|
|
60
|
+
origin: string
|
|
61
|
+
displayName?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type GeneratedLink =
|
|
65
|
+
| { kind: 'link', userId: string, link: string }
|
|
66
|
+
| { kind: 'exists' }
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Makes a one-time link with the service role key. GoTrue sends no email and
|
|
70
|
+
* skips the email rate limit. An `invite` link creates the user when the
|
|
71
|
+
* email has no account.
|
|
72
|
+
*
|
|
73
|
+
* GoTrue says which type the link has (`verification_type`). The link uses
|
|
74
|
+
* that type when the accept-invite page knows it, else the asked type.
|
|
75
|
+
*/
|
|
76
|
+
export async function generateAcceptLink(
|
|
77
|
+
service: ServiceClient,
|
|
78
|
+
params: GenerateLinkParams
|
|
79
|
+
): Promise<GeneratedLink> {
|
|
80
|
+
const options = { redirectTo: params.redirectTo }
|
|
81
|
+
const { data, error } = params.type === 'invite'
|
|
82
|
+
? await service.auth.admin.generateLink({
|
|
83
|
+
type: 'invite',
|
|
84
|
+
email: params.email,
|
|
85
|
+
options: { ...options, data: params.displayName ? { display_name: params.displayName } : {} },
|
|
86
|
+
})
|
|
87
|
+
: await service.auth.admin.generateLink({
|
|
88
|
+
type: 'magiclink',
|
|
89
|
+
email: params.email,
|
|
90
|
+
options,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
if (error) {
|
|
94
|
+
if (error.code === 'email_exists' || error.code === 'user_already_exists') {
|
|
95
|
+
return { kind: 'exists' }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (isEmailInvalidError(error)) {
|
|
99
|
+
throw emailInvalidError()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.error('invite: generateLink failed:', error.message)
|
|
103
|
+
|
|
104
|
+
throw inviteFailed()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const hashedToken = data?.properties?.hashed_token
|
|
108
|
+
const userId = data?.user?.id
|
|
109
|
+
|
|
110
|
+
if (!hashedToken || !userId) {
|
|
111
|
+
console.error('invite: generateLink returned no token')
|
|
112
|
+
|
|
113
|
+
throw inviteFailed()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const verification = String(data.properties.verification_type ?? '')
|
|
117
|
+
const type = ACCEPT_LINK_TYPES.includes(verification)
|
|
118
|
+
? (verification as AcceptLinkType)
|
|
119
|
+
: params.type
|
|
120
|
+
|
|
121
|
+
return { kind: 'link', userId, link: acceptInviteUrl(params.origin, hashedToken, type) }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface ProvisionParams {
|
|
125
|
+
client: UserClient
|
|
126
|
+
service: ServiceClient
|
|
127
|
+
email: string
|
|
128
|
+
roleKeys: string[]
|
|
129
|
+
displayName?: string
|
|
130
|
+
redirectTo: string
|
|
131
|
+
origin: string
|
|
132
|
+
/** Make a link for an email that has an account. Default: true. */
|
|
133
|
+
linkForExisting?: boolean
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface ProvisionResult {
|
|
137
|
+
userId: string
|
|
138
|
+
/** Null when the account existed and `linkForExisting` is false. */
|
|
139
|
+
link: string | null
|
|
140
|
+
/** True when this call made the account. False when it had one. */
|
|
141
|
+
created: boolean
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The error of a route that needs the service role key. */
|
|
145
|
+
export function serviceKeyRequiredError() {
|
|
146
|
+
return createPlutoError({
|
|
147
|
+
statusCode: 503,
|
|
148
|
+
message: 'Set NUXT_SUPABASE_SECRET_KEY on the server to make invite links.',
|
|
149
|
+
key: 'supabase.access.errors.serviceKeyRequired',
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Grants the roles and makes a sign-in link, with no email. It needs the
|
|
155
|
+
* service role key.
|
|
156
|
+
*
|
|
157
|
+
* - An email with an account (confirmed or not): the roles are added, and the
|
|
158
|
+
* link is a `magiclink`. The account is never deleted.
|
|
159
|
+
* - A new email: the `invite` link creates the user, then the roles are set.
|
|
160
|
+
* If the roles fail, the new user is deleted.
|
|
161
|
+
* - The account appears after the lookup (a race): the roles are added.
|
|
162
|
+
*/
|
|
163
|
+
export async function provisionAccess(params: ProvisionParams): Promise<ProvisionResult> {
|
|
164
|
+
const { client, service, email, roleKeys } = params
|
|
165
|
+
const link = (type: 'invite' | 'magiclink') =>
|
|
166
|
+
generateAcceptLink(service, { ...params, type })
|
|
167
|
+
|
|
168
|
+
async function forExisting(userId: string): Promise<ProvisionResult> {
|
|
169
|
+
await addRoles(client, userId, roleKeys)
|
|
170
|
+
|
|
171
|
+
if (params.linkForExisting === false) {
|
|
172
|
+
return { userId, link: null, created: false }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// A magic link signs in AS that account. A confirmed account has its own
|
|
176
|
+
// password and needs no link, so the admin never gets one: this keeps an
|
|
177
|
+
// admin from taking over a customer's or another admin's account. Only an
|
|
178
|
+
// account that never confirmed (a pending invite, or a sign-up with no
|
|
179
|
+
// confirmation) gets a link, because it cannot sign in without one.
|
|
180
|
+
const { data: found, error: lookupError } = await service.auth.admin.getUserById(userId)
|
|
181
|
+
|
|
182
|
+
if (lookupError || !found.user) {
|
|
183
|
+
console.error('invite: getUserById failed for an existing account')
|
|
184
|
+
|
|
185
|
+
throw inviteFailed()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (found.user.email_confirmed_at) {
|
|
189
|
+
return { userId, link: null, created: false }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const made = await link('magiclink')
|
|
193
|
+
|
|
194
|
+
if (made.kind !== 'link') {
|
|
195
|
+
throw inviteFailed()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { userId, link: made.link, created: false }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const existingId = await findUserIdByEmail(client, email)
|
|
202
|
+
|
|
203
|
+
if (existingId) {
|
|
204
|
+
return forExisting(existingId)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const made = await link('invite')
|
|
208
|
+
|
|
209
|
+
if (made.kind === 'exists') {
|
|
210
|
+
const racedId = await findUserIdByEmail(client, email)
|
|
211
|
+
|
|
212
|
+
if (!racedId) {
|
|
213
|
+
throw inviteFailed()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return forExisting(racedId)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// The lookup found no account, so this call made it. Only this new account
|
|
220
|
+
// may be deleted when the role write fails.
|
|
221
|
+
const { error: setError } = await client.rpc('set_user_roles', {
|
|
222
|
+
target: made.userId,
|
|
223
|
+
role_keys: roleKeys,
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
if (setError) {
|
|
227
|
+
const { error: undoError } = await service.auth.admin.deleteUser(made.userId)
|
|
228
|
+
|
|
229
|
+
if (undoError) {
|
|
230
|
+
console.error('invite: could not delete the new user:', undoError.message)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
throw mapAccessError(setError)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { userId: made.userId, link: made.link, created: true }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** True when Pluto mail can send. A failed check counts as not ready. */
|
|
240
|
+
export async function plutoMailReady(): Promise<boolean> {
|
|
241
|
+
try {
|
|
242
|
+
return (await getPlutoMailStatus()).configured === true
|
|
243
|
+
} catch {
|
|
244
|
+
return false
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The site name for the email: the `website_title` setting, else the config name. */
|
|
249
|
+
export async function inviteSiteName(client: UserClient): Promise<string> {
|
|
250
|
+
try {
|
|
251
|
+
const { data } = await client
|
|
252
|
+
.from('settings')
|
|
253
|
+
.select('setting_value')
|
|
254
|
+
.eq('setting_key', 'website_title')
|
|
255
|
+
.maybeSingle()
|
|
256
|
+
const title = data?.setting_value?.trim()
|
|
257
|
+
|
|
258
|
+
if (title) {
|
|
259
|
+
return title
|
|
260
|
+
}
|
|
261
|
+
} catch {
|
|
262
|
+
// The setting is not readable. Use the config name below.
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return String(useRuntimeConfig().public.websiteName ?? 'Pluto')
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The labels of the roles, in the order of the keys. A key with no label stays as it is. */
|
|
269
|
+
async function roleLabels(client: UserClient, roleKeys: string[]): Promise<string[]> {
|
|
270
|
+
try {
|
|
271
|
+
const { data } = await client.rpc('list_roles')
|
|
272
|
+
const labels = new Map((data ?? []).map((role) => [role.key, role.label]))
|
|
273
|
+
|
|
274
|
+
return roleKeys.map((key) => labels.get(key) || key)
|
|
275
|
+
} catch {
|
|
276
|
+
return roleKeys
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
interface InviteMailParams {
|
|
281
|
+
client: UserClient
|
|
282
|
+
to: string
|
|
283
|
+
link: string
|
|
284
|
+
userId: string
|
|
285
|
+
roleKeys: string[]
|
|
286
|
+
inviterName?: string
|
|
287
|
+
locale?: string
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Sends the invite email with Pluto mail. It returns false when the send
|
|
292
|
+
* fails. The caller keeps the user and the roles, and gives the link to the
|
|
293
|
+
* admin. The log holds the error code only: no recipient, link or key.
|
|
294
|
+
*/
|
|
295
|
+
export async function sendInviteMail(params: InviteMailParams): Promise<boolean> {
|
|
296
|
+
try {
|
|
297
|
+
const [siteName, roles] = await Promise.all([
|
|
298
|
+
inviteSiteName(params.client),
|
|
299
|
+
roleLabels(params.client, params.roleKeys),
|
|
300
|
+
])
|
|
301
|
+
const email = await renderPlutoEmail(
|
|
302
|
+
'invite',
|
|
303
|
+
{ url: params.link, inviterName: params.inviterName, roles },
|
|
304
|
+
{ locale: params.locale, siteName }
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
await sendPlutoMail({
|
|
308
|
+
to: params.to,
|
|
309
|
+
...email,
|
|
310
|
+
idempotencyKey: `invite/${params.userId}/${globalThis.crypto.randomUUID()}`,
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
return true
|
|
314
|
+
} catch (error) {
|
|
315
|
+
const code = (error as { code?: unknown } | null)?.code
|
|
316
|
+
|
|
317
|
+
console.error('invite: the Pluto mail send failed:', typeof code === 'string' ? code : 'unknown')
|
|
318
|
+
|
|
319
|
+
return false
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The name of the admin who invites, for the email. Empty when unknown. */
|
|
324
|
+
export function inviterNameOf(user: { user_metadata?: Record<string, unknown> | null }): string | undefined {
|
|
325
|
+
const meta = user.user_metadata ?? {}
|
|
326
|
+
|
|
327
|
+
for (const value of [meta.display_name, meta.full_name, meta.name]) {
|
|
328
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
329
|
+
return value.trim()
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return undefined
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Checks the optional email language against the admin languages. Throws a
|
|
338
|
+
* 400 for a language the admin does not have.
|
|
339
|
+
*/
|
|
340
|
+
export function assertInviteLocale(event: H3Event, locale: string | undefined): void {
|
|
341
|
+
if (locale === undefined) {
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const adminLocales = useRuntimeConfig(event).public.plutoI18n?.adminLocales ?? []
|
|
346
|
+
|
|
347
|
+
if (!adminLocales.includes(locale)) {
|
|
348
|
+
throw createPlutoError({
|
|
349
|
+
statusCode: 400,
|
|
350
|
+
message: 'This language is not supported.',
|
|
351
|
+
key: 'supabase.errors.localeUnsupported',
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
}
|
|
@@ -22,15 +22,16 @@ import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
|
|
|
22
22
|
* so querying it directly has no migration dependency at all.
|
|
23
23
|
*
|
|
24
24
|
* The trade-off: a user granted the `admin` role only through
|
|
25
|
-
* `user_roles` (see `permissions-storage/SKILL.md`'s "Granting a role by
|
|
25
|
+
* `user_roles` by hand (see `permissions-storage/SKILL.md`'s "Granting a role by
|
|
26
26
|
* hand"), with `profiles.is_admin` left `false`, cannot pass this check —
|
|
27
27
|
* only RLS and `has_capability()`-gated routes recognize a role-only
|
|
28
28
|
* admin. That is an acceptable, narrow limitation: `user_roles` cannot
|
|
29
29
|
* exist before `004` has been applied, so a role-only admin can only
|
|
30
30
|
* exist on a site that has already applied every current migration, at
|
|
31
31
|
* which point there is nothing left for that admin to need
|
|
32
|
-
* `/admin/migrations` for.
|
|
33
|
-
*
|
|
32
|
+
* `/admin/migrations` for. Migration 008 also closes the gap: `set_user_roles`
|
|
33
|
+
* and `add_user_roles` keep `profiles.is_admin` in step with the `admin`
|
|
34
|
+
* role. After a hand-made grant, set `profiles.is_admin = true` too.
|
|
34
35
|
*
|
|
35
36
|
* `serverSupabaseUser` (from `@nuxtjs/supabase`, backed by
|
|
36
37
|
* `client.auth.getClaims()`) returns decoded JWT claims, not a Supabase
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { H3Event } from 'h3'
|
|
2
|
+
import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Guards a server route so only an admin can call it, through
|
|
6
|
+
* `public.is_admin()`. That function reads `profiles.is_admin` and the
|
|
7
|
+
* `admin` role, so it also accepts an admin who has the role only.
|
|
8
|
+
*
|
|
9
|
+
* Use it for the access API (migration 008). Keep `requireAdmin` for the
|
|
10
|
+
* migrations routes: it needs no migration at all.
|
|
11
|
+
*
|
|
12
|
+
* The rpc runs with the session of the caller, so `auth.uid()` is the caller.
|
|
13
|
+
* Throws a 401 with no user, or a 403 for a user who is not an admin.
|
|
14
|
+
* Returns the claims (the user id is `sub`).
|
|
15
|
+
*/
|
|
16
|
+
export async function requireAdminRole(event: H3Event) {
|
|
17
|
+
const user = await serverSupabaseUser(event)
|
|
18
|
+
|
|
19
|
+
if (!user) {
|
|
20
|
+
throw createPlutoError({
|
|
21
|
+
statusCode: 401,
|
|
22
|
+
message: 'You must be logged in.',
|
|
23
|
+
key: 'supabase.errors.notLoggedIn',
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const client = await serverSupabaseClient<Database>(event)
|
|
28
|
+
const { data, error } = await client.rpc('is_admin')
|
|
29
|
+
|
|
30
|
+
if (error) {
|
|
31
|
+
// Logged, never returned: the 403 must not tell the caller why.
|
|
32
|
+
console.error('requireAdminRole: is_admin() failed:', error.message)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (error || data !== true) {
|
|
36
|
+
throw createPlutoError({
|
|
37
|
+
statusCode: 403,
|
|
38
|
+
message: 'Your account is not an admin.',
|
|
39
|
+
key: 'supabase.errors.notAdmin',
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return user
|
|
44
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small in-memory rate limiter. It keeps the hit times of each key in a
|
|
3
|
+
* sliding window. The state lives in one server process, so it is a brake
|
|
4
|
+
* for a single instance, not a global limit.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface RateLimitOptions {
|
|
8
|
+
/** The number of hits allowed in the window. */
|
|
9
|
+
limit: number
|
|
10
|
+
/** The window length, in milliseconds. */
|
|
11
|
+
windowMs: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RateLimitResult {
|
|
15
|
+
ok: boolean
|
|
16
|
+
/** Seconds until the next hit is allowed. 0 when `ok`. */
|
|
17
|
+
retryAfter: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createRateLimiter(
|
|
21
|
+
options: RateLimitOptions,
|
|
22
|
+
now: () => number = Date.now
|
|
23
|
+
) {
|
|
24
|
+
const hits = new Map<string, number[]>()
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
/** Counts one hit for the key. It does not count a refused hit. */
|
|
28
|
+
hit(key: string): RateLimitResult {
|
|
29
|
+
const time = now()
|
|
30
|
+
const recent = (hits.get(key) ?? []).filter(
|
|
31
|
+
(stamp) => time - stamp < options.windowMs
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if (recent.length >= options.limit) {
|
|
35
|
+
hits.set(key, recent)
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
retryAfter: Math.max(
|
|
40
|
+
1,
|
|
41
|
+
Math.ceil((recent[0]! + options.windowMs - time) / 1000)
|
|
42
|
+
),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
recent.push(time)
|
|
47
|
+
hits.set(key, recent)
|
|
48
|
+
|
|
49
|
+
// Drop the keys that have no recent hit, so the map does not grow.
|
|
50
|
+
if (hits.size > 1000) {
|
|
51
|
+
for (const [other, stamps] of hits) {
|
|
52
|
+
if (stamps.every((stamp) => time - stamp >= options.windowMs)) {
|
|
53
|
+
hits.delete(other)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { ok: true, retryAfter: 0 }
|
|
59
|
+
},
|
|
60
|
+
reset() {
|
|
61
|
+
hits.clear()
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 10 invites per 10 minutes for each caller. */
|
|
67
|
+
const inviteLimiter = createRateLimiter({ limit: 10, windowMs: 10 * 60 * 1000 })
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Throws a 429 when the caller sent too many invites. `sub` is the user id
|
|
71
|
+
* of the caller (the JWT `sub` claim).
|
|
72
|
+
*/
|
|
73
|
+
export function enforceInviteRateLimit(sub: string) {
|
|
74
|
+
const result = inviteLimiter.hit(sub)
|
|
75
|
+
|
|
76
|
+
if (!result.ok) {
|
|
77
|
+
throw createPlutoError({
|
|
78
|
+
statusCode: 429,
|
|
79
|
+
message: 'Too many invites. Wait a few minutes and try again.',
|
|
80
|
+
key: 'supabase.access.errors.rateLimited',
|
|
81
|
+
data: { retryAfter: result.retryAfter },
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { H3Event } from 'h3'
|
|
2
|
+
import { serverSupabaseServiceRole } from '#supabase/server'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns the Supabase client with the service role key, or null when the
|
|
6
|
+
* key is not set. The key is optional for invites: without it, the invite
|
|
7
|
+
* routes send a sign-in link instead (see `sign-in-link.ts`).
|
|
8
|
+
*/
|
|
9
|
+
export function optionalServiceClient(event: H3Event) {
|
|
10
|
+
try {
|
|
11
|
+
return serverSupabaseServiceRole<Database>(event)
|
|
12
|
+
} catch {
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The client of `optionalServiceClient`, when the key is set. */
|
|
18
|
+
export type ServiceClient = NonNullable<ReturnType<typeof optionalServiceClient>>
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { H3Event } from 'h3'
|
|
2
|
+
import { createClient } from '@supabase/supabase-js'
|
|
3
|
+
|
|
4
|
+
interface SignInLinkOptions {
|
|
5
|
+
redirectTo: string
|
|
6
|
+
displayName?: string
|
|
7
|
+
/** Create the account when the email has none. */
|
|
8
|
+
createUser: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Sends a passwordless sign-in link with the anon key. The invite routes use
|
|
13
|
+
* it when the service role key is not set, so an invite needs no setup.
|
|
14
|
+
*
|
|
15
|
+
* - A new client per call, with no session storage, so the admin's own
|
|
16
|
+
* session is never touched.
|
|
17
|
+
* - The implicit flow: the link comes back with the tokens in the URL hash.
|
|
18
|
+
* The PKCE flow cannot work here, because its code verifier would stay in
|
|
19
|
+
* this server-side client. The accept-invite page reads the hash.
|
|
20
|
+
* - With `createUser`, GoTrue creates the account (public sign-up must be
|
|
21
|
+
* on). The trigger makes the profile from `display_name`.
|
|
22
|
+
*/
|
|
23
|
+
export async function sendSignInLink(
|
|
24
|
+
event: H3Event,
|
|
25
|
+
email: string,
|
|
26
|
+
options: SignInLinkOptions
|
|
27
|
+
) {
|
|
28
|
+
const { url, key } = useRuntimeConfig(event).public.supabase
|
|
29
|
+
const client = createClient(url, key, {
|
|
30
|
+
auth: {
|
|
31
|
+
flowType: 'implicit',
|
|
32
|
+
persistSession: false,
|
|
33
|
+
autoRefreshToken: false,
|
|
34
|
+
detectSessionInUrl: false,
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const { error } = await client.auth.signInWithOtp({
|
|
39
|
+
email,
|
|
40
|
+
options: {
|
|
41
|
+
shouldCreateUser: options.createUser,
|
|
42
|
+
emailRedirectTo: options.redirectTo,
|
|
43
|
+
data: options.displayName ? { display_name: options.displayName } : undefined,
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
return { error }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* True when Supabase's built-in mailer refuses the address. Without custom
|
|
52
|
+
* SMTP, Supabase sends auth emails only to members of the project's
|
|
53
|
+
* organization.
|
|
54
|
+
*/
|
|
55
|
+
export function isEmailNotAuthorizedError(error: { code?: string } | null | undefined): boolean {
|
|
56
|
+
return error?.code === 'email_address_not_authorized'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function emailNotAuthorizedError() {
|
|
60
|
+
return createPlutoError({
|
|
61
|
+
statusCode: 400,
|
|
62
|
+
message: 'Supabase\'s built-in email sends only to members of your Supabase organization. Set up custom SMTP in Supabase to invite other addresses.',
|
|
63
|
+
key: 'supabase.access.errors.emailNotAuthorized',
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* True when Supabase refuses to send more auth emails: one per address each
|
|
69
|
+
* 60 seconds, and a few per hour for the project with the built-in mailer.
|
|
70
|
+
* This is not the in-memory limit of `rate-limit.ts`.
|
|
71
|
+
*/
|
|
72
|
+
export function isEmailRateLimitError(error: { code?: string, status?: number } | null | undefined): boolean {
|
|
73
|
+
return error?.status === 429 || error?.code === 'over_email_send_rate_limit'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function emailRateLimitError() {
|
|
77
|
+
return createPlutoError({
|
|
78
|
+
statusCode: 429,
|
|
79
|
+
message: 'Supabase sent too many emails. Wait a minute for the same address, or up to an hour with the built-in mailer. Custom SMTP in Supabase raises this limit.',
|
|
80
|
+
key: 'supabase.access.errors.emailRateLimited',
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* True when Supabase refuses the address itself, for example an example or
|
|
86
|
+
* test domain (`example.com`), or an address it cannot deliver to.
|
|
87
|
+
*/
|
|
88
|
+
export function isEmailInvalidError(error: { code?: string } | null | undefined): boolean {
|
|
89
|
+
return error?.code === 'email_address_invalid'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function emailInvalidError() {
|
|
93
|
+
return createPlutoError({
|
|
94
|
+
statusCode: 400,
|
|
95
|
+
message: 'Supabase does not accept this email address. Use a real address: Supabase refuses example and test domains.',
|
|
96
|
+
key: 'supabase.access.errors.emailInvalid',
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Maps a GoTrue error of `signInWithOtp` to a Pluto error. */
|
|
101
|
+
export function signInLinkError(error: { code?: string, status?: number, message: string }) {
|
|
102
|
+
if (isEmailNotAuthorizedError(error)) {
|
|
103
|
+
return emailNotAuthorizedError()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (isEmailInvalidError(error)) {
|
|
107
|
+
return emailInvalidError()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (isEmailRateLimitError(error)) {
|
|
111
|
+
return emailRateLimitError()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (error.code === 'signup_disabled' || error.code === 'otp_disabled') {
|
|
115
|
+
return createPlutoError({
|
|
116
|
+
statusCode: 503,
|
|
117
|
+
message: 'Public sign-up is off. Set NUXT_SUPABASE_SECRET_KEY on the server to send invites.',
|
|
118
|
+
key: 'supabase.access.errors.signupDisabled',
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.error('invite: signInWithOtp failed:', error.message)
|
|
123
|
+
|
|
124
|
+
return createPlutoError({
|
|
125
|
+
statusCode: 502,
|
|
126
|
+
message: 'The invite email could not be sent.',
|
|
127
|
+
key: 'supabase.access.errors.inviteFailed',
|
|
128
|
+
})
|
|
129
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** A staff user, as `GET /api/access/users` returns it (`list_staff_users`). */
|
|
2
|
+
export interface AccessUser {
|
|
3
|
+
id: string
|
|
4
|
+
email: string | null
|
|
5
|
+
display_name: string | null
|
|
6
|
+
username: string | null
|
|
7
|
+
/** The `profiles.is_admin` flag. */
|
|
8
|
+
is_admin: boolean
|
|
9
|
+
/** The role keys of the user. */
|
|
10
|
+
roles: string[]
|
|
11
|
+
invited_at: string | null
|
|
12
|
+
/** Empty until the user accepts the invite. */
|
|
13
|
+
email_confirmed_at: string | null
|
|
14
|
+
last_sign_in_at: string | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A role, as `GET /api/access/roles` returns it (`list_roles`). */
|
|
18
|
+
export interface AccessRole {
|
|
19
|
+
key: string
|
|
20
|
+
label: string
|
|
21
|
+
description: string
|
|
22
|
+
is_builtin: boolean
|
|
23
|
+
capabilities: string[]
|
|
24
|
+
user_count: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The answer of `GET /api/access/capabilities`. */
|
|
28
|
+
export interface AccessCapabilities {
|
|
29
|
+
/** True when the service role key is set on the server. */
|
|
30
|
+
serviceKey: boolean
|
|
31
|
+
mail: { configured: boolean }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The answer of `POST /api/access/users/invite`. */
|
|
35
|
+
export interface AccessInviteResult {
|
|
36
|
+
status: 'invited' | 'granted'
|
|
37
|
+
/** Present on the Pluto mail path. False when the send failed. */
|
|
38
|
+
emailSent?: boolean
|
|
39
|
+
/** Present when `emailSent` is false. */
|
|
40
|
+
link?: string
|
|
41
|
+
}
|