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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 2.0.0
|
|
4
|
+
|
|
5
|
+
slauth keeps its five-minute start and now goes as deep as a project needs. Everything
|
|
6
|
+
new is opt-in and still dependency-free.
|
|
7
|
+
|
|
8
|
+
### New
|
|
9
|
+
|
|
10
|
+
- **Sign in with providers** (`providers`): `github()`, `google()`, `discord()`, `gitlab()`,
|
|
11
|
+
`microsoft()`, any OpenID Connect issuer with `oidc()`, or your own with `oauth2()`, from
|
|
12
|
+
`slauthjs/providers`. PKCE, browser-bound state, account linking and unlinking, and
|
|
13
|
+
sign-in by email only when the provider says it's verified.
|
|
14
|
+
- **Magic links** (`magicLinks`): a one-tap link in the code email, behind a click so mail
|
|
15
|
+
scanners don't use it up.
|
|
16
|
+
- **Passwords** (`passwords`): PBKDF2-SHA256, email-verified sign-up, per-email lockout.
|
|
17
|
+
- **Two-factor** (`twoFactor`): TOTP with one-time recovery codes.
|
|
18
|
+
- **Tokens** (`tokens`): ES256 JWTs at `GET /auth/token`, public keys at `/auth/jwks.json`,
|
|
19
|
+
`auth.createToken` / `auth.verifyToken`; `auth.user()` accepts them as bearer tokens.
|
|
20
|
+
- **Organizations** (`organizations`): owners, admins and custom roles, email invitations,
|
|
21
|
+
`auth.orgs.require()`.
|
|
22
|
+
- **Users**: `name`, `image`, `roles`, `metadata` (public) and `privateMetadata`;
|
|
23
|
+
`requireUser(request, { role })`; email change with a code; self-service deletion.
|
|
24
|
+
- **Admin API**: `auth.users.get/byEmail/list/create/update/delete/ban/unban`,
|
|
25
|
+
`auth.sessions.list/revoke/revokeAll/create` (with `impersonatorId`), `auth.accounts.list`,
|
|
26
|
+
`auth.session(request)`.
|
|
27
|
+
- **Sessions**: list your devices, sign one or all others out. Bearer session tokens for
|
|
28
|
+
apps without cookies (`X-Slauth-Transport: bearer`, `createClient({ token })`).
|
|
29
|
+
- **Events** (`on`): `signUp`, `signIn`, `signOut`, `userUpdated`, `userDeleted`.
|
|
30
|
+
- **Custom emails** (`emails`), and senders now get `kind` and `link`.
|
|
31
|
+
- **Per-IP rate limits** (`rateLimit`) on code, password and two-factor routes.
|
|
32
|
+
- `allow(email, context)` now gets the sign-in method, provider and profile.
|
|
33
|
+
- `GET /auth/config` tells sign-in screens what's turned on.
|
|
34
|
+
- `<slauth-sign-in>` offers providers, passwords, sign-up (`mode="sign-up"`) and the
|
|
35
|
+
two-factor step, and shows provider errors.
|
|
36
|
+
- `<slauth-account>` (`slauthjs/account`): profile, email, passkeys, password, providers,
|
|
37
|
+
two-factor, devices, invitations, sign out and delete.
|
|
38
|
+
- `Store.list()` (optional) on every built-in store, for `auth.users.list()`.
|
|
39
|
+
- `baseUrl`, `signInPage`, `freshMinutes`, `allowDeletion` options.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- **Adding a passkey now needs a recent sign-in** (15 minutes by default, `freshMinutes`),
|
|
44
|
+
like setting a password, changing email, turning on two-factor, linking a provider and
|
|
45
|
+
deleting the account. The sign-in box's passkey offer comes straight after sign-in, so
|
|
46
|
+
it's unaffected; a settings-page "Add passkey" button may now get `reauth-required`.
|
|
47
|
+
Set `freshMinutes: false` for the 1.x behaviour.
|
|
48
|
+
- Users carry more fields. Users stored by 1.x are read with defaults; nothing to migrate.
|
|
49
|
+
- `<slauth-sign-in>` without a bundler needs `ui.js` next to `element.js` and `client.js`.
|
|
50
|
+
|
|
51
|
+
### Upgrading from 1.x
|
|
52
|
+
|
|
53
|
+
No data migration: users and sessions saved by 1.x keep working (old sessions join the
|
|
54
|
+
new per-user index the first time they're used). Check these:
|
|
55
|
+
|
|
56
|
+
1. **A settings-page "Add passkey" button** may now answer `reauth-required` for people
|
|
57
|
+
who signed in over 15 minutes ago. Ask them to sign in again, or set `freshMinutes`.
|
|
58
|
+
2. **Serving the element without a bundler?** Copy `ui.js` next to `element.js` and
|
|
59
|
+
`client.js`, or the element won't load.
|
|
60
|
+
3. **Per-IP rate limits are on** where the host sets `CF-Connecting-IP`, `Fly-Client-IP`
|
|
61
|
+
or `X-Real-IP`: 10 code requests per IP per 10 minutes. Many people behind one office
|
|
62
|
+
IP could hit it; raise it with `rateLimit.limits` or set `rateLimit: false`.
|
|
63
|
+
4. **A few more store writes**: each sign-in also updates the person's session list, and
|
|
64
|
+
rate limiting keeps counters. Fine for D1 and SQLite; on Workers KV's free tier
|
|
65
|
+
(1,000 writes a day), consider `rateLimit: false`.
|
|
66
|
+
5. `auth.user()` returns more fields (`name`, `image`, `roles`, `metadata`). Code that
|
|
67
|
+
compared the whole object, or stored it, sees the new keys.
|
|
68
|
+
|
|
69
|
+
## 1.0.0
|
|
70
|
+
|
|
71
|
+
Email codes, passkeys, sessions, stores (D1, SQLite, KV, memory), Resend and Cloudflare
|
|
72
|
+
Email senders, the client, `<slauth-sign-in>`, Cloudflare Access support and the PIN app lock.
|
package/README.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# slauth 🦥
|
|
2
2
|
|
|
3
|
-
**
|
|
4
|
-
on whatever you deploy to.
|
|
3
|
+
**Open-source auth you own.** Email codes and passkeys in five minutes, in your own database,
|
|
4
|
+
on whatever you deploy to. Then, when the side project grows up: "Sign in with GitHub",
|
|
5
|
+
passwords, two-factor, organizations, an admin API and JWTs, one config key at a time.
|
|
6
|
+
Zero dependencies.
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
Like Auth.js meets a free Clerk, with the simplicity turned all the way up.
|
|
7
9
|
|
|
8
10
|
```js
|
|
9
11
|
import { slauth } from 'slauthjs'
|
|
@@ -17,7 +19,7 @@ const auth = slauth({
|
|
|
17
19
|
})
|
|
18
20
|
|
|
19
21
|
if (url.pathname.startsWith('/auth/')) return auth.handle(request)
|
|
20
|
-
const user = await auth.user(request) // { id, email, createdAt } or null
|
|
22
|
+
const user = await auth.user(request) // { id, email, name, image, roles, metadata, createdAt } or null
|
|
21
23
|
```
|
|
22
24
|
|
|
23
25
|
```html
|
|
@@ -25,13 +27,45 @@ const user = await auth.user(request) // { id, email, createdAt } or null
|
|
|
25
27
|
<slauth-sign-in app-name="My App" redirect="/"></slauth-sign-in>
|
|
26
28
|
```
|
|
27
29
|
|
|
30
|
+
## Out of the box
|
|
31
|
+
|
|
28
32
|
- **Email codes**: 6 digits, 10 minutes, 5 tries, stored as an HMAC.
|
|
29
33
|
- **Passkeys**: WebAuthn (ES256 + RS256) checked with Web Crypto, no libraries.
|
|
30
34
|
- **Your database**: D1, SQLite (bun:sqlite, node:sqlite, better-sqlite3), Workers KV, memory, or any `get`/`put`/`delete`.
|
|
31
35
|
- **Runs anywhere** with `Request`/`Response`: Cloudflare Workers, Bun, Deno, Node, Next.js, Astro, Hono.
|
|
32
|
-
- **
|
|
36
|
+
- **Sessions** that people can see and revoke, per-IP rate limits, CSRF protection.
|
|
37
|
+
|
|
38
|
+
## When you need more
|
|
39
|
+
|
|
40
|
+
Every one of these is off until you turn it on:
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
import { github, google, oidc } from 'slauthjs/providers'
|
|
44
|
+
|
|
45
|
+
slauth({
|
|
46
|
+
...,
|
|
47
|
+
providers: [github({ clientId, clientSecret }), google({ clientId, clientSecret }), oidc({ id: 'okta', name: 'Okta', issuer, clientId })],
|
|
48
|
+
magicLinks: true, // a one-tap link in the code email
|
|
49
|
+
passwords: true, // optional, and only after proving the email
|
|
50
|
+
twoFactor: true, // authenticator apps + recovery codes
|
|
51
|
+
organizations: true, // teams, roles, email invitations
|
|
52
|
+
tokens: true, // ES256 JWTs + /auth/jwks.json for your other services
|
|
53
|
+
on: { signUp: ({ user }) => createProfile(user.id) },
|
|
54
|
+
})
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
| | |
|
|
58
|
+
|---|---|
|
|
59
|
+
| **Providers** | GitHub, Google, Discord, GitLab, Microsoft, any OpenID Connect issuer, or a custom `oauth2()`. PKCE, account linking, verified emails only. |
|
|
60
|
+
| **Passwords** | PBKDF2 via Web Crypto. "Forgot password" is just an email code. |
|
|
61
|
+
| **Two-factor** | TOTP (RFC 6238) with one-time recovery codes. Passkeys skip it. |
|
|
62
|
+
| **Users** | Names, pictures, roles, public and private metadata, email change, self-service deletion. `requireUser(request, { role: 'admin' })`. |
|
|
63
|
+
| **Admin API** | `auth.users.list/create/update/delete/ban`, `auth.sessions.revokeAll`, impersonation. |
|
|
64
|
+
| **Organizations** | Owners, admins and your own roles; invitations by email; `auth.orgs.require(request, orgId)`. |
|
|
65
|
+
| **Apps without cookies** | Bearer session tokens for mobile apps and CLIs. |
|
|
66
|
+
| **UI** | `<slauth-sign-in>` offers whatever's on; `<slauth-account>` is a whole account page in one tag. |
|
|
33
67
|
|
|
34
|
-
📖 **Docs: [slauth.dev/docs](https://slauth.dev/docs/)**: [quickstart](https://slauth.dev/docs/quickstart/), [frameworks](https://slauth.dev/docs/frameworks/cloudflare-workers/), [security](https://slauth.dev/docs/concepts/security/).
|
|
68
|
+
📖 **Docs: [slauth.dev/docs](https://slauth.dev/docs/)**: [quickstart](https://slauth.dev/docs/quickstart/), [providers](https://slauth.dev/docs/sign-in/providers/), [frameworks](https://slauth.dev/docs/frameworks/cloudflare-workers/), [security](https://slauth.dev/docs/concepts/security/). What changed: [CHANGELOG.md](CHANGELOG.md).
|
|
35
69
|
|
|
36
70
|
## Examples
|
|
37
71
|
|
|
@@ -41,7 +75,7 @@ const user = await auth.user(request) // { id, email, createdAt } or null
|
|
|
41
75
|
|
|
42
76
|
## Not for
|
|
43
77
|
|
|
44
|
-
|
|
78
|
+
SAML, SCIM, per-organization SSO, or a hosted dashboard you didn't build. For those, see
|
|
45
79
|
[the comparison](https://slauth.dev/compare).
|
|
46
80
|
|
|
47
81
|
## Repository
|
|
@@ -51,6 +85,9 @@ Enterprise SSO/SAML, orgs and roles, or dozens of OAuth providers. For those, se
|
|
|
51
85
|
| `src/` | the library (plain ESM + `.d.ts`) |
|
|
52
86
|
| `test/` | test helpers, a simulated passkey authenticator, type checks, a browser test |
|
|
53
87
|
| `examples/` | runnable apps |
|
|
54
|
-
| `site/` | slauth.dev: Astro marketing pages + Starlight docs |
|
|
55
88
|
|
|
56
|
-
|
|
89
|
+
The site and docs (slauth.dev) live in a separate repository. Spotted a docs mistake?
|
|
90
|
+
[Open an issue](https://github.com/silicondaydream/slauth/issues) here.
|
|
91
|
+
|
|
92
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) and [CODE_POLICY.md](CODE_POLICY.md). Security
|
|
93
|
+
reports: [SECURITY.md](SECURITY.md). MIT licensed.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slauthjs",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Open-source auth you own: email codes, passkeys, \"Sign in with GitHub/Google\", passwords, two-factor, sessions, organizations and JWTs. Your database, zero dependencies. Runs on Workers, Bun, Deno and Node.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.js",
|
|
@@ -15,10 +15,18 @@
|
|
|
15
15
|
"types": "./src/client.d.ts",
|
|
16
16
|
"default": "./src/client.js"
|
|
17
17
|
},
|
|
18
|
+
"./providers": {
|
|
19
|
+
"types": "./src/providers.d.ts",
|
|
20
|
+
"default": "./src/providers.js"
|
|
21
|
+
},
|
|
18
22
|
"./element": {
|
|
19
23
|
"types": "./src/element.d.ts",
|
|
20
24
|
"default": "./src/element.js"
|
|
21
25
|
},
|
|
26
|
+
"./account": {
|
|
27
|
+
"types": "./src/account-element.d.ts",
|
|
28
|
+
"default": "./src/account-element.js"
|
|
29
|
+
},
|
|
22
30
|
"./node": {
|
|
23
31
|
"types": "./src/node.d.ts",
|
|
24
32
|
"default": "./src/node.js"
|
|
@@ -54,10 +62,12 @@
|
|
|
54
62
|
},
|
|
55
63
|
"files": [
|
|
56
64
|
"src",
|
|
65
|
+
"CHANGELOG.md",
|
|
57
66
|
"!src/**/*.test.js"
|
|
58
67
|
],
|
|
59
68
|
"sideEffects": [
|
|
60
|
-
"./src/element.js"
|
|
69
|
+
"./src/element.js",
|
|
70
|
+
"./src/account-element.js"
|
|
61
71
|
],
|
|
62
72
|
"engines": {
|
|
63
73
|
"node": ">=20"
|
|
@@ -65,7 +75,8 @@
|
|
|
65
75
|
"scripts": {
|
|
66
76
|
"test": "vitest run",
|
|
67
77
|
"typecheck": "tsc -p tsconfig.json",
|
|
68
|
-
"prepublishOnly": "vitest run && tsc -p tsconfig.json"
|
|
78
|
+
"prepublishOnly": "vitest run && tsc -p tsconfig.json",
|
|
79
|
+
"smoke": "node test/smoke.mjs"
|
|
69
80
|
},
|
|
70
81
|
"devDependencies": {
|
|
71
82
|
"@types/node": "^22.20.5",
|
|
@@ -75,18 +86,31 @@
|
|
|
75
86
|
"keywords": [
|
|
76
87
|
"auth",
|
|
77
88
|
"authentication",
|
|
89
|
+
"oauth",
|
|
90
|
+
"oidc",
|
|
91
|
+
"sign-in-with-github",
|
|
78
92
|
"passkeys",
|
|
79
93
|
"webauthn",
|
|
80
|
-
"
|
|
94
|
+
"two-factor",
|
|
95
|
+
"totp",
|
|
96
|
+
"magic-link",
|
|
97
|
+
"sessions",
|
|
98
|
+
"organizations",
|
|
99
|
+
"jwt",
|
|
100
|
+
"nextauth-alternative",
|
|
101
|
+
"clerk-alternative",
|
|
81
102
|
"cloudflare-workers",
|
|
82
103
|
"d1",
|
|
83
104
|
"bun",
|
|
84
|
-
"deno"
|
|
85
|
-
"side-project"
|
|
105
|
+
"deno"
|
|
86
106
|
],
|
|
87
107
|
"homepage": "https://slauth.dev",
|
|
88
108
|
"repository": {
|
|
89
109
|
"type": "git",
|
|
90
110
|
"url": "git+https://github.com/silicondaydream/slauth.git"
|
|
111
|
+
},
|
|
112
|
+
"packageManager": "pnpm@10.28.0",
|
|
113
|
+
"publishConfig": {
|
|
114
|
+
"access": "public"
|
|
91
115
|
}
|
|
92
116
|
}
|
|
@@ -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
CHANGED
|
@@ -1,26 +1,100 @@
|
|
|
1
|
-
import type { Policy, SlauthUser } from './index.js'
|
|
1
|
+
import type { OrgRole, Policy, SlauthUser } from './index.js'
|
|
2
2
|
|
|
3
3
|
export class SlauthError extends Error {
|
|
4
|
-
/** e.g. "bad-code", "expired", "not-allowed", "slow-down", "signed-out", "offline". */
|
|
4
|
+
/** e.g. "bad-code", "expired", "not-allowed", "slow-down", "signed-out", "reauth-required", "offline". */
|
|
5
5
|
code: string
|
|
6
6
|
status: number
|
|
7
7
|
[detail: string]: unknown
|
|
8
8
|
}
|
|
9
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
|
+
|
|
10
24
|
export interface SlauthClient {
|
|
11
|
-
session(): Promise<{ user: SlauthUser | null; lock?: { policy: Policy; required: boolean; pinSet: boolean; unlocked: boolean } }>
|
|
25
|
+
session(): Promise<{ user: SlauthUser | null; twoFactor?: 'pending'; impersonatorId?: string; lock?: { policy: Policy; required: boolean; pinSet: boolean; unlocked: boolean } }>
|
|
26
|
+
config(): Promise<ClientConfig>
|
|
12
27
|
email: {
|
|
13
|
-
start(email: string): Promise<{ ok: true }>
|
|
14
|
-
verify(email: string, code: string): Promise<
|
|
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[] }>
|
|
15
54
|
}
|
|
16
55
|
passkey: {
|
|
17
56
|
supported(): boolean
|
|
18
57
|
register(name?: string): Promise<{ ok: true; passkey: { id: string; name: string } }>
|
|
19
|
-
signIn(): Promise<
|
|
58
|
+
signIn(): Promise<SignInResult>
|
|
20
59
|
list(): Promise<{ passkeys: { id: string; name: string; createdAt: number; lastUsedAt: number | null }[] }>
|
|
60
|
+
rename(id: string, name: string): Promise<{ ok: true }>
|
|
21
61
|
remove(id: string): Promise<{ ok: true }>
|
|
22
62
|
}
|
|
23
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
|
+
}
|
|
24
98
|
lock: {
|
|
25
99
|
unlock(pin: string): Promise<{ ok: true }>
|
|
26
100
|
setup(pin: string, policy?: Policy): Promise<{ ok: true }>
|
|
@@ -31,4 +105,9 @@ export interface SlauthClient {
|
|
|
31
105
|
}
|
|
32
106
|
}
|
|
33
107
|
|
|
34
|
-
export function createClient(options?: {
|
|
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
|