slauthjs 1.0.0 → 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/README.md +46 -9
- package/package.json +31 -7
- package/src/account-element.d.ts +8 -0
- package/src/account-element.js +244 -0
- package/src/client.d.ts +86 -7
- package/src/client.js +99 -6
- package/src/core.js +313 -182
- package/src/element.js +161 -59
- package/src/email/console.js +5 -4
- package/src/email/message.js +50 -10
- package/src/email-code.js +32 -18
- package/src/index.d.ts +221 -6
- package/src/index.js +3 -0
- package/src/oauth/flow.js +81 -0
- package/src/oauth/providers.js +171 -0
- package/src/orgs.js +184 -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 +98 -16
- package/src/stores/d1.js +14 -0
- package/src/stores/index.d.ts +2 -2
- package/src/stores/kv.js +9 -0
- package/src/stores/memory.js +5 -0
- package/src/stores/sqlite.js +8 -1
- package/src/tokens.js +119 -0
- package/src/two-factor.js +142 -0
- package/src/ui.js +49 -0
- package/src/users.js +92 -22
- package/src/util.js +37 -0
package/src/element.js
CHANGED
|
@@ -1,49 +1,66 @@
|
|
|
1
1
|
import { createClient } from './client.js'
|
|
2
|
+
import { MESSAGES, STYLE, escape } from './ui.js'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* <slauth-sign-in>: a drop-in sign-in box.
|
|
5
|
-
* "
|
|
6
|
-
*
|
|
5
|
+
* <slauth-sign-in>: a drop-in sign-in box. It asks the server what's turned on and
|
|
6
|
+
* offers it: "Continue with GitHub/Google/…", email → 6-digit code, a password, a
|
|
7
|
+
* passkey, then the two-factor code for people who set one up, and an offer to add a
|
|
8
|
+
* passkey afterwards. Works in any framework (it's a plain custom element) and is themed
|
|
9
|
+
* with CSS variables:
|
|
7
10
|
*
|
|
8
11
|
* slauth-sign-in {
|
|
9
12
|
* --slauth-accent: #f59e0b; --slauth-ink: #2b2118; --slauth-bg: #fff;
|
|
10
13
|
* --slauth-radius: 14px; --slauth-font: inherit;
|
|
14
|
+
* --slauth-accent-ink: #2b2118; (text on the accent; for dark themes)
|
|
15
|
+
* --slauth-error: #c2410c;
|
|
11
16
|
* }
|
|
12
17
|
*
|
|
13
18
|
* Attributes: base-path (default /auth), app-name, redirect (where to go once signed in),
|
|
14
|
-
* no-passkeys (
|
|
19
|
+
* mode ("sign-up" to start on the password sign-up form), no-passkeys (no passkey
|
|
20
|
+
* button or offer).
|
|
15
21
|
* Fires `slauth:signed-in` with `{ detail: { user } }`.
|
|
16
22
|
*
|
|
17
23
|
* import 'slauthjs/element'
|
|
18
24
|
* <slauth-sign-in app-name="Planda" redirect="/"></slauth-sign-in>
|
|
19
25
|
*/
|
|
20
26
|
|
|
21
|
-
const STYLE = `
|
|
22
|
-
:host { display: block; font-family: var(--slauth-font, inherit); color: var(--slauth-ink, #2b2118); max-width: 360px; }
|
|
23
|
-
.card { background: var(--slauth-bg, #fff); border: 2px solid var(--slauth-ink, #2b2118); border-radius: var(--slauth-radius, 14px); padding: 22px; box-shadow: 4px 4px 0 var(--slauth-ink, #2b2118); }
|
|
24
|
-
h2 { margin: 0 0 4px; font-size: 20px; line-height: 1.2; }
|
|
25
|
-
p { margin: 0 0 14px; font-size: 14px; opacity: .8; }
|
|
26
|
-
label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
|
|
27
|
-
input { box-sizing: border-box; width: 100%; font: inherit; font-size: 16px; padding: 10px 12px; border: 2px solid var(--slauth-ink, #2b2118); border-radius: calc(var(--slauth-radius, 14px) - 6px); background: transparent; color: inherit; }
|
|
28
|
-
input.code { letter-spacing: .4em; text-align: center; font-size: 22px; font-weight: 700; }
|
|
29
|
-
input:focus { outline: 3px solid var(--slauth-accent, #f59e0b); outline-offset: 1px; }
|
|
30
|
-
button { font: inherit; font-weight: 700; font-size: 15px; cursor: pointer; border-radius: calc(var(--slauth-radius, 14px) - 6px); padding: 10px 14px; width: 100%; margin-top: 12px; }
|
|
31
|
-
.primary { background: var(--slauth-accent, #f59e0b); color: var(--slauth-ink, #2b2118); border: 2px solid var(--slauth-ink, #2b2118); }
|
|
32
|
-
.primary:active { transform: translate(1px, 1px); }
|
|
33
|
-
.ghost { background: transparent; color: inherit; border: 2px dashed currentColor; opacity: .85; }
|
|
34
|
-
.link { background: none; border: 0; padding: 4px; width: auto; font-weight: 600; text-decoration: underline; opacity: .75; }
|
|
35
|
-
button:disabled { opacity: .5; cursor: default; }
|
|
36
|
-
.error { color: #c2410c; font-size: 13px; font-weight: 600; min-height: 18px; margin: 8px 0 0; }
|
|
37
|
-
.row { display: flex; justify-content: space-between; margin-top: 8px; }
|
|
38
|
-
`
|
|
39
|
-
|
|
40
27
|
class SlauthSignIn extends HTMLElement {
|
|
41
28
|
connectedCallback() {
|
|
42
29
|
this.auth = createClient({ basePath: this.getAttribute('base-path') ?? '/auth' })
|
|
43
30
|
this.appName = this.getAttribute('app-name') ?? ''
|
|
44
|
-
this.
|
|
31
|
+
this.config = { providers: [], passwords: false, passkeys: true }
|
|
32
|
+
this.state = { step: 'email', mode: this.getAttribute('mode') === 'sign-up' ? 'sign-up' : 'code', email: '', error: '', busy: false, recovery: false }
|
|
45
33
|
this.attachShadow({ mode: 'open' })
|
|
34
|
+
this.readUrl()
|
|
46
35
|
this.render()
|
|
36
|
+
this.load()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Errors and the two-factor step can arrive in the URL, after a provider sends people back. */
|
|
40
|
+
readUrl() {
|
|
41
|
+
const params = new URLSearchParams(window.location.search)
|
|
42
|
+
const error = params.get('slauth_error')
|
|
43
|
+
if (error) this.state.error = MESSAGES[error] ?? 'Something went wrong. Please try again.'
|
|
44
|
+
if (params.get('slauth_step') === 'two-factor') this.state.step = 'two-factor'
|
|
45
|
+
this.afterRedirect = params.get('redirect')
|
|
46
|
+
this.invite = params.get('slauth_invite')
|
|
47
|
+
if (error || params.has('slauth_step')) {
|
|
48
|
+
for (const key of ['slauth_error', 'slauth_step']) params.delete(key)
|
|
49
|
+
const query = params.toString()
|
|
50
|
+
window.history.replaceState(window.history.state, '', window.location.pathname + (query ? `?${query}` : '') + window.location.hash)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async load() {
|
|
55
|
+
try {
|
|
56
|
+
const [config, session] = await Promise.all([this.auth.config(), this.auth.session()])
|
|
57
|
+
this.config = config
|
|
58
|
+
if (session.twoFactor === 'pending') this.state.step = 'two-factor'
|
|
59
|
+
if (this.state.mode === 'sign-up' && !config.passwords) this.state.mode = 'code'
|
|
60
|
+
this.render()
|
|
61
|
+
} catch {
|
|
62
|
+
// The box still works with email codes.
|
|
63
|
+
}
|
|
47
64
|
}
|
|
48
65
|
|
|
49
66
|
set(patch) {
|
|
@@ -56,96 +73,181 @@ class SlauthSignIn extends HTMLElement {
|
|
|
56
73
|
try {
|
|
57
74
|
await action()
|
|
58
75
|
} catch (error) {
|
|
59
|
-
this.set({ error: error.message ?? 'Something went wrong.' })
|
|
76
|
+
this.set({ error: MESSAGES[error.code] ?? error.message ?? 'Something went wrong.' })
|
|
60
77
|
} finally {
|
|
61
78
|
if (this.isConnected) this.set({ busy: false })
|
|
62
79
|
}
|
|
63
80
|
}
|
|
64
81
|
|
|
65
|
-
done
|
|
82
|
+
/** After the first step: the two-factor code, the passkey offer, or done. */
|
|
83
|
+
async signedIn(result, { offer = true } = {}) {
|
|
84
|
+
if (result.twoFactor) return this.set({ step: 'two-factor', recovery: false })
|
|
85
|
+
this.user = result.user
|
|
86
|
+
if (offer && this.passkeys()) return this.set({ step: 'offer' })
|
|
87
|
+
return this.done(result.user)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async done(user) {
|
|
91
|
+
if (this.invite && this.config.organizations) await this.auth.orgs.accept(this.invite).catch(() => {})
|
|
66
92
|
// Back to the start, ready for the next time it's shown (e.g. after signing out).
|
|
67
|
-
this.state = { step: 'email', email: '', error: '', busy: false }
|
|
93
|
+
this.state = { step: 'email', mode: 'code', email: '', error: '', busy: false, recovery: false }
|
|
68
94
|
this.render()
|
|
69
95
|
this.dispatchEvent(new CustomEvent('slauth:signed-in', { detail: { user }, bubbles: true, composed: true }))
|
|
70
|
-
const to = this.getAttribute('redirect')
|
|
96
|
+
const to = this.getAttribute('redirect') ?? this.afterRedirect
|
|
71
97
|
if (to) window.location.assign(to)
|
|
72
98
|
}
|
|
73
99
|
|
|
100
|
+
passkeys() {
|
|
101
|
+
return !this.hasAttribute('no-passkeys') && this.config.passkeys !== false && this.auth.passkey.supported()
|
|
102
|
+
}
|
|
103
|
+
|
|
74
104
|
render() {
|
|
75
|
-
const { step, email, error, busy } = this.state
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
105
|
+
const { step, mode, email, error, busy, recovery } = this.state
|
|
106
|
+
const { providers = [], passwords } = this.config
|
|
107
|
+
const app = this.appName || (this.config.appName !== 'your app' ? (this.config.appName ?? '') : '')
|
|
108
|
+
const name = app ? ` to ${escape(app)}` : ''
|
|
109
|
+
const off = busy ? 'disabled' : ''
|
|
110
|
+
const redirect = this.getAttribute('redirect') ?? window.location.pathname + window.location.search
|
|
111
|
+
const providerButtons = providers.length
|
|
112
|
+
? `<div class="providers">${providers.map((p) => `<a class="button" part="provider" href="${escape(this.auth.oauth.url(p.id, { redirect }))}">Continue with ${escape(p.name)}</a>`).join('')}</div><div class="or">or</div>`
|
|
113
|
+
: ''
|
|
114
|
+
|
|
115
|
+
const emailViews = {
|
|
116
|
+
code: `
|
|
80
117
|
<h2>Sign in${name}</h2>
|
|
118
|
+
${providerButtons}
|
|
81
119
|
<p>We'll email you a 6-digit code. No password needed.</p>
|
|
82
120
|
<form data-on="email">
|
|
83
121
|
<label for="email">Email</label>
|
|
84
122
|
<input id="email" type="email" autocomplete="email webauthn" required value="${escape(email)}" />
|
|
85
|
-
<button class="primary" ${
|
|
123
|
+
<button class="primary" ${off}>Email me a code</button>
|
|
124
|
+
</form>
|
|
125
|
+
${this.passkeys() ? `<button class="ghost dashed" data-on="passkey" ${off}>Sign in with a passkey</button>` : ''}
|
|
126
|
+
${passwords ? `<div class="row"><button class="link" data-on="mode-password">Use a password</button><button class="link" data-on="mode-sign-up">Create an account</button></div>` : ''}`,
|
|
127
|
+
password: `
|
|
128
|
+
<h2>Sign in${name}</h2>
|
|
129
|
+
${providerButtons}
|
|
130
|
+
<form data-on="password">
|
|
131
|
+
<label for="email">Email</label>
|
|
132
|
+
<input id="email" type="email" autocomplete="username webauthn" required value="${escape(email)}" />
|
|
133
|
+
<label for="password">Password</label>
|
|
134
|
+
<input id="password" type="password" autocomplete="current-password" required />
|
|
135
|
+
<button class="primary" ${off}>Sign in</button>
|
|
86
136
|
</form>
|
|
87
|
-
|
|
137
|
+
<div class="row"><button class="link" data-on="mode-code">Email me a code instead</button><button class="link" data-on="mode-code">Forgot password?</button></div>`,
|
|
138
|
+
'sign-up': `
|
|
139
|
+
<h2>Create an account${name}</h2>
|
|
140
|
+
${providerButtons}
|
|
141
|
+
<form data-on="sign-up">
|
|
142
|
+
<label for="email">Email</label>
|
|
143
|
+
<input id="email" type="email" autocomplete="email" required value="${escape(email)}" />
|
|
144
|
+
<label for="password">Password</label>
|
|
145
|
+
<input id="password" type="password" autocomplete="new-password" minlength="8" required />
|
|
146
|
+
<button class="primary" ${off}>Create account</button>
|
|
147
|
+
</form>
|
|
148
|
+
<div class="row"><button class="link" data-on="mode-password">I have an account</button></div>`,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const views = {
|
|
152
|
+
email: emailViews[mode] ?? emailViews.code,
|
|
88
153
|
code: `
|
|
89
154
|
<h2>Check your email</h2>
|
|
90
|
-
<p>We sent a code to <strong>${escape(email)}</strong
|
|
155
|
+
<p>We sent a code to <strong>${escape(email)}</strong>${this.config.magicLinks ? ', with a link you can tap instead' : ''}.</p>
|
|
91
156
|
<form data-on="code">
|
|
92
157
|
<label for="code">Code</label>
|
|
93
158
|
<input id="code" class="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" required />
|
|
94
|
-
<button class="primary" ${
|
|
159
|
+
<button class="primary" ${off}>${mode === 'sign-up' ? 'Create account' : 'Sign in'}</button>
|
|
95
160
|
</form>
|
|
96
161
|
<div class="row"><button class="link" data-on="back">Use another email</button><button class="link" data-on="resend">Send again</button></div>`,
|
|
162
|
+
'two-factor': recovery
|
|
163
|
+
? `
|
|
164
|
+
<h2>Use a recovery code</h2>
|
|
165
|
+
<p>One of the codes you saved when you turned on two-factor sign-in. Each works once.</p>
|
|
166
|
+
<form data-on="two-factor">
|
|
167
|
+
<label for="code">Recovery code</label>
|
|
168
|
+
<input id="code" autocomplete="off" autocapitalize="off" spellcheck="false" required />
|
|
169
|
+
<button class="primary" ${off}>Continue</button>
|
|
170
|
+
</form>
|
|
171
|
+
<div class="row"><button class="link" data-on="use-app">Use the app instead</button></div>`
|
|
172
|
+
: `
|
|
173
|
+
<h2>Two-factor sign-in</h2>
|
|
174
|
+
<p>Enter the 6-digit code from your authenticator app.</p>
|
|
175
|
+
<form data-on="two-factor">
|
|
176
|
+
<label for="code">Code</label>
|
|
177
|
+
<input id="code" class="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" required />
|
|
178
|
+
<button class="primary" ${off}>Continue</button>
|
|
179
|
+
</form>
|
|
180
|
+
<div class="row"><button class="link" data-on="use-recovery">Lost your phone?</button><button class="link" data-on="back">Start over</button></div>`,
|
|
97
181
|
offer: `
|
|
98
182
|
<h2>You're in 🦥</h2>
|
|
99
183
|
<p>Add a passkey and next time it's just Face ID, Touch ID or your PIN.</p>
|
|
100
|
-
<button class="primary" data-on="add" ${
|
|
184
|
+
<button class="primary" data-on="add" ${off}>Add a passkey</button>
|
|
101
185
|
<button class="link" data-on="skip">Not now</button>`,
|
|
102
186
|
}
|
|
103
187
|
this.shadowRoot.innerHTML = `<style>${STYLE}</style><div class="card" part="card">${views[step]}<p class="error" role="alert">${escape(error)}</p></div>`
|
|
104
188
|
|
|
105
189
|
const on = (key, handler) => {
|
|
106
|
-
const el
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
190
|
+
for (const el of this.shadowRoot.querySelectorAll(`[data-on="${key}"]`)) {
|
|
191
|
+
el.addEventListener(el.tagName === 'FORM' ? 'submit' : 'click', (event) => {
|
|
192
|
+
event.preventDefault()
|
|
193
|
+
handler()
|
|
194
|
+
})
|
|
195
|
+
}
|
|
111
196
|
}
|
|
197
|
+
const value = (id) => this.shadowRoot.getElementById(id)?.value ?? ''
|
|
112
198
|
// Read inputs before run(): it re-renders the form, which would empty them.
|
|
113
199
|
on('email', () => {
|
|
114
|
-
const
|
|
115
|
-
this.set({ email:
|
|
200
|
+
const typed = value('email').trim()
|
|
201
|
+
this.set({ email: typed })
|
|
116
202
|
return this.run(async () => {
|
|
117
|
-
await this.auth.email.start(
|
|
203
|
+
await this.auth.email.start(typed, { redirect: this.getAttribute('redirect') ?? undefined })
|
|
118
204
|
this.set({ step: 'code' })
|
|
119
205
|
this.shadowRoot.getElementById('code')?.focus()
|
|
120
206
|
})
|
|
121
207
|
})
|
|
122
|
-
on('
|
|
123
|
-
const
|
|
208
|
+
on('password', () => {
|
|
209
|
+
const typed = value('email').trim()
|
|
210
|
+
const password = value('password')
|
|
211
|
+
this.set({ email: typed })
|
|
212
|
+
return this.run(async () => this.signedIn(await this.auth.password.signIn(typed, password)))
|
|
213
|
+
})
|
|
214
|
+
on('sign-up', () => {
|
|
215
|
+
const typed = value('email').trim()
|
|
216
|
+
const password = value('password')
|
|
217
|
+
this.set({ email: typed })
|
|
124
218
|
return this.run(async () => {
|
|
125
|
-
|
|
126
|
-
this.
|
|
127
|
-
if (passkeys) this.set({ step: 'offer' })
|
|
128
|
-
else this.done(user)
|
|
219
|
+
await this.auth.password.signUp(typed, password)
|
|
220
|
+
this.set({ step: 'code' })
|
|
129
221
|
})
|
|
130
222
|
})
|
|
131
|
-
on('
|
|
223
|
+
on('code', () => {
|
|
224
|
+
const code = value('code')
|
|
225
|
+
return this.run(async () => this.signedIn(await this.auth.email.verify(email, code)))
|
|
226
|
+
})
|
|
227
|
+
on('two-factor', () => {
|
|
228
|
+
const code = value('code')
|
|
229
|
+
return this.run(async () => this.signedIn(await this.auth.twoFactor.verify(recovery ? { recoveryCode: code } : { code }), { offer: false }))
|
|
230
|
+
})
|
|
231
|
+
on('passkey', () => this.run(async () => this.signedIn(await this.auth.passkey.signIn(), { offer: false })))
|
|
232
|
+
on('mode-code', () => this.set({ mode: 'code', error: '' }))
|
|
233
|
+
on('mode-password', () => this.set({ mode: 'password', error: '' }))
|
|
234
|
+
on('mode-sign-up', () => this.set({ mode: 'sign-up', error: '' }))
|
|
235
|
+
on('use-recovery', () => this.set({ recovery: true, error: '' }))
|
|
236
|
+
on('use-app', () => this.set({ recovery: false, error: '' }))
|
|
132
237
|
on('back', () => this.set({ step: 'email', error: '' }))
|
|
133
238
|
on('resend', () => this.run(() => this.auth.email.start(email)))
|
|
134
239
|
on('add', () =>
|
|
135
240
|
this.run(async () => {
|
|
136
241
|
await this.auth.passkey.register()
|
|
137
|
-
this.done(this.user)
|
|
242
|
+
await this.done(this.user)
|
|
138
243
|
}),
|
|
139
244
|
)
|
|
140
245
|
on('skip', () => this.done(this.user))
|
|
141
|
-
this.shadowRoot.querySelector('input'
|
|
246
|
+
const first = this.shadowRoot.querySelector(step === 'email' && email ? '#password, input' : 'input')
|
|
247
|
+
first?.focus({ preventScroll: true })
|
|
142
248
|
}
|
|
143
249
|
}
|
|
144
250
|
|
|
145
|
-
function escape(text) {
|
|
146
|
-
return String(text).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
147
|
-
}
|
|
148
|
-
|
|
149
251
|
if (typeof customElements !== 'undefined' && !customElements.get('slauth-sign-in')) {
|
|
150
252
|
customElements.define('slauth-sign-in', SlauthSignIn)
|
|
151
253
|
}
|
package/src/email/console.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Prints sign-in codes to the console instead of emailing them. The default
|
|
3
|
-
* you build, so there's nothing to set up to try slauth.
|
|
2
|
+
* Prints sign-in codes (and links) to the console instead of emailing them. The default
|
|
3
|
+
* while you build, so there's nothing to set up to try slauth.
|
|
4
4
|
*
|
|
5
5
|
* @returns {import('../index.js').EmailSender}
|
|
6
6
|
*/
|
|
7
7
|
export function consoleEmail() {
|
|
8
8
|
return {
|
|
9
|
-
async send({ to, code, appName }) {
|
|
10
|
-
console.log(`\n 🦥 slauth: ${appName} sign-in code for ${to} is ${code}\n`)
|
|
9
|
+
async send({ to, code, link, appName, kind = 'sign-in', subject }) {
|
|
10
|
+
if (kind === 'sign-in') console.log(`\n 🦥 slauth: ${appName} sign-in code for ${to} is ${code}${link ? `\n or open ${link}` : ''}\n`)
|
|
11
|
+
else console.log(`\n 🦥 slauth: email to ${to}: ${subject}${code ? `\n code: ${code}` : ''}${link ? `\n link: ${link}` : ''}\n`)
|
|
11
12
|
},
|
|
12
13
|
}
|
|
13
14
|
}
|
package/src/email/message.js
CHANGED
|
@@ -1,16 +1,56 @@
|
|
|
1
|
-
|
|
1
|
+
import { escapeHtml } from '../util.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The emails slauth sends, unless you pass your own `emails` function in the config.
|
|
5
|
+
*
|
|
6
|
+
* sign-in a 6-digit code (and a link, with magicLinks on)
|
|
7
|
+
* email-change a code to confirm a new address
|
|
8
|
+
* invite an invitation to join an organization
|
|
9
|
+
*/
|
|
10
|
+
export function defaultMessage(kind, { code, link, appName, minutes, orgName, inviter }) {
|
|
11
|
+
if (kind === 'email-change') {
|
|
12
|
+
return box({
|
|
13
|
+
subject: `${code} is your code to confirm this email for ${appName}`,
|
|
14
|
+
lines: [`Enter this code in ${escapeHtml(appName)} to use this address from now on:`],
|
|
15
|
+
textLines: [`Enter this code in ${appName} to use this address from now on:`],
|
|
16
|
+
code,
|
|
17
|
+
footer: `It works for ${minutes} minutes. If you didn't ask for it, you can ignore this email.`,
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
if (kind === 'invite') {
|
|
21
|
+
return box({
|
|
22
|
+
subject: `${inviter ? `${inviter} invited you` : "You're invited"} to join ${orgName} on ${appName}`,
|
|
23
|
+
lines: [`${inviter ? `${escapeHtml(inviter)} invited you` : "You're invited"} to join <strong>${escapeHtml(orgName)}</strong> on ${escapeHtml(appName)}.`],
|
|
24
|
+
textLines: [`${inviter ? `${inviter} invited you` : "You're invited"} to join ${orgName} on ${appName}.`],
|
|
25
|
+
link,
|
|
26
|
+
linkLabel: 'Accept the invitation',
|
|
27
|
+
footer: `Sign in with this email address to accept. The invitation lasts 7 days.`,
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
return box({
|
|
31
|
+
subject: `${code} is your ${appName} sign-in code`,
|
|
32
|
+
lines: [`Your <strong>${escapeHtml(appName)}</strong> sign-in code:`],
|
|
33
|
+
textLines: [`Your ${appName} sign-in code is ${code}`],
|
|
34
|
+
code,
|
|
35
|
+
link,
|
|
36
|
+
linkLabel: `Sign in to ${appName}`,
|
|
37
|
+
footer: `It works for ${minutes} minutes. If you didn't ask for it, you can ignore this email.`,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The sign-in email, as slauth 1.0 exported it. */
|
|
2
42
|
export function codeMessage({ code, appName, minutes }) {
|
|
3
|
-
|
|
4
|
-
|
|
43
|
+
return defaultMessage('sign-in', { code, appName, minutes })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function box({ subject, lines, textLines = lines, code, link, linkLabel, footer }) {
|
|
47
|
+
const text = [...textLines, code ? `\n${code}` : '', link ? `\n${linkLabel}: ${link}` : '', `\n${footer}`].filter(Boolean).join('\n')
|
|
5
48
|
const html = `<!doctype html><html><body style="margin:0;padding:32px 16px;background:#fdf6ec;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#2b2118">
|
|
6
49
|
<div style="max-width:420px;margin:0 auto;background:#fff;border:2px solid #2b2118;border-radius:16px;padding:28px">
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
50
|
+
${lines.map((l) => `<p style="margin:0 0 8px;font-size:15px">${l}</p>`).join('\n')}
|
|
51
|
+
${code ? `<p style="margin:0 0 16px;font-size:34px;font-weight:800;letter-spacing:6px">${escapeHtml(code)}</p>` : ''}
|
|
52
|
+
${link ? `<p style="margin:8px 0 16px"><a href="${escapeHtml(link)}" style="display:inline-block;background:#f59e0b;color:#2b2118;border:2px solid #2b2118;border-radius:10px;padding:10px 16px;font-weight:700;text-decoration:none">${escapeHtml(linkLabel)}</a></p>` : ''}
|
|
53
|
+
<p style="margin:0;font-size:13px;color:#6b5d50">${escapeHtml(footer)}</p>
|
|
10
54
|
</div></body></html>`
|
|
11
55
|
return { subject, text, html }
|
|
12
56
|
}
|
|
13
|
-
|
|
14
|
-
function escapeHtml(text) {
|
|
15
|
-
return String(text).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
16
|
-
}
|
package/src/email-code.js
CHANGED
|
@@ -1,53 +1,67 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { defaultMessage } from './email/message.js'
|
|
2
2
|
import { hmac, safeEqual } from './util.js'
|
|
3
3
|
|
|
4
|
-
const CODE_MINUTES = 10
|
|
4
|
+
export const CODE_MINUTES = 10
|
|
5
5
|
const MAX_TRIES = 5
|
|
6
6
|
const RESEND_SECONDS = 60
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
9
|
+
* 6-digit codes sent by email: for signing in, and for confirming a new address.
|
|
10
10
|
*
|
|
11
|
-
* code:<email>
|
|
11
|
+
* code:<email> { hash, expiresAt, tries, sentAt, ... } (lives 10 minutes)
|
|
12
|
+
* code-change:<userId> the same, for a new email address
|
|
12
13
|
*
|
|
13
14
|
* Only an HMAC of the code is stored. Each code allows 5 tries, and a new code can be
|
|
14
15
|
* asked for once a minute per address.
|
|
15
16
|
*
|
|
16
|
-
* @param {{ store: import('./index.js').Store, secret: string, sender: import('./index.js').EmailSender, appName: string }} options
|
|
17
|
+
* @param {{ store: import('./index.js').Store, secret: string, sender: import('./index.js').EmailSender, appName: string, message?: import('./index.js').SlauthConfig['emails'] }} options
|
|
17
18
|
*/
|
|
18
|
-
export function createEmailCodes({ store, secret, sender, appName }) {
|
|
19
|
+
export function createEmailCodes({ store, secret, sender, appName, message }) {
|
|
19
20
|
const hashOf = (email, code) => hmac(secret, `code:${email}:${code}`)
|
|
20
21
|
|
|
21
22
|
return {
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} email
|
|
25
|
+
* @param {{ key?: string, kind?: 'sign-in' | 'email-change', link?: string, data?: object }} [options]
|
|
26
|
+
* @returns {Promise<{ ok: true } | { ok: false, code: 'slow-down', retryAfter: number }>}
|
|
27
|
+
*/
|
|
28
|
+
async start(email, { key = `code:${email}`, kind = 'sign-in', link, data = {} } = {}) {
|
|
29
|
+
const existing = await store.get(key)
|
|
25
30
|
const wait = existing ? Math.ceil((existing.sentAt + RESEND_SECONDS * 1000 - Date.now()) / 1000) : 0
|
|
26
31
|
if (wait > 0) return { ok: false, code: 'slow-down', retryAfter: wait }
|
|
27
32
|
|
|
28
33
|
const code = randomCode()
|
|
29
34
|
const now = Date.now()
|
|
30
|
-
await store.put(
|
|
31
|
-
await
|
|
35
|
+
await store.put(key, { ...data, email, hash: await hashOf(email, code), expiresAt: now + CODE_MINUTES * 60_000, tries: 0, sentAt: now }, { ttl: CODE_MINUTES * 60 })
|
|
36
|
+
await send({ kind, to: email, code, link, minutes: CODE_MINUTES })
|
|
32
37
|
return { ok: true }
|
|
33
38
|
},
|
|
34
39
|
|
|
35
|
-
/** @returns {Promise<{ ok: true } | { ok: false, code: 'expired' | 'too-many-tries' | 'bad-code', triesLeft?: number }>} */
|
|
36
|
-
async verify(email, code) {
|
|
37
|
-
const entry = await store.get(
|
|
38
|
-
if (!entry || entry.expiresAt <= Date.now()) return { ok: false, code: 'expired' }
|
|
40
|
+
/** @returns {Promise<{ ok: true, entry: object } | { ok: false, code: 'expired' | 'too-many-tries' | 'bad-code', triesLeft?: number }>} */
|
|
41
|
+
async verify(email, code, { key = `code:${email}` } = {}) {
|
|
42
|
+
const entry = await store.get(key)
|
|
43
|
+
if (!entry || entry.expiresAt <= Date.now() || (entry.email && entry.email !== email)) return { ok: false, code: 'expired' }
|
|
39
44
|
if (entry.tries >= MAX_TRIES) return { ok: false, code: 'too-many-tries' }
|
|
40
45
|
|
|
41
46
|
const guess = String(code ?? '').replace(/\D/g, '')
|
|
42
47
|
if (guess.length === 6 && safeEqual(await hashOf(email, guess), entry.hash)) {
|
|
43
|
-
await store.delete(
|
|
44
|
-
return { ok: true }
|
|
48
|
+
await store.delete(key)
|
|
49
|
+
return { ok: true, entry }
|
|
45
50
|
}
|
|
46
51
|
const tries = entry.tries + 1
|
|
47
52
|
const ttl = Math.max(1, Math.ceil((entry.expiresAt - Date.now()) / 1000))
|
|
48
|
-
await store.put(
|
|
53
|
+
await store.put(key, { ...entry, tries }, { ttl })
|
|
49
54
|
return tries >= MAX_TRIES ? { ok: false, code: 'too-many-tries' } : { ok: false, code: 'bad-code', triesLeft: MAX_TRIES - tries }
|
|
50
55
|
},
|
|
56
|
+
|
|
57
|
+
send,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Builds an email (yours, if you passed `emails`) and hands it to the sender. */
|
|
61
|
+
async function send({ kind, to, code, link, ...details }) {
|
|
62
|
+
const data = { code, link, appName, ...details }
|
|
63
|
+
const built = (message && (await message(kind, data))) || defaultMessage(kind, data)
|
|
64
|
+
await sender.send({ to, code, link, appName, kind, ...built })
|
|
51
65
|
}
|
|
52
66
|
}
|
|
53
67
|
|