slauthjs 0.0.0-stage → 2.0.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/LICENSE +21 -0
  3. package/README.md +92 -2
  4. package/package.json +114 -4
  5. package/src/access.js +143 -0
  6. package/src/account-element.d.ts +8 -0
  7. package/src/account-element.js +244 -0
  8. package/src/client.d.ts +113 -0
  9. package/src/client.js +211 -0
  10. package/src/core.js +442 -0
  11. package/src/element.d.ts +6 -0
  12. package/src/element.js +253 -0
  13. package/src/email/cloudflare.d.ts +1 -0
  14. package/src/email/cloudflare.js +19 -0
  15. package/src/email/console.d.ts +1 -0
  16. package/src/email/console.js +14 -0
  17. package/src/email/index.d.ts +4 -0
  18. package/src/email/message.js +56 -0
  19. package/src/email/resend.d.ts +1 -0
  20. package/src/email/resend.js +21 -0
  21. package/src/email-code.js +75 -0
  22. package/src/index.d.ts +364 -0
  23. package/src/index.js +9 -0
  24. package/src/lock.js +225 -0
  25. package/src/node.d.ts +3 -0
  26. package/src/node.js +67 -0
  27. package/src/oauth/flow.js +81 -0
  28. package/src/oauth/providers.js +171 -0
  29. package/src/orgs.js +184 -0
  30. package/src/passkey/cbor.js +99 -0
  31. package/src/passkey/webauthn.js +183 -0
  32. package/src/password.js +81 -0
  33. package/src/providers.d.ts +28 -0
  34. package/src/providers.js +1 -0
  35. package/src/rate-limit.js +41 -0
  36. package/src/routes/account.js +130 -0
  37. package/src/routes/email.js +147 -0
  38. package/src/routes/oauth.js +106 -0
  39. package/src/routes/orgs.js +144 -0
  40. package/src/routes/passkeys.js +136 -0
  41. package/src/routes/password.js +78 -0
  42. package/src/routes/two-factor.js +89 -0
  43. package/src/session.js +131 -0
  44. package/src/stores/d1.d.ts +1 -0
  45. package/src/stores/d1.js +58 -0
  46. package/src/stores/index.d.ts +5 -0
  47. package/src/stores/kv.d.ts +1 -0
  48. package/src/stores/kv.js +27 -0
  49. package/src/stores/memory.d.ts +1 -0
  50. package/src/stores/memory.js +35 -0
  51. package/src/stores/sqlite.d.ts +1 -0
  52. package/src/stores/sqlite.js +42 -0
  53. package/src/tokens.js +119 -0
  54. package/src/two-factor.js +142 -0
  55. package/src/ui.js +49 -0
  56. package/src/users.js +126 -0
  57. package/src/util.js +106 -0
@@ -0,0 +1,81 @@
1
+ import { base64Url, randomToken, readCookie, serializeCookie, sha256 } from '../util.js'
2
+
3
+ const STATE_SECONDS = 10 * 60
4
+ const COOKIE = 'slauth_oauth'
5
+
6
+ /**
7
+ * The OAuth 2.0 authorization-code flow with PKCE (RFC 7636) and a `state` that's tied
8
+ * to this browser by a short-lived cookie, so nobody can finish a sign-in someone else
9
+ * started (login CSRF).
10
+ *
11
+ * oauth:<state> { provider, verifier, redirect, returnTo, linkUserId } (lives 10 minutes)
12
+ *
13
+ * @param {{ store: import('../index.js').Store, fetch?: typeof fetch }} options
14
+ */
15
+ export function createOAuthFlow({ store, fetch: doFetch = (...args) => fetch(...args) }) {
16
+ async function endpoints(provider) {
17
+ if (!provider.discover) return { authorization: provider.authorization, token: provider.token }
18
+ const config = await provider.discover(doFetch)
19
+ return { authorization: config.authorization_endpoint, token: config.token_endpoint }
20
+ }
21
+
22
+ return {
23
+ fetch: doFetch,
24
+
25
+ /** Where to send the browser, and the cookie that ties the flow to it. */
26
+ async start(provider, { callbackUrl, redirect, returnTo, linkUserId = null, secure }) {
27
+ const state = randomToken()
28
+ const verifier = randomToken()
29
+ await store.put(`oauth:${state}`, { provider: provider.id, verifier, redirect, returnTo, linkUserId }, { ttl: STATE_SECONDS })
30
+ const url = new URL((await endpoints(provider)).authorization)
31
+ url.searchParams.set('response_type', 'code')
32
+ url.searchParams.set('client_id', provider.clientId)
33
+ url.searchParams.set('redirect_uri', callbackUrl)
34
+ url.searchParams.set('state', state)
35
+ if (provider.scope) url.searchParams.set('scope', provider.scope)
36
+ if (provider.pkce) {
37
+ url.searchParams.set('code_challenge', base64Url(await sha256(verifier)))
38
+ url.searchParams.set('code_challenge_method', 'S256')
39
+ }
40
+ for (const [key, value] of Object.entries(provider.params ?? {})) url.searchParams.set(key, value)
41
+ return { location: url.toString(), cookie: serializeCookie(COOKIE, state, { maxAge: STATE_SECONDS, secure }) }
42
+ },
43
+
44
+ /**
45
+ * Finishes the flow: checks state, swaps the code for tokens, reads the profile.
46
+ * @returns {Promise<{ ok: true, profile: object, tokens: object, state: object } | { ok: false, code: string, state?: object }>}
47
+ */
48
+ async finish(provider, request, { callbackUrl }) {
49
+ const url = new URL(request.url)
50
+ const state = url.searchParams.get('state')
51
+ const fromCookie = readCookie(request.headers.get('cookie'), COOKIE)
52
+ const saved = state ? await store.get(`oauth:${state}`) : null
53
+ if (state) await store.delete(`oauth:${state}`)
54
+ if (!saved || saved.provider !== provider.id || !fromCookie || fromCookie !== state) return { ok: false, code: 'oauth-state' }
55
+ if (url.searchParams.get('error')) return { ok: false, code: url.searchParams.get('error') === 'access_denied' ? 'oauth-cancelled' : 'oauth-failed', state: saved }
56
+ const code = url.searchParams.get('code')
57
+ if (!code) return { ok: false, code: 'oauth-failed', state: saved }
58
+
59
+ const body = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: callbackUrl })
60
+ if (provider.pkce) body.set('code_verifier', saved.verifier)
61
+ const headers = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }
62
+ if (provider.tokenAuth === 'basic') headers.Authorization = `Basic ${btoa(`${encodeURIComponent(provider.clientId)}:${encodeURIComponent(provider.clientSecret ?? '')}`)}`
63
+ else {
64
+ body.set('client_id', provider.clientId)
65
+ if (provider.clientSecret) body.set('client_secret', provider.clientSecret)
66
+ }
67
+ const res = await doFetch((await endpoints(provider)).token, { method: 'POST', headers, body })
68
+ const tokens = await res.json().catch(() => ({}))
69
+ if (!res.ok || !tokens.access_token) {
70
+ console.error(`slauth: ${provider.id} wouldn't swap the code for a token:`, res.status, tokens.error ?? '')
71
+ return { ok: false, code: 'oauth-failed', state: saved }
72
+ }
73
+ const profile = await provider.profile(tokens, doFetch)
74
+ if (!profile?.id) return { ok: false, code: 'oauth-failed', state: saved }
75
+ if (provider.trustEmail && profile.email) profile.emailVerified = true
76
+ return { ok: true, profile, tokens, state: saved }
77
+ },
78
+
79
+ clearCookie: (secure) => serializeCookie(COOKIE, '', { maxAge: 0, secure }),
80
+ }
81
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * "Sign in with …" providers. Each is a plain object, so adding your own is just
3
+ * oauth2({ ... }) or oidc({ ... }):
4
+ *
5
+ * import { github, google } from 'slauthjs/providers'
6
+ * slauth({ ..., providers: [github({ clientId, clientSecret }), google({ clientId, clientSecret })] })
7
+ *
8
+ * The callback URL to register with each provider is <your site>/auth/oauth/<id>/callback.
9
+ *
10
+ * A provider's `profile(tokens, fetch)` returns { id, email, emailVerified, name, image }.
11
+ * slauth only signs someone in by email when the provider says the email is verified,
12
+ * so nobody can claim your account by making a provider account with your address.
13
+ */
14
+
15
+ /**
16
+ * Any OAuth 2.0 provider.
17
+ *
18
+ * @param {{
19
+ * id: string, name: string, clientId: string, clientSecret?: string,
20
+ * authorization: string, token: string, scope?: string, params?: Record<string, string>,
21
+ * pkce?: boolean, tokenAuth?: 'post' | 'basic',
22
+ * profile(tokens: Record<string, any>, fetch: typeof fetch): Promise<{ id: string, email?: string | null, emailVerified?: boolean, name?: string | null, image?: string | null }>,
23
+ * trustEmail?: boolean, keepTokens?: boolean,
24
+ * }} options
25
+ */
26
+ export function oauth2(options) {
27
+ for (const key of ['id', 'clientId', 'authorization', 'token', 'profile']) {
28
+ if (!options?.[key]) throw new Error(`slauth: the ${options?.id ?? 'OAuth'} provider needs \`${key}\`.`)
29
+ }
30
+ if (!/^[a-z0-9-]+$/.test(options.id)) throw new Error(`slauth: provider ids are lower-case letters, digits and dashes, not "${options.id}".`)
31
+ return { type: 'oauth2', name: options.id, pkce: true, tokenAuth: 'post', scope: '', params: {}, ...options }
32
+ }
33
+
34
+ /**
35
+ * Any OpenID Connect provider, found from its issuer's discovery document. The profile
36
+ * comes from the userinfo endpoint, using the access token we just got over TLS.
37
+ *
38
+ * @param {{ id: string, name: string, issuer: string, clientId: string, clientSecret?: string, scope?: string, params?: Record<string, string>, trustEmail?: boolean, keepTokens?: boolean, tokenAuth?: 'post' | 'basic' }} options
39
+ */
40
+ export function oidc({ issuer, scope = 'openid email profile', ...options }) {
41
+ if (!issuer) throw new Error(`slauth: the ${options.id ?? 'OIDC'} provider needs \`issuer\`.`)
42
+ let discovered = null
43
+ const discover = (doFetch) =>
44
+ (discovered ??= doFetch(`${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`)
45
+ .then((res) => {
46
+ if (!res.ok) throw new Error(`slauth: couldn't read ${issuer}'s OpenID configuration (${res.status}).`)
47
+ return res.json()
48
+ })
49
+ .catch((error) => {
50
+ discovered = null
51
+ throw error
52
+ }))
53
+ return oauth2({
54
+ // Filled in from discovery when the flow starts (see flow.js).
55
+ authorization: 'discover',
56
+ token: 'discover',
57
+ discover,
58
+ scope,
59
+ async profile(tokens, doFetch) {
60
+ const config = await discover(doFetch)
61
+ const info = await getJson(doFetch, config.userinfo_endpoint, tokens.access_token)
62
+ return { id: String(info.sub), email: info.email ?? null, emailVerified: info.email_verified === true || info.email_verified === 'true', name: info.name ?? null, image: info.picture ?? null }
63
+ },
64
+ ...options,
65
+ })
66
+ }
67
+
68
+ /** GitHub. Make an OAuth app at github.com/settings/developers. */
69
+ export function github({ clientId, clientSecret, scope = 'read:user user:email', ...rest }) {
70
+ return oauth2({
71
+ id: 'github',
72
+ name: 'GitHub',
73
+ clientId,
74
+ clientSecret,
75
+ scope,
76
+ authorization: 'https://github.com/login/oauth/authorize',
77
+ token: 'https://github.com/login/oauth/access_token',
78
+ async profile(tokens, doFetch) {
79
+ const me = await getJson(doFetch, 'https://api.github.com/user', tokens.access_token)
80
+ // The profile email can be hidden or unverified; /user/emails says which is which.
81
+ const emails = await getJson(doFetch, 'https://api.github.com/user/emails', tokens.access_token).catch(() => [])
82
+ const primary = emails.find((e) => e.primary && e.verified) ?? emails.find((e) => e.verified)
83
+ return { id: String(me.id), email: primary?.email ?? null, emailVerified: Boolean(primary), name: me.name || me.login, image: me.avatar_url ?? null }
84
+ },
85
+ ...rest,
86
+ })
87
+ }
88
+
89
+ /** Google. Make an OAuth client at console.cloud.google.com/apis/credentials. */
90
+ export function google({ clientId, clientSecret, scope = 'openid email profile', ...rest }) {
91
+ return oauth2({
92
+ id: 'google',
93
+ name: 'Google',
94
+ clientId,
95
+ clientSecret,
96
+ scope,
97
+ authorization: 'https://accounts.google.com/o/oauth2/v2/auth',
98
+ token: 'https://oauth2.googleapis.com/token',
99
+ params: { prompt: 'select_account' },
100
+ async profile(tokens, doFetch) {
101
+ const info = await getJson(doFetch, 'https://openidconnect.googleapis.com/v1/userinfo', tokens.access_token)
102
+ return { id: String(info.sub), email: info.email ?? null, emailVerified: info.email_verified === true, name: info.name ?? null, image: info.picture ?? null }
103
+ },
104
+ ...rest,
105
+ })
106
+ }
107
+
108
+ /** Discord. Make an application at discord.com/developers/applications. */
109
+ export function discord({ clientId, clientSecret, scope = 'identify email', ...rest }) {
110
+ return oauth2({
111
+ id: 'discord',
112
+ name: 'Discord',
113
+ clientId,
114
+ clientSecret,
115
+ scope,
116
+ authorization: 'https://discord.com/oauth2/authorize',
117
+ token: 'https://discord.com/api/oauth2/token',
118
+ async profile(tokens, doFetch) {
119
+ const me = await getJson(doFetch, 'https://discord.com/api/users/@me', tokens.access_token)
120
+ const image = me.avatar ? `https://cdn.discordapp.com/avatars/${me.id}/${me.avatar}.png` : null
121
+ return { id: String(me.id), email: me.email ?? null, emailVerified: me.verified === true, name: me.global_name ?? me.username, image }
122
+ },
123
+ ...rest,
124
+ })
125
+ }
126
+
127
+ /** GitLab (gitlab.com, or your own with `baseUrl`). */
128
+ export function gitlab({ clientId, clientSecret, baseUrl = 'https://gitlab.com', ...rest }) {
129
+ return oidc({ id: 'gitlab', name: 'GitLab', issuer: baseUrl, clientId, clientSecret, scope: 'openid email profile', ...rest })
130
+ }
131
+
132
+ /**
133
+ * Microsoft (personal accounts, and work or school accounts with `tenant`). Microsoft
134
+ * doesn't promise a work account's email belongs to the person, so by default only
135
+ * personal accounts' emails count as verified. With a single `tenant` you control,
136
+ * set trustEmail: true.
137
+ */
138
+ export function microsoft({ clientId, clientSecret, tenant = 'common', ...rest }) {
139
+ const CONSUMERS = '9188040d-6c67-4c5b-b112-36a304b66dad'
140
+ return oauth2({
141
+ id: 'microsoft',
142
+ name: 'Microsoft',
143
+ clientId,
144
+ clientSecret,
145
+ scope: 'openid email profile User.Read',
146
+ authorization: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`,
147
+ token: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
148
+ async profile(tokens, doFetch) {
149
+ const info = await getJson(doFetch, 'https://graph.microsoft.com/oidc/userinfo', tokens.access_token)
150
+ const claims = decodeJwtPayload(tokens.id_token)
151
+ return { id: String(info.sub), email: info.email ?? null, emailVerified: claims?.tid === CONSUMERS, name: info.name ?? null, image: null }
152
+ },
153
+ ...rest,
154
+ })
155
+ }
156
+
157
+ async function getJson(doFetch, url, accessToken) {
158
+ const res = await doFetch(url, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', 'User-Agent': 'slauth' } })
159
+ if (!res.ok) throw new Error(`slauth: ${new URL(url).host} said ${res.status} when asked for the profile.`)
160
+ return res.json()
161
+ }
162
+
163
+ /** Reads (doesn't check) a JWT's claims; only for tokens we got straight from the provider. */
164
+ function decodeJwtPayload(token) {
165
+ try {
166
+ const part = String(token).split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
167
+ return JSON.parse(atob(part + '='.repeat((4 - (part.length % 4)) % 4)))
168
+ } catch {
169
+ return null
170
+ }
171
+ }
package/src/orgs.js ADDED
@@ -0,0 +1,184 @@
1
+ import { randomId } from './util.js'
2
+ import { normalizeEmail } from './users.js'
3
+
4
+ const INVITE_DAYS = 7
5
+ /** Roles that can manage an organization. Any other role string is just a label for your app. */
6
+ const MANAGERS = new Set(['owner', 'admin'])
7
+
8
+ /**
9
+ * Organizations (teams, workspaces, families: whatever your app calls them).
10
+ *
11
+ * org:<orgId> { id, name, metadata, createdAt, createdBy }
12
+ * members:<orgId> [{ userId, role, joinedAt }]
13
+ * orgs:<userId> [orgId, ...]
14
+ * invite:<inviteId> { id, orgId, email, role, invitedBy, createdAt, expiresAt }
15
+ * invites:<email> [inviteId, ...]
16
+ * org-invites:<orgId> [inviteId, ...]
17
+ *
18
+ * Roles: "owner" (everything, including deleting the org), "admin" (members and
19
+ * invitations), and anything else you like ("member", "billing", "viewer").
20
+ *
21
+ * @param {import('./index.js').Store} store
22
+ */
23
+ export function createOrgs(store) {
24
+ const list = async (key) => (await store.get(key)) ?? []
25
+ const save = async (key, items) => (items.length ? store.put(key, items) : store.delete(key))
26
+
27
+ async function get(orgId) {
28
+ return typeof orgId === 'string' ? store.get(`org:${orgId}`) : null
29
+ }
30
+
31
+ async function members(orgId) {
32
+ return list(`members:${orgId}`)
33
+ }
34
+
35
+ async function role(orgId, userId) {
36
+ return (await members(orgId)).find((m) => m.userId === userId)?.role ?? null
37
+ }
38
+
39
+ async function addMember(orgId, userId, memberRole = 'member') {
40
+ const current = await members(orgId)
41
+ if (current.some((m) => m.userId === userId)) return setRole(orgId, userId, memberRole)
42
+ await store.put(`members:${orgId}`, [...current, { userId, role: memberRole, joinedAt: Date.now() }])
43
+ const mine = await list(`orgs:${userId}`)
44
+ if (!mine.includes(orgId)) await store.put(`orgs:${userId}`, [...mine, orgId])
45
+ return true
46
+ }
47
+
48
+ async function setRole(orgId, userId, memberRole) {
49
+ const current = await members(orgId)
50
+ const member = current.find((m) => m.userId === userId)
51
+ if (!member) return false
52
+ if (member.role === 'owner' && memberRole !== 'owner' && current.filter((m) => m.role === 'owner').length === 1) {
53
+ throw new OrgError('An organization needs at least one owner.', 'last-owner')
54
+ }
55
+ await store.put(`members:${orgId}`, current.map((m) => (m.userId === userId ? { ...m, role: memberRole } : m)))
56
+ return true
57
+ }
58
+
59
+ async function removeMember(orgId, userId) {
60
+ const current = await members(orgId)
61
+ const member = current.find((m) => m.userId === userId)
62
+ if (!member) return false
63
+ if (member.role === 'owner' && current.filter((m) => m.role === 'owner').length === 1 && current.length > 1) {
64
+ throw new OrgError('Make someone else an owner first.', 'last-owner')
65
+ }
66
+ await save(`members:${orgId}`, current.filter((m) => m.userId !== userId))
67
+ await save(`orgs:${userId}`, (await list(`orgs:${userId}`)).filter((id) => id !== orgId))
68
+ // The last person out turns off the lights.
69
+ if (current.length === 1) await remove(orgId)
70
+ return true
71
+ }
72
+
73
+ async function remove(orgId) {
74
+ for (const m of await members(orgId)) await save(`orgs:${m.userId}`, (await list(`orgs:${m.userId}`)).filter((id) => id !== orgId))
75
+ for (const id of await list(`org-invites:${orgId}`)) await revokeInvite(id)
76
+ await store.delete(`members:${orgId}`)
77
+ await store.delete(`org-invites:${orgId}`)
78
+ await store.delete(`org:${orgId}`)
79
+ }
80
+
81
+ async function invite(orgId, email, memberRole = 'member', invitedBy = null) {
82
+ const to = normalizeEmail(email)
83
+ const id = randomId('inv')
84
+ const now = Date.now()
85
+ const doc = { id, orgId, email: to, role: memberRole, invitedBy, createdAt: now, expiresAt: now + INVITE_DAYS * 86_400_000 }
86
+ await store.put(`invite:${id}`, doc, { ttl: INVITE_DAYS * 86_400 })
87
+ await store.put(`invites:${to}`, [...(await list(`invites:${to}`)), id])
88
+ await store.put(`org-invites:${orgId}`, [...(await list(`org-invites:${orgId}`)), id])
89
+ return doc
90
+ }
91
+
92
+ async function revokeInvite(inviteId) {
93
+ const doc = typeof inviteId === 'string' ? await store.get(`invite:${inviteId}`) : null
94
+ if (!doc) return false
95
+ await store.delete(`invite:${inviteId}`)
96
+ await save(`invites:${doc.email}`, (await list(`invites:${doc.email}`)).filter((id) => id !== inviteId))
97
+ await save(`org-invites:${doc.orgId}`, (await list(`org-invites:${doc.orgId}`)).filter((id) => id !== inviteId))
98
+ return true
99
+ }
100
+
101
+ async function live(ids) {
102
+ const out = []
103
+ for (const id of ids) {
104
+ const doc = await store.get(`invite:${id}`)
105
+ if (doc && doc.expiresAt > Date.now()) out.push(doc)
106
+ }
107
+ return out
108
+ }
109
+
110
+ return {
111
+ get,
112
+ members,
113
+ role,
114
+ addMember,
115
+ setRole,
116
+ removeMember,
117
+ remove,
118
+ invite,
119
+ revokeInvite,
120
+
121
+ async create({ name, metadata = {} }, ownerId) {
122
+ const org = { id: randomId('org'), name: String(name).trim().slice(0, 100), metadata, createdAt: Date.now(), createdBy: ownerId ?? null }
123
+ await store.put(`org:${org.id}`, org)
124
+ if (ownerId) await addMember(org.id, ownerId, 'owner')
125
+ return org
126
+ },
127
+
128
+ async update(orgId, patch) {
129
+ const org = await get(orgId)
130
+ if (!org) return null
131
+ const next = { ...org }
132
+ if (typeof patch.name === 'string' && patch.name.trim()) next.name = patch.name.trim().slice(0, 100)
133
+ if (patch.metadata !== undefined) next.metadata = patch.metadata
134
+ await store.put(`org:${orgId}`, next)
135
+ return next
136
+ },
137
+
138
+ /** The organizations someone belongs to, each with their role. */
139
+ async forUser(userId) {
140
+ const out = []
141
+ for (const id of await list(`orgs:${userId}`)) {
142
+ const org = await get(id)
143
+ const r = org && (await role(id, userId))
144
+ if (r) out.push({ ...org, role: r })
145
+ }
146
+ return out
147
+ },
148
+
149
+ invitesFor: async (email) => live(await list(`invites:${normalizeEmail(email)}`)),
150
+ invitesOf: async (orgId) => live(await list(`org-invites:${orgId}`)),
151
+ getInvite: async (inviteId) => (typeof inviteId === 'string' ? store.get(`invite:${inviteId}`) : null),
152
+
153
+ /** Accepts an invitation addressed to this user's email. */
154
+ async accept(inviteId, user) {
155
+ const doc = typeof inviteId === 'string' ? await store.get(`invite:${inviteId}`) : null
156
+ if (!doc || doc.email !== user.email || doc.expiresAt <= Date.now() || !(await get(doc.orgId))) return null
157
+ await addMember(doc.orgId, user.id, doc.role)
158
+ await revokeInvite(inviteId)
159
+ return doc
160
+ },
161
+
162
+ /** Called when a user is deleted. */
163
+ async forget(userId) {
164
+ for (const id of await list(`orgs:${userId}`)) {
165
+ const current = await members(id)
166
+ const rest = current.filter((m) => m.userId !== userId)
167
+ // Hand ownership on to the longest-standing member rather than leave it ownerless.
168
+ if (rest.length && !rest.some((m) => m.role === 'owner')) rest[0] = { ...rest[0], role: 'owner' }
169
+ if (rest.length) await store.put(`members:${id}`, rest)
170
+ else await remove(id)
171
+ }
172
+ await store.delete(`orgs:${userId}`)
173
+ },
174
+
175
+ canManage: (memberRole) => MANAGERS.has(memberRole),
176
+ }
177
+ }
178
+
179
+ export class OrgError extends Error {
180
+ constructor(message, code) {
181
+ super(message)
182
+ this.code = code
183
+ }
184
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Just enough CBOR (RFC 8949) to read what authenticators send: integers, byte and
3
+ * text strings, arrays, maps, booleans, null and floats. Definite lengths only, which
4
+ * is all WebAuthn uses.
5
+ */
6
+
7
+ /**
8
+ * Decodes one item from `bytes` starting at `offset`.
9
+ * @param {Uint8Array} bytes
10
+ * @returns {{ value: any, offset: number }} the item and where the next one starts
11
+ */
12
+ export function decodeCbor(bytes, offset = 0) {
13
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
14
+ let at = offset
15
+
16
+ const need = (n) => {
17
+ if (at + n > bytes.length) throw new Error('CBOR: ran out of bytes.')
18
+ }
19
+
20
+ const length = (info) => {
21
+ if (info < 24) return info
22
+ if (info === 24) return need(1), view.getUint8(at++)
23
+ if (info === 25) return need(2), (at += 2), view.getUint16(at - 2)
24
+ if (info === 26) return need(4), (at += 4), view.getUint32(at - 4)
25
+ if (info === 27) {
26
+ need(8)
27
+ const big = view.getBigUint64(at)
28
+ at += 8
29
+ if (big > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('CBOR: number too large.')
30
+ return Number(big)
31
+ }
32
+ throw new Error('CBOR: indefinite lengths are not supported.')
33
+ }
34
+
35
+ const item = (depth) => {
36
+ if (depth > 16) throw new Error('CBOR: nested too deeply.')
37
+ need(1)
38
+ const first = view.getUint8(at++)
39
+ const major = first >> 5
40
+ const info = first & 31
41
+ switch (major) {
42
+ case 0:
43
+ return length(info)
44
+ case 1:
45
+ return -1 - length(info)
46
+ case 2: {
47
+ const n = length(info)
48
+ need(n)
49
+ at += n
50
+ return bytes.slice(at - n, at)
51
+ }
52
+ case 3: {
53
+ const n = length(info)
54
+ need(n)
55
+ at += n
56
+ return new TextDecoder().decode(bytes.subarray(at - n, at))
57
+ }
58
+ case 4: {
59
+ const n = length(info)
60
+ const out = []
61
+ for (let i = 0; i < n; i++) out.push(item(depth + 1))
62
+ return out
63
+ }
64
+ case 5: {
65
+ const n = length(info)
66
+ const out = new Map()
67
+ for (let i = 0; i < n; i++) {
68
+ const key = item(depth + 1)
69
+ out.set(key, item(depth + 1))
70
+ }
71
+ return out
72
+ }
73
+ case 6:
74
+ // A tag: skip it and read what it wraps.
75
+ length(info)
76
+ return item(depth + 1)
77
+ case 7:
78
+ if (info === 20) return false
79
+ if (info === 21) return true
80
+ if (info === 22 || info === 23) return null
81
+ if (info === 25) return need(2), (at += 2), halfFloat(view.getUint16(at - 2))
82
+ if (info === 26) return need(4), (at += 4), view.getFloat32(at - 4)
83
+ if (info === 27) return need(8), (at += 8), view.getFloat64(at - 8)
84
+ throw new Error('CBOR: unsupported simple value.')
85
+ }
86
+ }
87
+
88
+ const value = item(0)
89
+ return { value, offset: at }
90
+ }
91
+
92
+ function halfFloat(bits) {
93
+ const exp = (bits >> 10) & 0x1f
94
+ const frac = bits & 0x3ff
95
+ const sign = bits & 0x8000 ? -1 : 1
96
+ if (exp === 0) return sign * 2 ** -14 * (frac / 1024)
97
+ if (exp === 31) return frac ? NaN : sign * Infinity
98
+ return sign * 2 ** (exp - 15) * (1 + frac / 1024)
99
+ }