slauthjs 0.0.0-stage → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/LICENSE +21 -0
  3. package/README.md +92 -2
  4. package/package.json +114 -4
  5. package/src/access.js +143 -0
  6. package/src/account-element.d.ts +8 -0
  7. package/src/account-element.js +244 -0
  8. package/src/client.d.ts +113 -0
  9. package/src/client.js +211 -0
  10. package/src/core.js +442 -0
  11. package/src/element.d.ts +6 -0
  12. package/src/element.js +253 -0
  13. package/src/email/cloudflare.d.ts +1 -0
  14. package/src/email/cloudflare.js +19 -0
  15. package/src/email/console.d.ts +1 -0
  16. package/src/email/console.js +14 -0
  17. package/src/email/index.d.ts +4 -0
  18. package/src/email/message.js +56 -0
  19. package/src/email/resend.d.ts +1 -0
  20. package/src/email/resend.js +21 -0
  21. package/src/email-code.js +75 -0
  22. package/src/index.d.ts +364 -0
  23. package/src/index.js +9 -0
  24. package/src/lock.js +225 -0
  25. package/src/node.d.ts +3 -0
  26. package/src/node.js +67 -0
  27. package/src/oauth/flow.js +81 -0
  28. package/src/oauth/providers.js +171 -0
  29. package/src/orgs.js +184 -0
  30. package/src/passkey/cbor.js +99 -0
  31. package/src/passkey/webauthn.js +183 -0
  32. package/src/password.js +81 -0
  33. package/src/providers.d.ts +28 -0
  34. package/src/providers.js +1 -0
  35. package/src/rate-limit.js +41 -0
  36. package/src/routes/account.js +130 -0
  37. package/src/routes/email.js +147 -0
  38. package/src/routes/oauth.js +106 -0
  39. package/src/routes/orgs.js +144 -0
  40. package/src/routes/passkeys.js +136 -0
  41. package/src/routes/password.js +78 -0
  42. package/src/routes/two-factor.js +89 -0
  43. package/src/session.js +131 -0
  44. package/src/stores/d1.d.ts +1 -0
  45. package/src/stores/d1.js +58 -0
  46. package/src/stores/index.d.ts +5 -0
  47. package/src/stores/kv.d.ts +1 -0
  48. package/src/stores/kv.js +27 -0
  49. package/src/stores/memory.d.ts +1 -0
  50. package/src/stores/memory.js +35 -0
  51. package/src/stores/sqlite.d.ts +1 -0
  52. package/src/stores/sqlite.js +42 -0
  53. package/src/tokens.js +119 -0
  54. package/src/two-factor.js +142 -0
  55. package/src/ui.js +49 -0
  56. package/src/users.js +126 -0
  57. package/src/util.js +106 -0
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
- # Temporary Holding Version
1
+ # slauth 🦥
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+
@@ -0,0 +1,8 @@
1
+ /** Importing this registers <slauth-account>. */
2
+ export {}
3
+
4
+ declare global {
5
+ interface HTMLElementTagNameMap {
6
+ 'slauth-account': HTMLElement
7
+ }
8
+ }