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 +21 -0
- package/README.md +55 -2
- package/package.json +90 -4
- package/src/access.js +143 -0
- package/src/client.d.ts +34 -0
- package/src/client.js +118 -0
- package/src/core.js +311 -0
- package/src/element.d.ts +6 -0
- package/src/element.js +151 -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 +13 -0
- package/src/email/index.d.ts +4 -0
- package/src/email/message.js +16 -0
- package/src/email/resend.d.ts +1 -0
- package/src/email/resend.js +21 -0
- package/src/email-code.js +61 -0
- package/src/index.d.ts +149 -0
- package/src/index.js +6 -0
- package/src/lock.js +225 -0
- package/src/node.d.ts +3 -0
- package/src/node.js +67 -0
- package/src/passkey/cbor.js +99 -0
- package/src/passkey/webauthn.js +183 -0
- package/src/session.js +49 -0
- package/src/stores/d1.d.ts +1 -0
- package/src/stores/d1.js +44 -0
- package/src/stores/index.d.ts +5 -0
- package/src/stores/kv.d.ts +1 -0
- package/src/stores/kv.js +18 -0
- package/src/stores/memory.d.ts +1 -0
- package/src/stores/memory.js +30 -0
- package/src/stores/sqlite.d.ts +1 -0
- package/src/stores/sqlite.js +35 -0
- package/src/users.js +56 -0
- package/src/util.js +69 -0
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
|
-
#
|
|
1
|
+
# slauth 🦥
|
|
2
2
|
|
|
3
|
-
|
|
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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
|
package/src/client.d.ts
ADDED
|
@@ -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
|
+
}
|