slauthjs 0.0.0-stage → 1.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/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,56 @@
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
+ **Auth for side projects.** Email codes and passkeys in five minutes, in your own database,
4
+ on whatever you deploy to. Zero dependencies, about 29 kB.
5
+
6
+ Sturdier than a password in an env var, and a lot less fuss than a hosted auth company.
7
+
8
+ ```js
9
+ import { slauth } from 'slauthjs'
10
+ import { d1 } from 'slauthjs/stores/d1'
11
+ import { resend } from 'slauthjs/email/resend'
12
+
13
+ const auth = slauth({
14
+ secret: env.SLAUTH_SECRET,
15
+ store: d1(env.DB),
16
+ email: resend({ apiKey: env.RESEND_API_KEY, from: 'My App <hi@myapp.dev>' }),
17
+ })
18
+
19
+ if (url.pathname.startsWith('/auth/')) return auth.handle(request)
20
+ const user = await auth.user(request) // { id, email, createdAt } or null
21
+ ```
22
+
23
+ ```html
24
+ <script type="module">import 'slauthjs/element'</script>
25
+ <slauth-sign-in app-name="My App" redirect="/"></slauth-sign-in>
26
+ ```
27
+
28
+ - **Email codes**: 6 digits, 10 minutes, 5 tries, stored as an HMAC.
29
+ - **Passkeys**: WebAuthn (ES256 + RS256) checked with Web Crypto, no libraries.
30
+ - **Your database**: D1, SQLite (bun:sqlite, node:sqlite, better-sqlite3), Workers KV, memory, or any `get`/`put`/`delete`.
31
+ - **Runs anywhere** with `Request`/`Response`: Cloudflare Workers, Bun, Deno, Node, Next.js, Astro, Hono.
32
+ - **Extras**: an allow list (`['me@example.com', '@family.com']`), an optional PIN app lock, and Cloudflare Access support.
33
+
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/).
35
+
36
+ ## Examples
37
+
38
+ - [`examples/workers-d1`](examples/workers-d1): Cloudflare Workers + D1, with the sign-in box
39
+ - [`examples/bun-sqlite`](examples/bun-sqlite): Bun + bun:sqlite
40
+ - [`examples/node-express`](examples/node-express): Express + node:sqlite
41
+
42
+ ## Not for
43
+
44
+ Enterprise SSO/SAML, orgs and roles, or dozens of OAuth providers. For those, see
45
+ [the comparison](https://slauth.dev/compare).
46
+
47
+ ## Repository
48
+
49
+ | | |
50
+ |---|---|
51
+ | `src/` | the library (plain ESM + `.d.ts`) |
52
+ | `test/` | test helpers, a simulated passkey authenticator, type checks, a browser test |
53
+ | `examples/` | runnable apps |
54
+ | `site/` | slauth.dev: Astro marketing pages + Starlight docs |
55
+
56
+ See [CONTRIBUTING.md](CONTRIBUTING.md). MIT licensed.
package/package.json CHANGED
@@ -1,6 +1,92 @@
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": "1.0.0",
4
+ "description": "Auth for side projects: email codes and passkeys, your own 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
+ "./element": {
19
+ "types": "./src/element.d.ts",
20
+ "default": "./src/element.js"
21
+ },
22
+ "./node": {
23
+ "types": "./src/node.d.ts",
24
+ "default": "./src/node.js"
25
+ },
26
+ "./stores/memory": {
27
+ "types": "./src/stores/memory.d.ts",
28
+ "default": "./src/stores/memory.js"
29
+ },
30
+ "./stores/d1": {
31
+ "types": "./src/stores/d1.d.ts",
32
+ "default": "./src/stores/d1.js"
33
+ },
34
+ "./stores/kv": {
35
+ "types": "./src/stores/kv.d.ts",
36
+ "default": "./src/stores/kv.js"
37
+ },
38
+ "./stores/sqlite": {
39
+ "types": "./src/stores/sqlite.d.ts",
40
+ "default": "./src/stores/sqlite.js"
41
+ },
42
+ "./email/console": {
43
+ "types": "./src/email/console.d.ts",
44
+ "default": "./src/email/console.js"
45
+ },
46
+ "./email/resend": {
47
+ "types": "./src/email/resend.d.ts",
48
+ "default": "./src/email/resend.js"
49
+ },
50
+ "./email/cloudflare": {
51
+ "types": "./src/email/cloudflare.d.ts",
52
+ "default": "./src/email/cloudflare.js"
53
+ }
54
+ },
55
+ "files": [
56
+ "src",
57
+ "!src/**/*.test.js"
58
+ ],
59
+ "sideEffects": [
60
+ "./src/element.js"
61
+ ],
62
+ "engines": {
63
+ "node": ">=20"
64
+ },
65
+ "scripts": {
66
+ "test": "vitest run",
67
+ "typecheck": "tsc -p tsconfig.json",
68
+ "prepublishOnly": "vitest run && tsc -p tsconfig.json"
69
+ },
70
+ "devDependencies": {
71
+ "@types/node": "^22.20.5",
72
+ "typescript": "^5.9.3",
73
+ "vitest": "^5.0.3"
74
+ },
75
+ "keywords": [
76
+ "auth",
77
+ "authentication",
78
+ "passkeys",
79
+ "webauthn",
80
+ "magic-code",
81
+ "cloudflare-workers",
82
+ "d1",
83
+ "bun",
84
+ "deno",
85
+ "side-project"
86
+ ],
87
+ "homepage": "https://slauth.dev",
88
+ "repository": {
89
+ "type": "git",
90
+ "url": "git+https://github.com/silicondaydream/slauth.git"
91
+ }
92
+ }
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,34 @@
1
+ import type { Policy, SlauthUser } from './index.js'
2
+
3
+ export class SlauthError extends Error {
4
+ /** e.g. "bad-code", "expired", "not-allowed", "slow-down", "signed-out", "offline". */
5
+ code: string
6
+ status: number
7
+ [detail: string]: unknown
8
+ }
9
+
10
+ export interface SlauthClient {
11
+ session(): Promise<{ user: SlauthUser | null; lock?: { policy: Policy; required: boolean; pinSet: boolean; unlocked: boolean } }>
12
+ email: {
13
+ start(email: string): Promise<{ ok: true }>
14
+ verify(email: string, code: string): Promise<{ user: SlauthUser }>
15
+ }
16
+ passkey: {
17
+ supported(): boolean
18
+ register(name?: string): Promise<{ ok: true; passkey: { id: string; name: string } }>
19
+ signIn(): Promise<{ user: SlauthUser }>
20
+ list(): Promise<{ passkeys: { id: string; name: string; createdAt: number; lastUsedAt: number | null }[] }>
21
+ remove(id: string): Promise<{ ok: true }>
22
+ }
23
+ signOut(): Promise<{ ok: true }>
24
+ lock: {
25
+ unlock(pin: string): Promise<{ ok: true }>
26
+ setup(pin: string, policy?: Policy): Promise<{ ok: true }>
27
+ skip(): Promise<{ ok: true }>
28
+ changePin(current: string, next: string): Promise<{ ok: true }>
29
+ setPolicy(policy: Policy, pin?: string): Promise<{ ok: true; policy: Policy }>
30
+ lockNow(): Promise<{ ok: true }>
31
+ }
32
+ }
33
+
34
+ export function createClient(options?: { basePath?: string; fetch?: typeof fetch }): SlauthClient
package/src/client.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The browser side: tiny helpers around the /auth routes, including the WebAuthn
3
+ * plumbing passkeys need.
4
+ *
5
+ * import { createClient } from 'slauthjs/client'
6
+ * const auth = createClient() // or createClient({ basePath: '/api/auth' })
7
+ * await auth.email.start('me@example.com')
8
+ * const { user } = await auth.email.verify('me@example.com', '123456')
9
+ */
10
+
11
+ /** Thrown for any answer that isn't a success; `code` says which (see the docs' error list). */
12
+ export class SlauthError extends Error {
13
+ constructor(message, code, status, details = {}) {
14
+ super(message)
15
+ this.code = code
16
+ this.status = status
17
+ Object.assign(this, details)
18
+ }
19
+ }
20
+
21
+ export function createClient({ basePath = '/auth', fetch: doFetch = (...args) => fetch(...args) } = {}) {
22
+ const base = basePath.replace(/\/+$/, '')
23
+
24
+ async function call(path, body) {
25
+ const init = body === undefined ? { credentials: 'same-origin' } : { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
26
+ let res
27
+ try {
28
+ res = await doFetch(`${base}/${path}`, init)
29
+ } catch {
30
+ throw new SlauthError("Couldn't reach the server.", 'offline', 0)
31
+ }
32
+ const data = await res.json().catch(() => ({}))
33
+ if (!res.ok) {
34
+ const { error, code, ...rest } = data
35
+ throw new SlauthError(error ?? `HTTP ${res.status}`, code ?? 'server-error', res.status, rest)
36
+ }
37
+ return data
38
+ }
39
+
40
+ const passkey = {
41
+ /** True when this browser can use passkeys. */
42
+ supported: () => typeof window !== 'undefined' && typeof window.PublicKeyCredential === 'function',
43
+
44
+ /** Adds a passkey for the signed-in person. */
45
+ async register(name) {
46
+ const options = await call('passkey/register/options', {})
47
+ const credential = await navigator.credentials.create({
48
+ publicKey: {
49
+ ...options,
50
+ challenge: bytes(options.challenge),
51
+ user: { ...options.user, id: bytes(options.user.id) },
52
+ excludeCredentials: options.excludeCredentials.map((c) => ({ ...c, id: bytes(c.id) })),
53
+ },
54
+ })
55
+ return call('passkey/register/verify', { credential: serialize(credential), name })
56
+ },
57
+
58
+ /** Signs in with a passkey; the browser lets the person pick one. */
59
+ async signIn() {
60
+ const options = await call('passkey/login/options', {})
61
+ const credential = await navigator.credentials.get({
62
+ publicKey: { ...options, challenge: bytes(options.challenge), allowCredentials: [] },
63
+ })
64
+ return call('passkey/login/verify', { credential: serialize(credential) })
65
+ },
66
+
67
+ list: () => call('passkeys'),
68
+ remove: (id) => call('passkey/delete', { id }),
69
+ }
70
+
71
+ return {
72
+ /** `{ user }` (null when signed out), plus `lock` when the app lock is on. */
73
+ session: () => call('session'),
74
+ email: {
75
+ start: (email) => call('email/start', { email }),
76
+ verify: (email, code) => call('email/verify', { email, code }),
77
+ },
78
+ passkey,
79
+ signOut: () => call('signout', {}),
80
+ lock: {
81
+ unlock: (pin) => call('lock/login', { pin }),
82
+ setup: (pin, policy) => call('lock/setup', { pin, policy }),
83
+ skip: () => call('lock/setup', { policy: 'never' }),
84
+ changePin: (current, next) => call('lock/pin', { current, next }),
85
+ setPolicy: (policy, pin) => call('lock/policy', { policy, pin }),
86
+ lockNow: () => call('lock/logout', {}),
87
+ },
88
+ }
89
+ }
90
+
91
+ function bytes(base64url) {
92
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
93
+ const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4))
94
+ return Uint8Array.from(binary, (c) => c.charCodeAt(0))
95
+ }
96
+
97
+ function text(buffer) {
98
+ let binary = ''
99
+ for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte)
100
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
101
+ }
102
+
103
+ /** A PublicKeyCredential as JSON with base64url fields, the shape the server reads. */
104
+ function serialize(credential) {
105
+ if (typeof credential.toJSON === 'function') return credential.toJSON()
106
+ const r = credential.response
107
+ const response = { clientDataJSON: text(r.clientDataJSON) }
108
+ if (r.attestationObject) {
109
+ response.attestationObject = text(r.attestationObject)
110
+ response.transports = r.getTransports?.() ?? []
111
+ }
112
+ if (r.authenticatorData) {
113
+ response.authenticatorData = text(r.authenticatorData)
114
+ response.signature = text(r.signature)
115
+ response.userHandle = r.userHandle ? text(r.userHandle) : null
116
+ }
117
+ return { id: credential.id, rawId: text(credential.rawId), type: credential.type, response }
118
+ }