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
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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 silicondaydream
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,93 @@
|
|
|
1
|
-
#
|
|
1
|
+
# slauth 🦥
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
7
|
+
|
|
8
|
+
Like Auth.js meets a free Clerk, with the simplicity turned all the way up.
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
import { slauth } from 'slauthjs'
|
|
12
|
+
import { d1 } from 'slauthjs/stores/d1'
|
|
13
|
+
import { resend } from 'slauthjs/email/resend'
|
|
14
|
+
|
|
15
|
+
const auth = slauth({
|
|
16
|
+
secret: env.SLAUTH_SECRET,
|
|
17
|
+
store: d1(env.DB),
|
|
18
|
+
email: resend({ apiKey: env.RESEND_API_KEY, from: 'My App <hi@myapp.dev>' }),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
if (url.pathname.startsWith('/auth/')) return auth.handle(request)
|
|
22
|
+
const user = await auth.user(request) // { id, email, name, image, roles, metadata, createdAt } or null
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```html
|
|
26
|
+
<script type="module">import 'slauthjs/element'</script>
|
|
27
|
+
<slauth-sign-in app-name="My App" redirect="/"></slauth-sign-in>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Out of the box
|
|
31
|
+
|
|
32
|
+
- **Email codes**: 6 digits, 10 minutes, 5 tries, stored as an HMAC.
|
|
33
|
+
- **Passkeys**: WebAuthn (ES256 + RS256) checked with Web Crypto, no libraries.
|
|
34
|
+
- **Your database**: D1, SQLite (bun:sqlite, node:sqlite, better-sqlite3), Workers KV, memory, or any `get`/`put`/`delete`.
|
|
35
|
+
- **Runs anywhere** with `Request`/`Response`: Cloudflare Workers, Bun, Deno, Node, Next.js, Astro, Hono.
|
|
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. |
|
|
67
|
+
|
|
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).
|
|
69
|
+
|
|
70
|
+
## Examples
|
|
71
|
+
|
|
72
|
+
- [`examples/workers-d1`](examples/workers-d1): Cloudflare Workers + D1, with the sign-in box
|
|
73
|
+
- [`examples/bun-sqlite`](examples/bun-sqlite): Bun + bun:sqlite
|
|
74
|
+
- [`examples/node-express`](examples/node-express): Express + node:sqlite
|
|
75
|
+
|
|
76
|
+
## Not for
|
|
77
|
+
|
|
78
|
+
SAML, SCIM, per-organization SSO, or a hosted dashboard you didn't build. For those, see
|
|
79
|
+
[the comparison](https://slauth.dev/compare).
|
|
80
|
+
|
|
81
|
+
## Repository
|
|
82
|
+
|
|
83
|
+
| | |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `src/` | the library (plain ESM + `.d.ts`) |
|
|
86
|
+
| `test/` | test helpers, a simulated passkey authenticator, type checks, a browser test |
|
|
87
|
+
| `examples/` | runnable apps |
|
|
88
|
+
|
|
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,6 +1,116 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slauthjs",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.js",
|
|
8
|
+
"types": "./src/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.d.ts",
|
|
12
|
+
"default": "./src/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./client": {
|
|
15
|
+
"types": "./src/client.d.ts",
|
|
16
|
+
"default": "./src/client.js"
|
|
17
|
+
},
|
|
18
|
+
"./providers": {
|
|
19
|
+
"types": "./src/providers.d.ts",
|
|
20
|
+
"default": "./src/providers.js"
|
|
21
|
+
},
|
|
22
|
+
"./element": {
|
|
23
|
+
"types": "./src/element.d.ts",
|
|
24
|
+
"default": "./src/element.js"
|
|
25
|
+
},
|
|
26
|
+
"./account": {
|
|
27
|
+
"types": "./src/account-element.d.ts",
|
|
28
|
+
"default": "./src/account-element.js"
|
|
29
|
+
},
|
|
30
|
+
"./node": {
|
|
31
|
+
"types": "./src/node.d.ts",
|
|
32
|
+
"default": "./src/node.js"
|
|
33
|
+
},
|
|
34
|
+
"./stores/memory": {
|
|
35
|
+
"types": "./src/stores/memory.d.ts",
|
|
36
|
+
"default": "./src/stores/memory.js"
|
|
37
|
+
},
|
|
38
|
+
"./stores/d1": {
|
|
39
|
+
"types": "./src/stores/d1.d.ts",
|
|
40
|
+
"default": "./src/stores/d1.js"
|
|
41
|
+
},
|
|
42
|
+
"./stores/kv": {
|
|
43
|
+
"types": "./src/stores/kv.d.ts",
|
|
44
|
+
"default": "./src/stores/kv.js"
|
|
45
|
+
},
|
|
46
|
+
"./stores/sqlite": {
|
|
47
|
+
"types": "./src/stores/sqlite.d.ts",
|
|
48
|
+
"default": "./src/stores/sqlite.js"
|
|
49
|
+
},
|
|
50
|
+
"./email/console": {
|
|
51
|
+
"types": "./src/email/console.d.ts",
|
|
52
|
+
"default": "./src/email/console.js"
|
|
53
|
+
},
|
|
54
|
+
"./email/resend": {
|
|
55
|
+
"types": "./src/email/resend.d.ts",
|
|
56
|
+
"default": "./src/email/resend.js"
|
|
57
|
+
},
|
|
58
|
+
"./email/cloudflare": {
|
|
59
|
+
"types": "./src/email/cloudflare.d.ts",
|
|
60
|
+
"default": "./src/email/cloudflare.js"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"files": [
|
|
64
|
+
"src",
|
|
65
|
+
"CHANGELOG.md",
|
|
66
|
+
"!src/**/*.test.js"
|
|
67
|
+
],
|
|
68
|
+
"sideEffects": [
|
|
69
|
+
"./src/element.js",
|
|
70
|
+
"./src/account-element.js"
|
|
71
|
+
],
|
|
72
|
+
"engines": {
|
|
73
|
+
"node": ">=20"
|
|
74
|
+
},
|
|
75
|
+
"scripts": {
|
|
76
|
+
"test": "vitest run",
|
|
77
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
78
|
+
"prepublishOnly": "vitest run && tsc -p tsconfig.json",
|
|
79
|
+
"smoke": "node test/smoke.mjs"
|
|
80
|
+
},
|
|
81
|
+
"devDependencies": {
|
|
82
|
+
"@types/node": "^22.20.5",
|
|
83
|
+
"typescript": "^5.9.3",
|
|
84
|
+
"vitest": "^5.0.3"
|
|
85
|
+
},
|
|
86
|
+
"keywords": [
|
|
87
|
+
"auth",
|
|
88
|
+
"authentication",
|
|
89
|
+
"oauth",
|
|
90
|
+
"oidc",
|
|
91
|
+
"sign-in-with-github",
|
|
92
|
+
"passkeys",
|
|
93
|
+
"webauthn",
|
|
94
|
+
"two-factor",
|
|
95
|
+
"totp",
|
|
96
|
+
"magic-link",
|
|
97
|
+
"sessions",
|
|
98
|
+
"organizations",
|
|
99
|
+
"jwt",
|
|
100
|
+
"nextauth-alternative",
|
|
101
|
+
"clerk-alternative",
|
|
102
|
+
"cloudflare-workers",
|
|
103
|
+
"d1",
|
|
104
|
+
"bun",
|
|
105
|
+
"deno"
|
|
106
|
+
],
|
|
107
|
+
"homepage": "https://slauth.dev",
|
|
108
|
+
"repository": {
|
|
109
|
+
"type": "git",
|
|
110
|
+
"url": "git+https://github.com/silicondaydream/slauth.git"
|
|
111
|
+
},
|
|
112
|
+
"packageManager": "pnpm@10.28.0",
|
|
113
|
+
"publishConfig": {
|
|
114
|
+
"access": "public"
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/access.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who is this? Cloudflare Access answers that before a request ever reaches the app:
|
|
3
|
+
* it signs people in (email code or Google) and passes each request through with a
|
|
4
|
+
* signed JWT in the Cf-Access-Jwt-Assertion header. This checks that JWT.
|
|
5
|
+
*
|
|
6
|
+
* Never trust the plain Cf-Access-Authenticated-User-Email header on its own: anyone who
|
|
7
|
+
* reaches the Worker without going through Access (a stray route, a workers.dev URL
|
|
8
|
+
* left open) can set it. The JWT can only be signed by your Access team.
|
|
9
|
+
*
|
|
10
|
+
* Web Crypto only, so it runs on Workers, Node 20+ and in tests alike.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { base64Url, readCookie } from './util.js'
|
|
14
|
+
|
|
15
|
+
const JWKS_TTL_MS = 60 * 60 * 1000
|
|
16
|
+
const CLOCK_SKEW_S = 60
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {{ email: string, userId: string }} User
|
|
20
|
+
* @typedef {{
|
|
21
|
+
* teamDomain: string,
|
|
22
|
+
* audience: string | string[],
|
|
23
|
+
* allowedEmails?: string[],
|
|
24
|
+
* aliases?: Record<string, string>,
|
|
25
|
+
* devUser?: string,
|
|
26
|
+
* fetch?: typeof fetch,
|
|
27
|
+
* }} AccessOptions
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {AccessOptions} options
|
|
32
|
+
* teamDomain "yourteam" or "yourteam.cloudflareaccess.com"
|
|
33
|
+
* audience the Application Audience (AUD) tag from the Access application
|
|
34
|
+
* allowedEmails optional second check after the Access policy
|
|
35
|
+
* aliases email -> userId, to keep someone's data when their email changes
|
|
36
|
+
* devUser local development only: treat every request as this email
|
|
37
|
+
*/
|
|
38
|
+
export function createAccess(options) {
|
|
39
|
+
const team = normalizeTeam(options.teamDomain)
|
|
40
|
+
const issuer = `https://${team}`
|
|
41
|
+
const audiences = [options.audience].flat().filter(Boolean)
|
|
42
|
+
const allowed = options.allowedEmails?.map((e) => e.trim().toLowerCase())
|
|
43
|
+
const doFetch = options.fetch ?? ((...args) => fetch(...args))
|
|
44
|
+
|
|
45
|
+
if (!options.devUser && audiences.length === 0) throw new Error('slauth: Access audience (AUD tag) is required.')
|
|
46
|
+
|
|
47
|
+
/** @type {{ keys: Map<string, CryptoKey>, at: number } | null} */
|
|
48
|
+
let jwks = null
|
|
49
|
+
|
|
50
|
+
async function keyFor(kid, force = false) {
|
|
51
|
+
if (force || !jwks || Date.now() - jwks.at > JWKS_TTL_MS) {
|
|
52
|
+
const res = await doFetch(`${issuer}/cdn-cgi/access/certs`)
|
|
53
|
+
if (!res.ok) throw new Error(`slauth: could not load Access keys (${res.status}).`)
|
|
54
|
+
const { keys = [] } = await res.json()
|
|
55
|
+
const map = new Map()
|
|
56
|
+
for (const jwk of keys) {
|
|
57
|
+
if (jwk.kty !== 'RSA' || !jwk.kid) continue
|
|
58
|
+
map.set(jwk.kid, await crypto.subtle.importKey('jwk', jwk, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify']))
|
|
59
|
+
}
|
|
60
|
+
jwks = { keys: map, at: Date.now() }
|
|
61
|
+
}
|
|
62
|
+
return jwks.keys.get(kid)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The signed-in person, or null when the request didn't come through Access, the
|
|
67
|
+
* token is bad or expired, or the email isn't on the list.
|
|
68
|
+
* @param {Request} request
|
|
69
|
+
* @returns {Promise<User | null>}
|
|
70
|
+
*/
|
|
71
|
+
async function getUser(request) {
|
|
72
|
+
if (options.devUser) return toUser(options.devUser)
|
|
73
|
+
|
|
74
|
+
const token = request.headers.get('cf-access-jwt-assertion') ?? readCookie(request.headers.get('cookie'), 'CF_Authorization')
|
|
75
|
+
if (!token) return null
|
|
76
|
+
const claims = await verify(token)
|
|
77
|
+
if (!claims || typeof claims.email !== 'string') return null
|
|
78
|
+
return toUser(claims.email)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function verify(token) {
|
|
82
|
+
const parts = token.split('.')
|
|
83
|
+
if (parts.length !== 3) return null
|
|
84
|
+
let header, claims
|
|
85
|
+
try {
|
|
86
|
+
header = JSON.parse(decodeText(parts[0]))
|
|
87
|
+
claims = JSON.parse(decodeText(parts[1]))
|
|
88
|
+
} catch {
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
if (header.alg !== 'RS256' || typeof header.kid !== 'string') return null
|
|
92
|
+
|
|
93
|
+
// Access rotates its keys; an unknown kid means it's time to look again.
|
|
94
|
+
const key = (await keyFor(header.kid)) ?? (await keyFor(header.kid, true))
|
|
95
|
+
if (!key) return null
|
|
96
|
+
|
|
97
|
+
const signed = new TextEncoder().encode(`${parts[0]}.${parts[1]}`)
|
|
98
|
+
const ok = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, decodeBytes(parts[2]), signed)
|
|
99
|
+
if (!ok) return null
|
|
100
|
+
|
|
101
|
+
const now = Math.floor(Date.now() / 1000)
|
|
102
|
+
if (claims.iss !== issuer) return null
|
|
103
|
+
if (![claims.aud].flat().some((a) => audiences.includes(a))) return null
|
|
104
|
+
if (typeof claims.exp !== 'number' || claims.exp + CLOCK_SKEW_S < now) return null
|
|
105
|
+
if (typeof claims.nbf === 'number' && claims.nbf - CLOCK_SKEW_S > now) return null
|
|
106
|
+
return claims
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function toUser(rawEmail) {
|
|
110
|
+
const email = rawEmail.trim().toLowerCase()
|
|
111
|
+
if (allowed && !allowed.includes(email)) return null
|
|
112
|
+
return { email, userId: options.aliases?.[email] ?? (await userIdFor(email)) }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { getUser }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A stable, opaque id for an email: the same in every app, so the suite agrees on who
|
|
120
|
+
* is who without sharing a database. It never contains the email itself.
|
|
121
|
+
* @param {string} email
|
|
122
|
+
*/
|
|
123
|
+
export async function userIdFor(email) {
|
|
124
|
+
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`slauth:${email.trim().toLowerCase()}`))
|
|
125
|
+
return `u_${base64Url(bytes).slice(0, 22)}`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalizeTeam(domain) {
|
|
129
|
+
const bare = String(domain ?? '').trim().replace(/^https?:\/\//, '').replace(/\/.*$/, '')
|
|
130
|
+
return bare.includes('.') ? bare : `${bare}.cloudflareaccess.com`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
function decodeBytes(segment) {
|
|
135
|
+
const base64 = segment.replace(/-/g, '+').replace(/_/g, '/')
|
|
136
|
+
const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4))
|
|
137
|
+
return Uint8Array.from(binary, (c) => c.charCodeAt(0))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function decodeText(segment) {
|
|
141
|
+
return new TextDecoder().decode(decodeBytes(segment))
|
|
142
|
+
}
|
|
143
|
+
|