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/src/index.d.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** A key-value store: the only thing slauth needs from your database. */
|
|
2
|
+
export interface Store {
|
|
3
|
+
get(key: string): Promise<any>
|
|
4
|
+
/** ttl is in seconds. */
|
|
5
|
+
put(key: string, value: unknown, options?: { ttl?: number }): Promise<void>
|
|
6
|
+
delete(key: string): Promise<void>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Sends the sign-in email. `subject`, `text` and `html` are ready to use. */
|
|
10
|
+
export interface EmailSender {
|
|
11
|
+
send(message: { to: string; code: string; appName: string; subject: string; text: string; html: string }): Promise<void>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SlauthUser {
|
|
15
|
+
id: string
|
|
16
|
+
email: string
|
|
17
|
+
createdAt: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SlauthConfig {
|
|
21
|
+
/** A random string of 16+ characters. Make one with: openssl rand -base64 32 */
|
|
22
|
+
secret: string
|
|
23
|
+
/** Where users, sessions and passkeys live: memory(), d1(env.DB), kv(env.KV), sqlite(db), or your own. */
|
|
24
|
+
store: Store
|
|
25
|
+
/** How codes are emailed. Defaults to printing them to the console. */
|
|
26
|
+
email?: EmailSender
|
|
27
|
+
/** Shown in emails and as the passkey's site name. */
|
|
28
|
+
appName?: string
|
|
29
|
+
/** Who may sign in: a list of emails ("@family.com" for a whole domain) or a function. Everyone, if left out. */
|
|
30
|
+
allow?: string[] | ((email: string) => boolean | Promise<boolean>)
|
|
31
|
+
/** Where the routes live. Default "/auth". */
|
|
32
|
+
basePath?: string
|
|
33
|
+
/** Session cookie name. Default "slauth_session". */
|
|
34
|
+
cookie?: string
|
|
35
|
+
/** Sessions last this many days after the last visit. Default 30. */
|
|
36
|
+
sessionDays?: number
|
|
37
|
+
/** Origins allowed to post to the routes (and to use passkeys). Default: the request's own origin. */
|
|
38
|
+
origins?: string | string[]
|
|
39
|
+
/** Passkey settings, or false to turn passkeys off. */
|
|
40
|
+
passkeys?:
|
|
41
|
+
| false
|
|
42
|
+
| {
|
|
43
|
+
/** The site passkeys belong to, e.g. "myapp.dev" (works for every subdomain). Default: the request's hostname. */
|
|
44
|
+
rpId?: string
|
|
45
|
+
rpName?: string
|
|
46
|
+
origin?: string | string[]
|
|
47
|
+
userVerification?: 'required' | 'preferred' | 'discouraged'
|
|
48
|
+
}
|
|
49
|
+
/** Also accept people signed in by Cloudflare Access. */
|
|
50
|
+
access?: { team: string; aud: string | string[]; devUser?: string; fetch?: typeof fetch }
|
|
51
|
+
/** Turn on the PIN app lock. true = off by default for each person; or set their default. */
|
|
52
|
+
lock?: true | { default?: Policy; cookie?: string }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface Slauth {
|
|
56
|
+
/** Answers every request under basePath (404 for anything else). */
|
|
57
|
+
handle(request: Request): Promise<Response>
|
|
58
|
+
/** The signed-in person, or null. Ignores the app lock. */
|
|
59
|
+
user(request: Request): Promise<SlauthUser | null>
|
|
60
|
+
/** The signed-in, unlocked person, or a 401 Response to return as it is. */
|
|
61
|
+
requireUser(request: Request): Promise<SlauthUser | Response>
|
|
62
|
+
store: Store
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function slauth(config: SlauthConfig): Slauth
|
|
66
|
+
export function memory(): Store
|
|
67
|
+
export function consoleEmail(): EmailSender
|
|
68
|
+
|
|
69
|
+
/** What createAccess and createLock work with. */
|
|
70
|
+
export interface User {
|
|
71
|
+
/** Lower-cased email from the Access sign-in. */
|
|
72
|
+
email: string
|
|
73
|
+
/** Stable, opaque id: the same in every app for the same email. */
|
|
74
|
+
userId: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface AccessOptions {
|
|
78
|
+
/** "yourteam" or "yourteam.cloudflareaccess.com". */
|
|
79
|
+
teamDomain: string
|
|
80
|
+
/** The Application Audience (AUD) tag of the Access application(s). */
|
|
81
|
+
audience: string | string[]
|
|
82
|
+
/** Optional second check after the Access policy. */
|
|
83
|
+
allowedEmails?: string[]
|
|
84
|
+
/** email -> userId, to keep someone's data when their email changes. */
|
|
85
|
+
aliases?: Record<string, string>
|
|
86
|
+
/** Local development only: treat every request as this email. Never set in production. */
|
|
87
|
+
devUser?: string
|
|
88
|
+
fetch?: typeof fetch
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createAccess(options: AccessOptions): {
|
|
92
|
+
/** The signed-in person, or null when the request did not come through Access. */
|
|
93
|
+
getUser(request: Request): Promise<User | null>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function userIdFor(email: string): Promise<string>
|
|
97
|
+
|
|
98
|
+
export type Policy = 'always' | 'daily' | 'weekly' | 'never'
|
|
99
|
+
export const POLICIES: readonly Policy[]
|
|
100
|
+
|
|
101
|
+
export interface LockDoc {
|
|
102
|
+
pinHash?: string
|
|
103
|
+
salt?: string
|
|
104
|
+
sessionKey: string
|
|
105
|
+
failures: number
|
|
106
|
+
lockedUntil: number
|
|
107
|
+
policy?: Policy
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface LockStore {
|
|
111
|
+
get(userId: string): Promise<LockDoc | null>
|
|
112
|
+
put(userId: string, doc: LockDoc): Promise<void>
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface LockReply {
|
|
116
|
+
status: number
|
|
117
|
+
body: Record<string, unknown>
|
|
118
|
+
/** A Set-Cookie header value, when the reply changes the unlock. */
|
|
119
|
+
cookie?: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface LockOptions {
|
|
123
|
+
/** Cookie name; keep an app's old one so nobody is signed out twice. Default "slauth_lock". */
|
|
124
|
+
cookie?: string
|
|
125
|
+
/** For people who haven't chosen yet. Default "weekly". */
|
|
126
|
+
defaultPolicy?: Policy
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function createLock(
|
|
130
|
+
store: LockStore,
|
|
131
|
+
options?: LockOptions,
|
|
132
|
+
): {
|
|
133
|
+
/**
|
|
134
|
+
* Routes: GET session, POST login | setup | logout | pin | policy.
|
|
135
|
+
* Returns null for any other route.
|
|
136
|
+
*/
|
|
137
|
+
handle(
|
|
138
|
+
route: string,
|
|
139
|
+
method: string,
|
|
140
|
+
readBody: () => Promise<any>,
|
|
141
|
+
user: User,
|
|
142
|
+
cookieHeader: string | null | undefined,
|
|
143
|
+
secure: boolean,
|
|
144
|
+
): Promise<LockReply | null>
|
|
145
|
+
isUnlocked(user: User, cookieHeader: string | null | undefined): Promise<boolean>
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function validPin(pin: unknown): pin is string
|
|
149
|
+
export function validPolicy(policy: unknown): policy is Policy
|
package/src/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { slauth } from './core.js'
|
|
2
|
+
export { memory } from './stores/memory.js'
|
|
3
|
+
export { consoleEmail } from './email/console.js'
|
|
4
|
+
// The Cloudflare Access check and the PIN lock, usable on their own too.
|
|
5
|
+
export { createAccess, userIdFor } from './access.js'
|
|
6
|
+
export { createLock, validPin, validPolicy, POLICIES } from './lock.js'
|
package/src/lock.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app lock: a 4-digit PIN that each person can ask an app to check again now and
|
|
3
|
+
* then, on top of the Access sign-in. Access says *who* you are; the lock guards an
|
|
4
|
+
* unlocked phone or iPad from whoever picks it up.
|
|
5
|
+
*
|
|
6
|
+
* Each person picks how often, per app:
|
|
7
|
+
* always every visit (the unlock ends when the browser closes, at most 12 hours)
|
|
8
|
+
* daily once a day
|
|
9
|
+
* weekly once a week
|
|
10
|
+
* never no PIN; the Access sign-in is enough
|
|
11
|
+
*
|
|
12
|
+
* Grown out of the Planda / LedgerFox PIN pad, and compatible with its stored hashes:
|
|
13
|
+
* a salted PBKDF2 hash, 5 free tries then a lock of 5 minutes doubling to an hour, and a
|
|
14
|
+
* signed HttpOnly cookie. The signing key is random and per person; changing the PIN
|
|
15
|
+
* replaces it, which locks that person's other devices.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { base64Url, randomToken, readCookie, safeEqual } from './util.js'
|
|
19
|
+
|
|
20
|
+
const DAY = 24 * 60 * 60
|
|
21
|
+
export const POLICIES = /** @type {const} */ (['always', 'daily', 'weekly', 'never'])
|
|
22
|
+
const TTL_SECONDS = { always: DAY / 2, daily: DAY, weekly: 7 * DAY }
|
|
23
|
+
const FREE_ATTEMPTS = 5
|
|
24
|
+
const PBKDF2_ITERATIONS = 100_000
|
|
25
|
+
|
|
26
|
+
export function validPin(pin) {
|
|
27
|
+
return typeof pin === 'string' && /^\d{4}$/.test(pin)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function validPolicy(policy) {
|
|
31
|
+
return POLICIES.includes(policy)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {'always' | 'daily' | 'weekly' | 'never'} Policy
|
|
36
|
+
* @typedef {{ pinHash?: string, salt?: string, sessionKey: string, failures: number, lockedUntil: number, policy?: Policy }} LockDoc
|
|
37
|
+
* @typedef {{ get(userId: string): Promise<LockDoc | null>, put(userId: string, doc: LockDoc): Promise<void> }} LockStore
|
|
38
|
+
* @typedef {{ email: string, userId: string }} User
|
|
39
|
+
* @typedef {{ status: number, body: object, cookie?: string }} LockReply
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {LockStore} store where each person's lock document lives
|
|
44
|
+
* @param {{ cookie?: string, defaultPolicy?: Policy }} [options]
|
|
45
|
+
* cookie the cookie name (keep an app's old one so nobody is signed out twice)
|
|
46
|
+
* defaultPolicy for people who haven't chosen yet; "weekly" unless set
|
|
47
|
+
*/
|
|
48
|
+
export function createLock(store, { cookie: cookieName = 'slauth_lock', defaultPolicy = 'weekly' } = {}) {
|
|
49
|
+
if (!validPolicy(defaultPolicy)) throw new Error(`slauth: unknown policy "${defaultPolicy}".`)
|
|
50
|
+
|
|
51
|
+
async function load(userId) {
|
|
52
|
+
const doc = await store.get(userId)
|
|
53
|
+
if (doc && typeof doc.sessionKey === 'string') return doc
|
|
54
|
+
const fresh = { sessionKey: randomToken(), failures: 0, lockedUntil: 0 }
|
|
55
|
+
await store.put(userId, fresh)
|
|
56
|
+
return fresh
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const policyOf = (doc) => (validPolicy(doc.policy) ? doc.policy : defaultPolicy)
|
|
60
|
+
const pinIsSet = (doc) => Boolean(doc.pinHash && doc.salt)
|
|
61
|
+
const matches = async (doc, pin) => pinIsSet(doc) && safeEqual(await hashPin(pin, doc.salt), doc.pinHash)
|
|
62
|
+
|
|
63
|
+
async function unlockCookie(userId, doc, secure) {
|
|
64
|
+
const issued = String(Math.floor(Date.now() / 1000))
|
|
65
|
+
const value = `${issued}.${await sign(`${userId}.${issued}`, doc.sessionKey)}`
|
|
66
|
+
// "always" is a browser-session cookie; the others last as long as the longest policy,
|
|
67
|
+
// and the policy in force decides on each request whether the unlock still counts.
|
|
68
|
+
return cookie(cookieName, value, policyOf(doc) === 'always' ? null : TTL_SECONDS.weekly, secure)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* True when this person may use the app right now: they chose "never", or they
|
|
73
|
+
* unlocked recently enough for their policy.
|
|
74
|
+
* @param {User} user
|
|
75
|
+
* @param {string | null | undefined} cookieHeader
|
|
76
|
+
*/
|
|
77
|
+
async function isUnlocked(user, cookieHeader) {
|
|
78
|
+
const doc = await load(user.userId)
|
|
79
|
+
const policy = policyOf(doc)
|
|
80
|
+
if (policy === 'never') return true
|
|
81
|
+
if (!pinIsSet(doc)) return false
|
|
82
|
+
|
|
83
|
+
const [issued, signature] = (readCookie(cookieHeader, cookieName) ?? '').split('.')
|
|
84
|
+
const issuedAt = Number(issued)
|
|
85
|
+
if (!signature || !Number.isFinite(issuedAt)) return false
|
|
86
|
+
const age = Date.now() / 1000 - issuedAt
|
|
87
|
+
if (age < -60 || age > TTL_SECONDS[policy]) return false
|
|
88
|
+
// The user id is part of what's signed, so one person's cookie never unlocks another's.
|
|
89
|
+
return safeEqual(signature, await sign(`${user.userId}.${issued}`, doc.sessionKey))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function savePin(userId, doc, pin, policy, secure) {
|
|
93
|
+
const salt = randomToken()
|
|
94
|
+
const next = { ...doc, pinHash: await hashPin(pin, salt), salt, sessionKey: randomToken(), failures: 0, lockedUntil: 0, policy }
|
|
95
|
+
await store.put(userId, next)
|
|
96
|
+
return unlockCookie(userId, next, secure)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @returns {Promise<LockReply>} */
|
|
100
|
+
async function login(user, guess, secure) {
|
|
101
|
+
if (!validPin(guess)) return { status: 400, body: { error: 'The PIN is 4 digits.' } }
|
|
102
|
+
const doc = await load(user.userId)
|
|
103
|
+
const now = Date.now()
|
|
104
|
+
if (!pinIsSet(doc)) return { status: 409, body: { error: 'No PIN has been created yet.', reason: 'no-pin' } }
|
|
105
|
+
if (doc.lockedUntil > now) {
|
|
106
|
+
return { status: 429, body: { error: 'Too many tries.', reason: 'locked', retryAt: doc.lockedUntil } }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (await matches(doc, guess)) {
|
|
110
|
+
if (doc.failures !== 0 || doc.lockedUntil !== 0) await store.put(user.userId, { ...doc, failures: 0, lockedUntil: 0 })
|
|
111
|
+
return { status: 200, body: { ok: true }, cookie: await unlockCookie(user.userId, doc, secure) }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const failures = doc.failures + 1
|
|
115
|
+
const over = failures - FREE_ATTEMPTS
|
|
116
|
+
const lockedUntil = over >= 0 ? now + Math.min(60, 5 * 2 ** over) * 60_000 : 0
|
|
117
|
+
await store.put(user.userId, { ...doc, failures, lockedUntil })
|
|
118
|
+
return lockedUntil
|
|
119
|
+
? { status: 429, body: { error: 'Too many tries.', reason: 'locked', retryAt: lockedUntil } }
|
|
120
|
+
: { status: 401, body: { error: 'Wrong PIN.', reason: 'wrong', attemptsLeft: FREE_ATTEMPTS - failures } }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** First time in this app: choose a PIN (and optionally how often to ask for it). */
|
|
124
|
+
async function setup(user, pin, policy, secure) {
|
|
125
|
+
if (!validPin(pin)) return { status: 400, body: { error: 'The PIN is 4 digits.' } }
|
|
126
|
+
if (policy !== undefined && !validPolicy(policy)) return { status: 400, body: { error: 'Unknown lock setting.' } }
|
|
127
|
+
const doc = await load(user.userId)
|
|
128
|
+
if (pinIsSet(doc)) return { status: 409, body: { error: 'A PIN already exists.' } }
|
|
129
|
+
return { status: 200, body: { ok: true }, cookie: await savePin(user.userId, doc, pin, policy ?? policyOf(doc), secure) }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Change PIN from Settings. Not counted toward the lockout; locks this person's other devices. */
|
|
133
|
+
async function change(user, current, next, secure) {
|
|
134
|
+
if (!validPin(current) || !validPin(next)) return { status: 400, body: { error: 'PINs are 4 digits.' } }
|
|
135
|
+
const doc = await load(user.userId)
|
|
136
|
+
if (!(await matches(doc, current))) return { status: 403, body: { error: 'The current PIN is wrong.' } }
|
|
137
|
+
return { status: 200, body: { ok: true }, cookie: await savePin(user.userId, doc, next, policyOf(doc), secure) }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Change how often the PIN is asked for. With a PIN set, the current PIN is needed,
|
|
142
|
+
* so a borrowed unlocked device can't switch the lock off.
|
|
143
|
+
*/
|
|
144
|
+
async function setPolicy(user, policy, pin, secure) {
|
|
145
|
+
if (!validPolicy(policy)) return { status: 400, body: { error: 'Unknown lock setting.' } }
|
|
146
|
+
const doc = await load(user.userId)
|
|
147
|
+
if (pinIsSet(doc)) {
|
|
148
|
+
if (!validPin(pin) || !(await matches(doc, pin))) return { status: 403, body: { error: 'The current PIN is wrong.' } }
|
|
149
|
+
} else if (policy !== 'never') {
|
|
150
|
+
return { status: 409, body: { error: 'Create a PIN first.', reason: 'no-pin' } }
|
|
151
|
+
}
|
|
152
|
+
const next = { ...doc, policy }
|
|
153
|
+
await store.put(user.userId, next)
|
|
154
|
+
const reply = { status: 200, body: { ok: true, policy } }
|
|
155
|
+
return pinIsSet(next) ? { ...reply, cookie: await unlockCookie(user.userId, next, secure) } : reply
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The lock routes. `route` is the part after the app's prefix (e.g. "session" for
|
|
160
|
+
* /api/lock/session). Returns null for any other route.
|
|
161
|
+
* @param {string} route
|
|
162
|
+
* @param {string} method
|
|
163
|
+
* @param {() => Promise<any>} readBody
|
|
164
|
+
* @param {User} user from Access
|
|
165
|
+
* @param {string | null | undefined} cookieHeader
|
|
166
|
+
* @param {boolean} secure https, so the cookie gets the Secure flag
|
|
167
|
+
* @returns {Promise<LockReply | null>}
|
|
168
|
+
*/
|
|
169
|
+
async function handle(route, method, readBody, user, cookieHeader, secure) {
|
|
170
|
+
if (route === 'session' && method === 'GET') {
|
|
171
|
+
const doc = await load(user.userId)
|
|
172
|
+
const policy = policyOf(doc)
|
|
173
|
+
return {
|
|
174
|
+
status: 200,
|
|
175
|
+
body: { email: user.email, policy, required: policy !== 'never', pinSet: pinIsSet(doc), unlocked: await isUnlocked(user, cookieHeader) },
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (route === 'login' && method === 'POST') return login(user, (await readBody())?.pin, secure)
|
|
179
|
+
if (route === 'setup' && method === 'POST') {
|
|
180
|
+
const body = await readBody()
|
|
181
|
+
if (body?.policy === 'never' && body?.pin === undefined) return setPolicy(user, 'never', undefined, secure)
|
|
182
|
+
return setup(user, body?.pin, body?.policy, secure)
|
|
183
|
+
}
|
|
184
|
+
if (route === 'logout' && method === 'POST') return { status: 200, body: { ok: true }, cookie: cookie(cookieName, '', 0, secure) }
|
|
185
|
+
if (route === 'pin' && method === 'POST') {
|
|
186
|
+
if (!(await isUnlocked(user, cookieHeader))) return { status: 401, body: { error: 'Please enter your PIN.' } }
|
|
187
|
+
const body = await readBody()
|
|
188
|
+
return change(user, body?.current, body?.next, secure)
|
|
189
|
+
}
|
|
190
|
+
if (route === 'policy' && method === 'POST') {
|
|
191
|
+
if (!(await isUnlocked(user, cookieHeader))) return { status: 401, body: { error: 'Please enter your PIN.' } }
|
|
192
|
+
const body = await readBody()
|
|
193
|
+
return setPolicy(user, body?.policy, body?.pin, secure)
|
|
194
|
+
}
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { handle, isUnlocked }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function cookie(name, value, maxAge, secure) {
|
|
202
|
+
const age = maxAge === null ? '' : `; Max-Age=${maxAge}`
|
|
203
|
+
return `${name}=${value}; Path=/; HttpOnly; SameSite=Strict${age}${secure ? '; Secure' : ''}`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async function hashPin(pin, salt) {
|
|
208
|
+
const encoder = new TextEncoder()
|
|
209
|
+
const key = await crypto.subtle.importKey('raw', encoder.encode(pin), 'PBKDF2', false, ['deriveBits'])
|
|
210
|
+
const bits = await crypto.subtle.deriveBits(
|
|
211
|
+
{ name: 'PBKDF2', hash: 'SHA-256', salt: encoder.encode(salt), iterations: PBKDF2_ITERATIONS },
|
|
212
|
+
key,
|
|
213
|
+
256,
|
|
214
|
+
)
|
|
215
|
+
return base64Url(bits)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function sign(payload, secret) {
|
|
219
|
+
const encoder = new TextEncoder()
|
|
220
|
+
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
|
|
221
|
+
return base64Url(await crypto.subtle.sign('HMAC', key, encoder.encode(payload)))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
|
package/src/node.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
+
export function toNodeHandler(auth: { handle(request: Request): Promise<Response> }): (req: IncomingMessage, res: ServerResponse, next?: (error?: unknown) => void) => Promise<void>
|
|
3
|
+
export function toRequest(req: IncomingMessage): Promise<Request>
|
package/src/node.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain Node (node:http, Express, Connect): turns `auth.handle` into `(req, res, next)`.
|
|
3
|
+
*
|
|
4
|
+
* import { toNodeHandler } from 'slauthjs/node'
|
|
5
|
+
* app.use('/auth', toNodeHandler(auth)) // Express
|
|
6
|
+
* const user = await auth.user(toRequest(req)) // in your own routes
|
|
7
|
+
*
|
|
8
|
+
* Everything else (Workers, Bun, Deno, Next.js, Astro, Hono) speaks Request/Response
|
|
9
|
+
* already and can call auth.handle(request) directly.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** @param {{ handle(request: Request): Promise<Response> }} auth */
|
|
13
|
+
export function toNodeHandler(auth) {
|
|
14
|
+
return async (req, res, next) => {
|
|
15
|
+
try {
|
|
16
|
+
const response = await auth.handle(await toRequest(req))
|
|
17
|
+
res.statusCode = response.status
|
|
18
|
+
for (const [key, value] of response.headers) {
|
|
19
|
+
if (key.toLowerCase() === 'set-cookie') continue
|
|
20
|
+
res.setHeader(key, value)
|
|
21
|
+
}
|
|
22
|
+
const cookies = response.headers.getSetCookie?.() ?? []
|
|
23
|
+
if (cookies.length) res.setHeader('Set-Cookie', cookies)
|
|
24
|
+
res.end(Buffer.from(await response.arrayBuffer()))
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (next) next(error)
|
|
27
|
+
else {
|
|
28
|
+
res.statusCode = 500
|
|
29
|
+
res.end()
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A Web Request from a Node IncomingMessage (body included).
|
|
37
|
+
* @param {import('node:http').IncomingMessage & { originalUrl?: string, body?: unknown }} req
|
|
38
|
+
*/
|
|
39
|
+
export async function toRequest(req) {
|
|
40
|
+
const proto = req.headers['x-forwarded-proto']?.toString().split(',')[0] ?? (req.socket?.encrypted ? 'https' : 'http')
|
|
41
|
+
const host = req.headers['x-forwarded-host']?.toString() ?? req.headers.host ?? 'localhost'
|
|
42
|
+
// Under app.use('/auth', ...) Express strips the prefix from req.url; originalUrl keeps it.
|
|
43
|
+
const url = new URL(req.originalUrl ?? req.url ?? '/', `${proto}://${host}`)
|
|
44
|
+
|
|
45
|
+
const headers = new Headers()
|
|
46
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
47
|
+
if (value === undefined) continue
|
|
48
|
+
for (const v of [value].flat()) headers.append(key, v)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let body
|
|
52
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
53
|
+
// Express's json() may already have read the body.
|
|
54
|
+
if (req.body !== undefined && typeof req.body === 'object' && !Buffer.isBuffer(req.body)) body = JSON.stringify(req.body)
|
|
55
|
+
else {
|
|
56
|
+
const chunks = []
|
|
57
|
+
let size = 0
|
|
58
|
+
for await (const chunk of req) {
|
|
59
|
+
size += chunk.length
|
|
60
|
+
if (size > 1_000_000) throw new Error('slauth: request body too large.')
|
|
61
|
+
chunks.push(chunk)
|
|
62
|
+
}
|
|
63
|
+
body = Buffer.concat(chunks)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return new Request(url, { method: req.method, headers, body })
|
|
67
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Just enough CBOR (RFC 8949) to read what authenticators send: integers, byte and
|
|
3
|
+
* text strings, arrays, maps, booleans, null and floats. Definite lengths only, which
|
|
4
|
+
* is all WebAuthn uses.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Decodes one item from `bytes` starting at `offset`.
|
|
9
|
+
* @param {Uint8Array} bytes
|
|
10
|
+
* @returns {{ value: any, offset: number }} the item and where the next one starts
|
|
11
|
+
*/
|
|
12
|
+
export function decodeCbor(bytes, offset = 0) {
|
|
13
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
14
|
+
let at = offset
|
|
15
|
+
|
|
16
|
+
const need = (n) => {
|
|
17
|
+
if (at + n > bytes.length) throw new Error('CBOR: ran out of bytes.')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const length = (info) => {
|
|
21
|
+
if (info < 24) return info
|
|
22
|
+
if (info === 24) return need(1), view.getUint8(at++)
|
|
23
|
+
if (info === 25) return need(2), (at += 2), view.getUint16(at - 2)
|
|
24
|
+
if (info === 26) return need(4), (at += 4), view.getUint32(at - 4)
|
|
25
|
+
if (info === 27) {
|
|
26
|
+
need(8)
|
|
27
|
+
const big = view.getBigUint64(at)
|
|
28
|
+
at += 8
|
|
29
|
+
if (big > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('CBOR: number too large.')
|
|
30
|
+
return Number(big)
|
|
31
|
+
}
|
|
32
|
+
throw new Error('CBOR: indefinite lengths are not supported.')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const item = (depth) => {
|
|
36
|
+
if (depth > 16) throw new Error('CBOR: nested too deeply.')
|
|
37
|
+
need(1)
|
|
38
|
+
const first = view.getUint8(at++)
|
|
39
|
+
const major = first >> 5
|
|
40
|
+
const info = first & 31
|
|
41
|
+
switch (major) {
|
|
42
|
+
case 0:
|
|
43
|
+
return length(info)
|
|
44
|
+
case 1:
|
|
45
|
+
return -1 - length(info)
|
|
46
|
+
case 2: {
|
|
47
|
+
const n = length(info)
|
|
48
|
+
need(n)
|
|
49
|
+
at += n
|
|
50
|
+
return bytes.slice(at - n, at)
|
|
51
|
+
}
|
|
52
|
+
case 3: {
|
|
53
|
+
const n = length(info)
|
|
54
|
+
need(n)
|
|
55
|
+
at += n
|
|
56
|
+
return new TextDecoder().decode(bytes.subarray(at - n, at))
|
|
57
|
+
}
|
|
58
|
+
case 4: {
|
|
59
|
+
const n = length(info)
|
|
60
|
+
const out = []
|
|
61
|
+
for (let i = 0; i < n; i++) out.push(item(depth + 1))
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
case 5: {
|
|
65
|
+
const n = length(info)
|
|
66
|
+
const out = new Map()
|
|
67
|
+
for (let i = 0; i < n; i++) {
|
|
68
|
+
const key = item(depth + 1)
|
|
69
|
+
out.set(key, item(depth + 1))
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
case 6:
|
|
74
|
+
// A tag: skip it and read what it wraps.
|
|
75
|
+
length(info)
|
|
76
|
+
return item(depth + 1)
|
|
77
|
+
case 7:
|
|
78
|
+
if (info === 20) return false
|
|
79
|
+
if (info === 21) return true
|
|
80
|
+
if (info === 22 || info === 23) return null
|
|
81
|
+
if (info === 25) return need(2), (at += 2), halfFloat(view.getUint16(at - 2))
|
|
82
|
+
if (info === 26) return need(4), (at += 4), view.getFloat32(at - 4)
|
|
83
|
+
if (info === 27) return need(8), (at += 8), view.getFloat64(at - 8)
|
|
84
|
+
throw new Error('CBOR: unsupported simple value.')
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const value = item(0)
|
|
89
|
+
return { value, offset: at }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function halfFloat(bits) {
|
|
93
|
+
const exp = (bits >> 10) & 0x1f
|
|
94
|
+
const frac = bits & 0x3ff
|
|
95
|
+
const sign = bits & 0x8000 ? -1 : 1
|
|
96
|
+
if (exp === 0) return sign * 2 ** -14 * (frac / 1024)
|
|
97
|
+
if (exp === 31) return frac ? NaN : sign * Infinity
|
|
98
|
+
return sign * 2 ** (exp - 15) * (1 + frac / 1024)
|
|
99
|
+
}
|