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,244 @@
1
+ import { createClient } from './client.js'
2
+ import { MESSAGES, STYLE, escape } from './ui.js'
3
+
4
+ /**
5
+ * <slauth-account>: an account page in one tag. Name and email, passkeys, password,
6
+ * linked providers, two-factor, signed-in devices, organization invitations, and
7
+ * signing out or deleting the account. It shows only what the server has turned on,
8
+ * and is themed with the same CSS variables as <slauth-sign-in>.
9
+ *
10
+ * Attributes: base-path (default /auth), signed-out (where to go after signing out or
11
+ * deleting the account; default "/").
12
+ *
13
+ * import 'slauthjs/account'
14
+ * <slauth-account></slauth-account>
15
+ */
16
+ class SlauthAccount extends HTMLElement {
17
+ connectedCallback() {
18
+ this.auth = createClient({ basePath: this.getAttribute('base-path') ?? '/auth' })
19
+ this.data = null
20
+ this.ui = { error: '', notice: '', busy: false, editing: null, setup: null, recoveryCodes: null, newEmail: '' }
21
+ this.attachShadow({ mode: 'open' })
22
+ this.render()
23
+ this.load()
24
+ }
25
+
26
+ async load() {
27
+ try {
28
+ const [session, config] = await Promise.all([this.auth.session(), this.auth.config()])
29
+ if (!session.user) {
30
+ this.data = { signedOut: true }
31
+ return this.render()
32
+ }
33
+ const [passkeys, accounts, sessions, invites] = await Promise.all([
34
+ config.passkeys ? this.auth.passkey.list() : { passkeys: [] },
35
+ this.auth.accounts.list(),
36
+ this.auth.sessions.list(),
37
+ config.organizations ? this.auth.orgs.invites() : { invites: [] },
38
+ ])
39
+ const orgs = config.organizations ? (await this.auth.orgs.list()).orgs : []
40
+ this.data = { user: session.user, config, passkeys: passkeys.passkeys, ...accounts, sessions: sessions.sessions, invites: invites.invites, orgs }
41
+ } catch (error) {
42
+ this.ui.error = error.message
43
+ }
44
+ this.render()
45
+ }
46
+
47
+ async run(action, notice = '') {
48
+ this.ui = { ...this.ui, busy: true, error: '', notice: '' }
49
+ this.render()
50
+ try {
51
+ await action()
52
+ this.ui.notice = notice
53
+ await this.load()
54
+ } catch (error) {
55
+ this.ui.error = MESSAGES[error.code] ?? error.message ?? 'Something went wrong.'
56
+ } finally {
57
+ this.ui.busy = false
58
+ if (this.isConnected) this.render()
59
+ }
60
+ }
61
+
62
+ leave() {
63
+ window.location.assign(this.getAttribute('signed-out') ?? '/')
64
+ }
65
+
66
+ render() {
67
+ const d = this.data
68
+ const { error, notice, busy, editing, setup, recoveryCodes } = this.ui
69
+ const off = busy ? 'disabled' : ''
70
+ const date = (ms) => new Date(ms).toLocaleDateString(undefined, { dateStyle: 'medium' })
71
+ let body
72
+ if (!d) body = '<p>Loading…</p>'
73
+ else if (d.signedOut) body = '<h2>Your account</h2><p>Please sign in first.</p>'
74
+ else {
75
+ const { user, config } = d
76
+ const providerRows = config.providers
77
+ .map((p) => {
78
+ const linked = d.accounts.find((a) => a.provider === p.id)
79
+ return `<li><span>${escape(p.name)}${linked ? ` <span class="muted">${escape(linked.email ?? linked.username ?? '')}</span>` : ''}</span>${
80
+ linked ? `<button class="link" data-on="unlink" data-id="${escape(p.id)}" ${off}>Disconnect</button>` : `<button class="link" data-on="link" data-id="${escape(p.id)}" ${off}>Connect</button>`
81
+ }</li>`
82
+ })
83
+ .join('')
84
+ body = `
85
+ <h2>Your account</h2>
86
+ <p>${escape(user.email)}</p>
87
+
88
+ <h3>Profile</h3>
89
+ <form data-on="name">
90
+ <label for="name">Name</label>
91
+ <input id="name" autocomplete="name" value="${escape(user.name ?? '')}" />
92
+ <button class="ghost" ${off}>Save name</button>
93
+ </form>
94
+ ${
95
+ editing === 'email'
96
+ ? this.ui.newEmail
97
+ ? `<form data-on="email-code"><label for="email-code">Code sent to ${escape(this.ui.newEmail)}</label><input id="email-code" class="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" required /><button class="primary" ${off}>Confirm new email</button></form>`
98
+ : `<form data-on="email"><label for="email">New email</label><input id="email" type="email" autocomplete="email" required /><button class="primary" ${off}>Send a code</button></form>`
99
+ : `<div class="row"><button class="link" data-on="edit-email">Change email</button></div>`
100
+ }
101
+
102
+ ${
103
+ config.passkeys
104
+ ? `<h3>Passkeys</h3>
105
+ <ul class="list">${d.passkeys.map((pk) => `<li><span>${escape(pk.name)} <span class="muted">added ${date(pk.createdAt)}</span></span><button class="link" data-on="remove-passkey" data-id="${escape(pk.id)}" ${off}>Remove</button></li>`).join('') || '<li><span class="muted">None yet.</span></li>'}</ul>
106
+ ${this.auth.passkey.supported() ? `<button class="ghost dashed" data-on="add-passkey" ${off}>Add a passkey</button>` : ''}`
107
+ : ''
108
+ }
109
+
110
+ ${
111
+ config.passwords || config.providers.length
112
+ ? `<h3>Ways to sign in</h3>
113
+ <ul class="list">
114
+ ${config.passwords ? `<li><span>Password <span class="muted">${d.password ? 'set' : 'not set'}</span></span><button class="link" data-on="edit-password">${d.password ? 'Change' : 'Set'}</button></li>` : ''}
115
+ ${providerRows}
116
+ </ul>
117
+ ${
118
+ editing === 'password'
119
+ ? `<form data-on="password">${d.password ? '<label for="current">Current password</label><input id="current" type="password" autocomplete="current-password" />' : ''}<label for="password">New password</label><input id="password" type="password" autocomplete="new-password" minlength="8" required /><button class="primary" ${off}>Save password</button></form>`
120
+ : ''
121
+ }`
122
+ : ''
123
+ }
124
+
125
+ ${
126
+ config.twoFactor
127
+ ? `<h3>Two-factor sign-in</h3>
128
+ ${
129
+ recoveryCodes
130
+ ? `<p>Save these recovery codes somewhere safe. Each one works once if you lose your phone.</p><div class="codes">${recoveryCodes.map((c) => `<span>${escape(c)}</span>`).join('')}</div><button class="ghost" data-on="codes-done">I've saved them</button>`
131
+ : setup
132
+ ? `<p>Add this to your authenticator app (<a href="${escape(setup.uri)}">open in app</a>), or type the key:</p><p class="codes"><span>${escape(setup.secret.replace(/(.{4})/g, '$1 ').trim())}</span></p>
133
+ <form data-on="enable-2fa"><label for="totp">Code from the app</label><input id="totp" class="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" required /><button class="primary" ${off}>Turn on</button></form>`
134
+ : d.twoFactor
135
+ ? `<p>On: signing in asks for a code from your authenticator app.</p>
136
+ <form data-on="disable-2fa"><label for="totp">Code from the app (or a recovery code) to turn it off</label><input id="totp" autocomplete="one-time-code" required /><button class="ghost" ${off}>Turn off</button></form>`
137
+ : `<p>Off. Turn it on to ask for a code from an authenticator app when you sign in.</p><button class="ghost" data-on="setup-2fa" ${off}>Set up</button>`
138
+ }`
139
+ : ''
140
+ }
141
+
142
+ <h3>Signed-in devices</h3>
143
+ <ul class="list">${d.sessions
144
+ .map((s) => `<li><span>${escape(device(s.userAgent))} <span class="muted">${s.current ? 'this device' : `since ${date(s.createdAt)}`}</span></span>${s.current ? '' : `<button class="link" data-on="revoke" data-id="${escape(s.id)}" ${off}>Sign out</button>`}</li>`)
145
+ .join('')}</ul>
146
+ ${d.sessions.length > 1 ? `<button class="ghost" data-on="revoke-others" ${off}>Sign out everywhere else</button>` : ''}
147
+
148
+ ${
149
+ config.organizations && (d.invites.length || d.orgs.length)
150
+ ? `<h3>Organizations</h3>
151
+ <ul class="list">
152
+ ${d.invites.map((i) => `<li><span>${escape(i.org.name)} <span class="muted">invited as ${escape(i.role)}</span></span><span><button class="link" data-on="accept" data-id="${escape(i.id)}" ${off}>Join</button> <button class="link" data-on="decline" data-id="${escape(i.id)}" ${off}>Decline</button></span></li>`).join('')}
153
+ ${d.orgs.map((o) => `<li><span>${escape(o.name)}</span><span class="muted">${escape(o.role)}</span></li>`).join('')}
154
+ </ul>`
155
+ : ''
156
+ }
157
+
158
+ <h3>Leave</h3>
159
+ <button class="ghost" data-on="sign-out" ${off}>Sign out</button>
160
+ ${editing === 'delete' ? `<p>This deletes your account and everything in it, for good.</p><button class="primary" data-on="delete" ${off}>Yes, delete my account</button>` : `<div class="row"><button class="link" data-on="edit-delete">Delete my account</button></div>`}`
161
+ }
162
+ this.shadowRoot.innerHTML = `<style>${STYLE}:host { max-width: 480px; }</style><div class="card" part="card">${body}${notice ? `<p role="status">${escape(notice)}</p>` : ''}<p class="error" role="alert">${escape(error)}</p></div>`
163
+
164
+ const on = (key, handler) => {
165
+ for (const el of this.shadowRoot.querySelectorAll(`[data-on="${key}"]`)) {
166
+ el.addEventListener(el.tagName === 'FORM' ? 'submit' : 'click', (event) => {
167
+ event.preventDefault()
168
+ handler(el.dataset.id)
169
+ })
170
+ }
171
+ }
172
+ const value = (id) => this.shadowRoot.getElementById(id)?.value ?? ''
173
+ const edit = (what) => ((this.ui = { ...this.ui, editing: this.ui.editing === what ? null : what, error: '', newEmail: '' }), this.render())
174
+
175
+ on('name', () => {
176
+ const name = value('name')
177
+ return this.run(() => this.auth.user.update({ name }), 'Saved.')
178
+ })
179
+ on('edit-email', () => edit('email'))
180
+ on('email', () => {
181
+ const email = value('email').trim()
182
+ return this.run(async () => {
183
+ await this.auth.email.change.start(email)
184
+ this.ui.newEmail = email
185
+ })
186
+ })
187
+ on('email-code', () => {
188
+ const code = value('email-code')
189
+ return this.run(async () => {
190
+ await this.auth.email.change.verify(this.ui.newEmail, code)
191
+ this.ui = { ...this.ui, editing: null, newEmail: '' }
192
+ }, 'Your email is changed.')
193
+ })
194
+ on('add-passkey', () => this.run(() => this.auth.passkey.register(), 'Passkey added.'))
195
+ on('remove-passkey', (id) => this.run(() => this.auth.passkey.remove(id)))
196
+ on('edit-password', () => edit('password'))
197
+ on('password', () => {
198
+ const current = value('current') || undefined
199
+ const password = value('password')
200
+ return this.run(async () => {
201
+ await this.auth.password.set(password, current)
202
+ this.ui.editing = null
203
+ }, 'Password saved.')
204
+ })
205
+ on('link', (id) => this.auth.oauth.link(id, { redirect: window.location.pathname }))
206
+ on('unlink', (id) => this.run(() => this.auth.accounts.unlink(id)))
207
+ on('setup-2fa', () =>
208
+ this.run(async () => {
209
+ this.ui.setup = await this.auth.twoFactor.setup()
210
+ }),
211
+ )
212
+ on('enable-2fa', () => {
213
+ const code = value('totp')
214
+ return this.run(async () => {
215
+ const { recoveryCodes: codes } = await this.auth.twoFactor.enable(code)
216
+ this.ui = { ...this.ui, setup: null, recoveryCodes: codes }
217
+ })
218
+ })
219
+ on('codes-done', () => ((this.ui.recoveryCodes = null), this.render()))
220
+ on('disable-2fa', () => {
221
+ const code = value('totp').trim()
222
+ return this.run(() => this.auth.twoFactor.disable(/^\d{6}$/.test(code) ? { code } : { recoveryCode: code }), 'Two-factor sign-in is off.')
223
+ })
224
+ on('revoke', (id) => this.run(() => this.auth.sessions.revoke(id)))
225
+ on('revoke-others', () => this.run(() => this.auth.sessions.revokeOthers(), 'Signed out everywhere else.'))
226
+ on('accept', (id) => this.run(() => this.auth.orgs.accept(id)))
227
+ on('decline', (id) => this.run(() => this.auth.orgs.decline(id)))
228
+ on('sign-out', () => this.run(async () => (await this.auth.signOut(), this.leave())))
229
+ on('edit-delete', () => edit('delete'))
230
+ on('delete', () => this.run(async () => (await this.auth.user.delete(), this.leave())))
231
+ }
232
+ }
233
+
234
+ /** "Chrome on macOS" from a user agent, roughly. */
235
+ function device(ua) {
236
+ if (!ua) return 'Unknown device'
237
+ const browser = /Edg\//.test(ua) ? 'Edge' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'A browser'
238
+ const os = /iPhone|iPad/.test(ua) ? 'iOS' : /Android/.test(ua) ? 'Android' : /Mac OS X/.test(ua) ? 'macOS' : /Windows/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : ''
239
+ return os ? `${browser} on ${os}` : browser
240
+ }
241
+
242
+ if (typeof customElements !== 'undefined' && !customElements.get('slauth-account')) {
243
+ customElements.define('slauth-account', SlauthAccount)
244
+ }
@@ -0,0 +1,113 @@
1
+ import type { OrgRole, Policy, SlauthUser } from './index.js'
2
+
3
+ export class SlauthError extends Error {
4
+ /** e.g. "bad-code", "expired", "not-allowed", "slow-down", "signed-out", "reauth-required", "offline". */
5
+ code: string
6
+ status: number
7
+ [detail: string]: unknown
8
+ }
9
+
10
+ /** A sign-in reply: the user, or a request for the two-factor code. */
11
+ export type SignInResult = { user: SlauthUser; twoFactor?: undefined } | { twoFactor: true; user?: undefined }
12
+
13
+ export interface ClientConfig {
14
+ appName: string
15
+ emailCodes: boolean
16
+ magicLinks: boolean
17
+ passkeys: boolean
18
+ passwords: boolean
19
+ twoFactor: boolean
20
+ organizations: boolean
21
+ providers: { id: string; name: string }[]
22
+ }
23
+
24
+ export interface SlauthClient {
25
+ session(): Promise<{ user: SlauthUser | null; twoFactor?: 'pending'; impersonatorId?: string; lock?: { policy: Policy; required: boolean; pinSet: boolean; unlocked: boolean } }>
26
+ config(): Promise<ClientConfig>
27
+ email: {
28
+ start(email: string, options?: { redirect?: string }): Promise<{ ok: true }>
29
+ verify(email: string, code: string): Promise<SignInResult>
30
+ link(token: string): Promise<SignInResult & { redirect: string }>
31
+ change: {
32
+ start(email: string): Promise<{ ok: true }>
33
+ verify(email: string, code: string): Promise<{ user: SlauthUser }>
34
+ }
35
+ }
36
+ password: {
37
+ signIn(email: string, password: string): Promise<SignInResult>
38
+ signUp(email: string, password: string, options?: { redirect?: string }): Promise<{ ok: true; verify: 'email' }>
39
+ set(password: string, current?: string): Promise<{ ok: true }>
40
+ remove(): Promise<{ ok: true }>
41
+ }
42
+ oauth: {
43
+ /** Navigates to the provider; returns the URL. */
44
+ signIn(provider: string, options?: { redirect?: string }): string
45
+ link(provider: string, options?: { redirect?: string }): string
46
+ url(provider: string, options?: { redirect?: string }): string
47
+ }
48
+ twoFactor: {
49
+ verify(code: string | { code: string } | { recoveryCode: string }): Promise<{ user: SlauthUser; recoveryCodesLeft?: number }>
50
+ setup(): Promise<{ secret: string; uri: string }>
51
+ enable(code: string): Promise<{ ok: true; recoveryCodes: string[] }>
52
+ disable(code: string | { code: string } | { recoveryCode: string }): Promise<{ ok: true }>
53
+ recoveryCodes(code: string): Promise<{ ok: true; recoveryCodes: string[] }>
54
+ }
55
+ passkey: {
56
+ supported(): boolean
57
+ register(name?: string): Promise<{ ok: true; passkey: { id: string; name: string } }>
58
+ signIn(): Promise<SignInResult>
59
+ list(): Promise<{ passkeys: { id: string; name: string; createdAt: number; lastUsedAt: number | null }[] }>
60
+ rename(id: string, name: string): Promise<{ ok: true }>
61
+ remove(id: string): Promise<{ ok: true }>
62
+ }
63
+ signOut(): Promise<{ ok: true }>
64
+ user: {
65
+ update(patch: { name?: string | null; image?: string | null }): Promise<{ user: SlauthUser }>
66
+ delete(): Promise<{ ok: true }>
67
+ }
68
+ sessions: {
69
+ list(): Promise<{ sessions: { id: string; createdAt: number; lastActiveAt: number; method: string | null; userAgent: string | null; ip: string | null; current: boolean }[] }>
70
+ revoke(id: string): Promise<{ ok: true }>
71
+ revokeOthers(): Promise<{ ok: true }>
72
+ }
73
+ accounts: {
74
+ list(): Promise<{ accounts: { provider: string; name: string; email: string | null; username: string | null; createdAt: number }[]; password: boolean; twoFactor: boolean }>
75
+ unlink(provider: string): Promise<{ ok: true }>
76
+ }
77
+ /** A JWT for your other services (needs `tokens` on), cached until shortly before it expires. */
78
+ getToken(): Promise<string>
79
+ orgs: {
80
+ list(): Promise<{ orgs: { id: string; name: string; role: OrgRole; metadata: Record<string, unknown>; createdAt: number }[] }>
81
+ create(name: string): Promise<{ org: { id: string; name: string; role: 'owner' } }>
82
+ get(id: string): Promise<{
83
+ org: { id: string; name: string; metadata: Record<string, unknown>; createdAt: number }
84
+ role: OrgRole
85
+ members: { userId: string; role: OrgRole; joinedAt: number; email: string; name: string | null; image: string | null }[]
86
+ invites?: { id: string; email: string; role: OrgRole; createdAt: number; expiresAt: number }[]
87
+ }>
88
+ rename(id: string, name: string): Promise<{ org: { id: string; name: string } }>
89
+ delete(id: string): Promise<{ ok: true }>
90
+ invite(id: string, email: string, role?: OrgRole): Promise<{ ok: true; invite: { id: string; email: string; role: OrgRole; expiresAt: number } }>
91
+ revokeInvite(id: string, inviteId: string): Promise<{ ok: true }>
92
+ setRole(id: string, userId: string, role: OrgRole): Promise<{ ok: true }>
93
+ removeMember(id: string, userId: string): Promise<{ ok: true }>
94
+ invites(): Promise<{ invites: { id: string; role: OrgRole; org: { id: string; name: string }; expiresAt: number }[] }>
95
+ accept(inviteId: string): Promise<{ ok: true; org: { id: string; name: string; role: OrgRole } }>
96
+ decline(inviteId: string): Promise<{ ok: true }>
97
+ }
98
+ lock: {
99
+ unlock(pin: string): Promise<{ ok: true }>
100
+ setup(pin: string, policy?: Policy): Promise<{ ok: true }>
101
+ skip(): Promise<{ ok: true }>
102
+ changePin(current: string, next: string): Promise<{ ok: true }>
103
+ setPolicy(policy: Policy, pin?: string): Promise<{ ok: true; policy: Policy }>
104
+ lockNow(): Promise<{ ok: true }>
105
+ }
106
+ }
107
+
108
+ export function createClient(options?: {
109
+ basePath?: string
110
+ fetch?: typeof fetch
111
+ /** For apps without cookies: where to keep the session token. */
112
+ token?: { get(): string | null | undefined; set(token: string | null): void | Promise<void> }
113
+ }): SlauthClient
package/src/client.js ADDED
@@ -0,0 +1,211 @@
1
+ /**
2
+ * The browser side: tiny helpers around the /auth routes, including the WebAuthn
3
+ * plumbing passkeys need.
4
+ *
5
+ * import { createClient } from 'slauthjs/client'
6
+ * const auth = createClient() // or createClient({ basePath: '/api/auth' })
7
+ * await auth.email.start('me@example.com')
8
+ * const { user } = await auth.email.verify('me@example.com', '123456')
9
+ */
10
+
11
+ /** Thrown for any answer that isn't a success; `code` says which (see the docs' error list). */
12
+ export class SlauthError extends Error {
13
+ constructor(message, code, status, details = {}) {
14
+ super(message)
15
+ this.code = code
16
+ this.status = status
17
+ Object.assign(this, details)
18
+ }
19
+ }
20
+
21
+ export function createClient({ basePath = '/auth', fetch: doFetch = (...args) => fetch(...args), token: bearer } = {}) {
22
+ const base = basePath.replace(/\/+$/, '')
23
+ // Apps that can't keep cookies (React Native, CLIs) pass `token: { get, set }` and get bearer tokens instead.
24
+ const extra = () => {
25
+ if (!bearer) return {}
26
+ const value = bearer.get()
27
+ return { 'X-Slauth-Transport': 'bearer', ...(value ? { Authorization: `Bearer ${value}` } : {}) }
28
+ }
29
+
30
+ async function call(path, body) {
31
+ const init =
32
+ body === undefined
33
+ ? { credentials: 'same-origin', headers: extra() }
34
+ : { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...extra() }, body: JSON.stringify(body) }
35
+ let res
36
+ try {
37
+ res = await doFetch(`${base}/${path}`, init)
38
+ } catch {
39
+ throw new SlauthError("Couldn't reach the server.", 'offline', 0)
40
+ }
41
+ const data = await res.json().catch(() => ({}))
42
+ if (!res.ok) {
43
+ const { error, code, ...rest } = data
44
+ throw new SlauthError(error ?? `HTTP ${res.status}`, code ?? 'server-error', res.status, rest)
45
+ }
46
+ if (bearer && typeof data.token === 'string' && path !== 'token') await bearer.set(data.token)
47
+ if (bearer && path === 'signout') await bearer.set(null)
48
+ return data
49
+ }
50
+
51
+ /** Remembers the pending two-factor sign-in for bearer apps. */
52
+ let pendingToken = null
53
+ const signIn = async (path, body) => {
54
+ const data = await call(path, body)
55
+ if (data.pendingToken) pendingToken = data.pendingToken
56
+ return data
57
+ }
58
+
59
+ let cachedToken = null
60
+
61
+ const passkey = {
62
+ /** True when this browser can use passkeys. */
63
+ supported: () => typeof window !== 'undefined' && typeof window.PublicKeyCredential === 'function',
64
+
65
+ /** Adds a passkey for the signed-in person. */
66
+ async register(name) {
67
+ const options = await call('passkey/register/options', {})
68
+ const credential = await navigator.credentials.create({
69
+ publicKey: {
70
+ ...options,
71
+ challenge: bytes(options.challenge),
72
+ user: { ...options.user, id: bytes(options.user.id) },
73
+ excludeCredentials: options.excludeCredentials.map((c) => ({ ...c, id: bytes(c.id) })),
74
+ },
75
+ })
76
+ return call('passkey/register/verify', { credential: serialize(credential), name })
77
+ },
78
+
79
+ /** Signs in with a passkey; the browser lets the person pick one. */
80
+ async signIn() {
81
+ const options = await call('passkey/login/options', {})
82
+ const credential = await navigator.credentials.get({
83
+ publicKey: { ...options, challenge: bytes(options.challenge), allowCredentials: [] },
84
+ })
85
+ return call('passkey/login/verify', { credential: serialize(credential) })
86
+ },
87
+
88
+ list: () => call('passkeys'),
89
+ rename: (id, name) => call('passkey/rename', { id, name }),
90
+ remove: (id) => call('passkey/delete', { id }),
91
+ }
92
+
93
+ const go = (url) => {
94
+ if (typeof window !== 'undefined') window.location.assign(url)
95
+ return url
96
+ }
97
+ const providerUrl = (id, params) => `${base}/oauth/${encodeURIComponent(id)}?${new URLSearchParams(params)}`
98
+
99
+ return {
100
+ /** `{ user }` (null when signed out), `twoFactor: 'pending'` mid sign-in, and `lock` when the app lock is on. */
101
+ session: () => call('session'),
102
+ /** What this app offers: providers, passwords, magic links, two-factor, organizations. */
103
+ config: () => call('config'),
104
+ email: {
105
+ start: (email, options = {}) => call('email/start', { email, ...options }),
106
+ verify: (email, code) => signIn('email/verify', { email, code }),
107
+ /** Finishes a magic link (the page the link opens does this for you). */
108
+ link: (token) => signIn('email/link', { token }),
109
+ change: {
110
+ start: (email) => call('email/change/start', { email }),
111
+ verify: (email, code) => call('email/change/verify', { email, code }),
112
+ },
113
+ },
114
+ password: {
115
+ signIn: (email, password) => signIn('password/signin', { email, password }),
116
+ /** Emails a code; once it's used (email.verify) the password is saved. */
117
+ signUp: (email, password, options = {}) => call('password/signup', { email, password, ...options }),
118
+ set: (password, current) => call('password/set', { password, current }),
119
+ remove: () => call('password/remove', {}),
120
+ },
121
+ /** "Sign in with …": goes to the provider and comes back to `redirect`. */
122
+ oauth: {
123
+ signIn: (provider, { redirect } = {}) => go(providerUrl(provider, redirect ? { redirect } : {})),
124
+ link: (provider, { redirect } = {}) => go(providerUrl(provider, { link: '1', ...(redirect ? { redirect } : {}) })),
125
+ url: (provider, { redirect } = {}) => providerUrl(provider, redirect ? { redirect } : {}),
126
+ },
127
+ twoFactor: {
128
+ /** The second step of signing in. Pass { recoveryCode } instead of a code if the phone is lost. */
129
+ verify: (code) => call('two-factor/verify', { ...(typeof code === 'string' ? { code } : code), ...(pendingToken ? { pendingToken } : {}) }),
130
+ setup: () => call('two-factor/setup', {}),
131
+ enable: (code) => call('two-factor/enable', { code }),
132
+ disable: (code) => call('two-factor/disable', typeof code === 'string' ? { code } : code),
133
+ recoveryCodes: (code) => call('two-factor/recovery-codes', { code }),
134
+ },
135
+ passkey,
136
+ signOut: async () => {
137
+ cachedToken = null
138
+ return call('signout', {})
139
+ },
140
+ user: {
141
+ update: (patch) => call('user', patch),
142
+ delete: () => call('user/delete', {}),
143
+ },
144
+ sessions: {
145
+ list: () => call('sessions'),
146
+ revoke: (id) => call('sessions/revoke', { id }),
147
+ revokeOthers: () => call('sessions/revoke-others', {}),
148
+ },
149
+ accounts: {
150
+ list: () => call('accounts'),
151
+ unlink: (provider) => call('accounts/unlink', { provider }),
152
+ },
153
+ /** A JWT for your other services, reused until a minute before it expires. */
154
+ async getToken() {
155
+ if (cachedToken && cachedToken.expiresAt - 60_000 > Date.now()) return cachedToken.token
156
+ cachedToken = await call('token')
157
+ return cachedToken.token
158
+ },
159
+ orgs: {
160
+ list: () => call('orgs'),
161
+ create: (name) => call('orgs', { name }),
162
+ get: (id) => call(`orgs/${encodeURIComponent(id)}`),
163
+ rename: (id, name) => call(`orgs/${encodeURIComponent(id)}`, { name }),
164
+ delete: (id) => call(`orgs/${encodeURIComponent(id)}/delete`, {}),
165
+ invite: (id, email, role) => call(`orgs/${encodeURIComponent(id)}/invite`, { email, role }),
166
+ revokeInvite: (id, inviteId) => call(`orgs/${encodeURIComponent(id)}/invites/revoke`, { id: inviteId }),
167
+ setRole: (id, userId, role) => call(`orgs/${encodeURIComponent(id)}/members/role`, { userId, role }),
168
+ removeMember: (id, userId) => call(`orgs/${encodeURIComponent(id)}/members/remove`, { userId }),
169
+ invites: () => call('invites'),
170
+ accept: (inviteId) => call('invites/accept', { id: inviteId }),
171
+ decline: (inviteId) => call('invites/decline', { id: inviteId }),
172
+ },
173
+ lock: {
174
+ unlock: (pin) => call('lock/login', { pin }),
175
+ setup: (pin, policy) => call('lock/setup', { pin, policy }),
176
+ skip: () => call('lock/setup', { policy: 'never' }),
177
+ changePin: (current, next) => call('lock/pin', { current, next }),
178
+ setPolicy: (policy, pin) => call('lock/policy', { policy, pin }),
179
+ lockNow: () => call('lock/logout', {}),
180
+ },
181
+ }
182
+ }
183
+
184
+ function bytes(base64url) {
185
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
186
+ const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4))
187
+ return Uint8Array.from(binary, (c) => c.charCodeAt(0))
188
+ }
189
+
190
+ function text(buffer) {
191
+ let binary = ''
192
+ for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte)
193
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
194
+ }
195
+
196
+ /** A PublicKeyCredential as JSON with base64url fields, the shape the server reads. */
197
+ function serialize(credential) {
198
+ if (typeof credential.toJSON === 'function') return credential.toJSON()
199
+ const r = credential.response
200
+ const response = { clientDataJSON: text(r.clientDataJSON) }
201
+ if (r.attestationObject) {
202
+ response.attestationObject = text(r.attestationObject)
203
+ response.transports = r.getTransports?.() ?? []
204
+ }
205
+ if (r.authenticatorData) {
206
+ response.authenticatorData = text(r.authenticatorData)
207
+ response.signature = text(r.signature)
208
+ response.userHandle = r.userHandle ? text(r.userHandle) : null
209
+ }
210
+ return { id: credential.id, rawId: text(credential.rawId), type: credential.type, response }
211
+ }