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,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
|
+
}
|
package/src/client.d.ts
ADDED
|
@@ -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
|
+
}
|