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.
- package/CHANGELOG.md +72 -0
- package/LICENSE +21 -0
- package/README.md +92 -2
- package/package.json +114 -4
- package/src/access.js +143 -0
- package/src/account-element.d.ts +8 -0
- package/src/account-element.js +244 -0
- package/src/client.d.ts +113 -0
- package/src/client.js +211 -0
- package/src/core.js +442 -0
- package/src/element.d.ts +6 -0
- package/src/element.js +253 -0
- package/src/email/cloudflare.d.ts +1 -0
- package/src/email/cloudflare.js +19 -0
- package/src/email/console.d.ts +1 -0
- package/src/email/console.js +14 -0
- package/src/email/index.d.ts +4 -0
- package/src/email/message.js +56 -0
- package/src/email/resend.d.ts +1 -0
- package/src/email/resend.js +21 -0
- package/src/email-code.js +75 -0
- package/src/index.d.ts +364 -0
- package/src/index.js +9 -0
- package/src/lock.js +225 -0
- package/src/node.d.ts +3 -0
- package/src/node.js +67 -0
- package/src/oauth/flow.js +81 -0
- package/src/oauth/providers.js +171 -0
- package/src/orgs.js +184 -0
- package/src/passkey/cbor.js +99 -0
- package/src/passkey/webauthn.js +183 -0
- package/src/password.js +81 -0
- package/src/providers.d.ts +28 -0
- package/src/providers.js +1 -0
- package/src/rate-limit.js +41 -0
- package/src/routes/account.js +130 -0
- package/src/routes/email.js +147 -0
- package/src/routes/oauth.js +106 -0
- package/src/routes/orgs.js +144 -0
- package/src/routes/passkeys.js +136 -0
- package/src/routes/password.js +78 -0
- package/src/routes/two-factor.js +89 -0
- package/src/session.js +131 -0
- package/src/stores/d1.d.ts +1 -0
- package/src/stores/d1.js +58 -0
- package/src/stores/index.d.ts +5 -0
- package/src/stores/kv.d.ts +1 -0
- package/src/stores/kv.js +27 -0
- package/src/stores/memory.d.ts +1 -0
- package/src/stores/memory.js +35 -0
- package/src/stores/sqlite.d.ts +1 -0
- package/src/stores/sqlite.js +42 -0
- package/src/tokens.js +119 -0
- package/src/two-factor.js +142 -0
- package/src/ui.js +49 -0
- package/src/users.js +126 -0
- 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
|
+
}
|