slauthjs 0.0.0-stage → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -0
- package/LICENSE +21 -0
- package/README.md +92 -2
- package/package.json +114 -4
- package/src/access.js +143 -0
- package/src/account-element.d.ts +8 -0
- package/src/account-element.js +244 -0
- package/src/client.d.ts +113 -0
- package/src/client.js +211 -0
- package/src/core.js +442 -0
- package/src/element.d.ts +6 -0
- package/src/element.js +253 -0
- package/src/email/cloudflare.d.ts +1 -0
- package/src/email/cloudflare.js +19 -0
- package/src/email/console.d.ts +1 -0
- package/src/email/console.js +14 -0
- package/src/email/index.d.ts +4 -0
- package/src/email/message.js +56 -0
- package/src/email/resend.d.ts +1 -0
- package/src/email/resend.js +21 -0
- package/src/email-code.js +75 -0
- package/src/index.d.ts +364 -0
- package/src/index.js +9 -0
- package/src/lock.js +225 -0
- package/src/node.d.ts +3 -0
- package/src/node.js +67 -0
- package/src/oauth/flow.js +81 -0
- package/src/oauth/providers.js +171 -0
- package/src/orgs.js +184 -0
- package/src/passkey/cbor.js +99 -0
- package/src/passkey/webauthn.js +183 -0
- package/src/password.js +81 -0
- package/src/providers.d.ts +28 -0
- package/src/providers.js +1 -0
- package/src/rate-limit.js +41 -0
- package/src/routes/account.js +130 -0
- package/src/routes/email.js +147 -0
- package/src/routes/oauth.js +106 -0
- package/src/routes/orgs.js +144 -0
- package/src/routes/passkeys.js +136 -0
- package/src/routes/password.js +78 -0
- package/src/routes/two-factor.js +89 -0
- package/src/session.js +131 -0
- package/src/stores/d1.d.ts +1 -0
- package/src/stores/d1.js +58 -0
- package/src/stores/index.d.ts +5 -0
- package/src/stores/kv.d.ts +1 -0
- package/src/stores/kv.js +27 -0
- package/src/stores/memory.d.ts +1 -0
- package/src/stores/memory.js +35 -0
- package/src/stores/sqlite.d.ts +1 -0
- package/src/stores/sqlite.js +42 -0
- package/src/tokens.js +119 -0
- package/src/two-factor.js +142 -0
- package/src/ui.js +49 -0
- package/src/users.js +126 -0
- package/src/util.js +106 -0
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
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
|
+
/** Optional: keys starting with `prefix`, in order. Only needed for auth.users.list(). */
|
|
8
|
+
list?(prefix: string, options?: { limit?: number; cursor?: string | null }): Promise<{ items: { key: string; value: any }[]; cursor: string | null }>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type EmailKind = 'sign-in' | 'email-change' | 'invite'
|
|
12
|
+
|
|
13
|
+
/** Sends slauth's emails. `subject`, `text` and `html` are ready to use. */
|
|
14
|
+
export interface EmailSender {
|
|
15
|
+
send(message: { to: string; kind: EmailKind; code?: string; link?: string; appName: string; subject: string; text: string; html: string }): Promise<void>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** What the browser, auth.user() and your event handlers see. */
|
|
19
|
+
export interface SlauthUser {
|
|
20
|
+
id: string
|
|
21
|
+
email: string
|
|
22
|
+
name: string | null
|
|
23
|
+
image: string | null
|
|
24
|
+
/** Set on the server (auth.users.update); checked by requireUser(request, { role }). */
|
|
25
|
+
roles: string[]
|
|
26
|
+
/** Yours to fill on the server; sent to the browser. */
|
|
27
|
+
metadata: Record<string, unknown>
|
|
28
|
+
createdAt: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The whole record, for server code only. */
|
|
32
|
+
export interface StoredUser extends SlauthUser {
|
|
33
|
+
/** Never sent to the browser. */
|
|
34
|
+
privateMetadata: Record<string, unknown>
|
|
35
|
+
updatedAt: number
|
|
36
|
+
bannedAt: number | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Profile {
|
|
40
|
+
id: string
|
|
41
|
+
email?: string | null
|
|
42
|
+
emailVerified?: boolean
|
|
43
|
+
name?: string | null
|
|
44
|
+
image?: string | null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A "Sign in with …" provider; make one with the helpers in 'slauthjs/providers'. */
|
|
48
|
+
export interface Provider {
|
|
49
|
+
id: string
|
|
50
|
+
name: string
|
|
51
|
+
clientId: string
|
|
52
|
+
clientSecret?: string
|
|
53
|
+
authorization: string
|
|
54
|
+
token: string
|
|
55
|
+
scope?: string
|
|
56
|
+
params?: Record<string, string>
|
|
57
|
+
pkce?: boolean
|
|
58
|
+
tokenAuth?: 'post' | 'basic'
|
|
59
|
+
/** Count the provider's email as verified even when it doesn't say so. Only for providers you trust. */
|
|
60
|
+
trustEmail?: boolean
|
|
61
|
+
/** Save the provider's access and refresh tokens, for auth.accounts.list(). */
|
|
62
|
+
keepTokens?: boolean
|
|
63
|
+
profile(tokens: Record<string, any>, fetch: typeof globalThis.fetch): Promise<Profile>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface SignInContext {
|
|
67
|
+
method: 'email' | 'link' | 'password' | 'passkey' | 'oauth' | 'access' | 'session' | 'email-change'
|
|
68
|
+
provider?: string
|
|
69
|
+
profile?: Profile
|
|
70
|
+
user?: SlauthUser
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface SlauthEvents {
|
|
74
|
+
signUp?(event: { user: SlauthUser; method: string; request?: Request }): unknown
|
|
75
|
+
signIn?(event: { user: SlauthUser; method: string; request: Request }): unknown
|
|
76
|
+
signOut?(event: { user: SlauthUser; request: Request }): unknown
|
|
77
|
+
userUpdated?(event: { user: SlauthUser }): unknown
|
|
78
|
+
userDeleted?(event: { user: SlauthUser }): unknown
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface SlauthConfig {
|
|
82
|
+
/** A random string of 16+ characters. Make one with: openssl rand -base64 32 */
|
|
83
|
+
secret: string
|
|
84
|
+
/** Where users, sessions and passkeys live: memory(), d1(env.DB), kv(env.KV), sqlite(db), or your own. */
|
|
85
|
+
store: Store
|
|
86
|
+
/** How codes are emailed. Defaults to printing them to the console. */
|
|
87
|
+
email?: EmailSender
|
|
88
|
+
/** Your own wording for the emails. Return null to keep slauth's. */
|
|
89
|
+
emails?: (kind: EmailKind, data: { code?: string; link?: string; appName: string; minutes?: number; orgName?: string; inviter?: string }) => { subject: string; text: string; html: string } | null | Promise<{ subject: string; text: string; html: string } | null>
|
|
90
|
+
/** Shown in emails and as the passkey's site name. */
|
|
91
|
+
appName?: string
|
|
92
|
+
/** Who may sign in: a list of emails ("@family.com" for a whole domain) or a function. Everyone, if left out. */
|
|
93
|
+
allow?: string[] | ((email: string, context: SignInContext) => boolean | Promise<boolean>)
|
|
94
|
+
/** Where the routes live. Default "/auth". */
|
|
95
|
+
basePath?: string
|
|
96
|
+
/** Your site's address for links in emails and provider callbacks, e.g. "https://myapp.dev". Default: the request's origin. */
|
|
97
|
+
baseUrl?: string
|
|
98
|
+
/** The page with your sign-in box; provider errors and two-factor steps come back here. Default: the page the person started on. */
|
|
99
|
+
signInPage?: string
|
|
100
|
+
/** Session cookie name. Default "slauth_session". */
|
|
101
|
+
cookie?: string
|
|
102
|
+
/** Sessions last this many days after the last visit. Default 30. */
|
|
103
|
+
sessionDays?: number
|
|
104
|
+
/** Adding a passkey or password, changing email, turning on two-factor or deleting the account need a sign-in this recent. Default 15; false to skip. */
|
|
105
|
+
freshMinutes?: number | false
|
|
106
|
+
/** Origins allowed to post to the routes (and to use passkeys). Default: the request's own origin. */
|
|
107
|
+
origins?: string | string[]
|
|
108
|
+
/** Passkey settings, or false to turn passkeys off. */
|
|
109
|
+
passkeys?:
|
|
110
|
+
| false
|
|
111
|
+
| {
|
|
112
|
+
/** The site passkeys belong to, e.g. "myapp.dev" (works for every subdomain). Default: the request's hostname. */
|
|
113
|
+
rpId?: string
|
|
114
|
+
rpName?: string
|
|
115
|
+
origin?: string | string[]
|
|
116
|
+
userVerification?: 'required' | 'preferred' | 'discouraged'
|
|
117
|
+
}
|
|
118
|
+
/** "Sign in with …": github(), google(), oidc() and friends from 'slauthjs/providers'. */
|
|
119
|
+
providers?: Provider[]
|
|
120
|
+
/** Put a one-tap sign-in link in the code email too. */
|
|
121
|
+
magicLinks?: boolean
|
|
122
|
+
/** Let people sign in with a password (after proving their email once). */
|
|
123
|
+
passwords?: boolean | { minLength?: number; iterations?: number; validate?: (password: string) => string | null | Promise<string | null> }
|
|
124
|
+
/** Let people turn on an authenticator app (TOTP) with recovery codes. */
|
|
125
|
+
twoFactor?: boolean | { issuer?: string }
|
|
126
|
+
/** Short-lived JWTs for your other services, checked with /auth/jwks.json. */
|
|
127
|
+
tokens?: boolean | { minutes?: number; issuer?: string; audience?: string; claims?: (user: SlauthUser) => Record<string, unknown> | Promise<Record<string, unknown>> }
|
|
128
|
+
/** Teams with members, roles and email invitations. */
|
|
129
|
+
organizations?: boolean | { allowCreate?: boolean | ((user: SlauthUser) => boolean | Promise<boolean>); invitePage?: string }
|
|
130
|
+
/** Per-IP limits on code, password and two-factor routes; false to turn off. */
|
|
131
|
+
rateLimit?: false | { limits?: Record<string, [requests: number, seconds: number]>; ip?: (request: Request) => string | null }
|
|
132
|
+
/** Let people delete their own account at POST /auth/user/delete. Default true. */
|
|
133
|
+
allowDeletion?: boolean
|
|
134
|
+
/** Things to do when people sign up, sign in, and so on. */
|
|
135
|
+
on?: SlauthEvents
|
|
136
|
+
/** For providers' HTTP calls; handy in tests. */
|
|
137
|
+
fetch?: typeof globalThis.fetch
|
|
138
|
+
/** Also accept people signed in by Cloudflare Access. */
|
|
139
|
+
access?: { team: string; aud: string | string[]; devUser?: string; fetch?: typeof fetch }
|
|
140
|
+
/** Turn on the PIN app lock. true = off by default for each person; or set their default. */
|
|
141
|
+
lock?: true | { default?: Policy; cookie?: string }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface SessionInfo {
|
|
145
|
+
id: string
|
|
146
|
+
createdAt: number
|
|
147
|
+
lastActiveAt: number
|
|
148
|
+
method: string | null
|
|
149
|
+
userAgent: string | null
|
|
150
|
+
ip: string | null
|
|
151
|
+
impersonatorId: string | null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface LinkedAccount {
|
|
155
|
+
provider: string
|
|
156
|
+
accountId: string
|
|
157
|
+
userId: string
|
|
158
|
+
email: string | null
|
|
159
|
+
name: string | null
|
|
160
|
+
createdAt: number
|
|
161
|
+
updatedAt: number
|
|
162
|
+
tokens?: Record<string, any> & { savedAt: number }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export type OrgRole = 'owner' | 'admin' | 'member' | (string & {})
|
|
166
|
+
|
|
167
|
+
export interface Org {
|
|
168
|
+
id: string
|
|
169
|
+
name: string
|
|
170
|
+
metadata: Record<string, unknown>
|
|
171
|
+
createdAt: number
|
|
172
|
+
createdBy: string | null
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface OrgMember {
|
|
176
|
+
userId: string
|
|
177
|
+
role: OrgRole
|
|
178
|
+
joinedAt: number
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface OrgInvite {
|
|
182
|
+
id: string
|
|
183
|
+
orgId: string
|
|
184
|
+
email: string
|
|
185
|
+
role: OrgRole
|
|
186
|
+
invitedBy: string | null
|
|
187
|
+
createdAt: number
|
|
188
|
+
expiresAt: number
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface TokenClaims extends Record<string, unknown> {
|
|
192
|
+
sub: string
|
|
193
|
+
email: string
|
|
194
|
+
name: string | null
|
|
195
|
+
roles: string[]
|
|
196
|
+
iat: number
|
|
197
|
+
exp: number
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface Slauth {
|
|
201
|
+
/** Answers every request under basePath (404 for anything else). */
|
|
202
|
+
handle(request: Request): Promise<Response>
|
|
203
|
+
/** The signed-in person, or null. Ignores the app lock. Reads the cookie, a bearer session token, or (with tokens on) a JWT. */
|
|
204
|
+
user(request: Request): Promise<SlauthUser | null>
|
|
205
|
+
/** The signed-in, unlocked person (with one of these roles), or a 401/403 Response to return as it is. */
|
|
206
|
+
requireUser(request: Request, options?: { role?: string | string[] }): Promise<SlauthUser | Response>
|
|
207
|
+
/** The user and their session (null for Access or a JWT), or null. */
|
|
208
|
+
session(request: Request): Promise<{ user: SlauthUser; session: { id: string; userId: string; createdAt: number; method: string | null; impersonatorId: string | null } | null } | null>
|
|
209
|
+
store: Store
|
|
210
|
+
|
|
211
|
+
users: {
|
|
212
|
+
get(id: string): Promise<StoredUser | null>
|
|
213
|
+
byEmail(email: string): Promise<StoredUser | null>
|
|
214
|
+
/** Needs a store with list() (all the built-in ones have it). */
|
|
215
|
+
list(options?: { limit?: number; cursor?: string | null }): Promise<{ users: StoredUser[]; cursor: string | null }>
|
|
216
|
+
create(user: { email: string; name?: string | null; image?: string | null; roles?: string[]; metadata?: Record<string, unknown>; privateMetadata?: Record<string, unknown> }): Promise<StoredUser>
|
|
217
|
+
update(id: string, patch: Partial<Pick<StoredUser, 'email' | 'name' | 'image' | 'roles' | 'metadata' | 'privateMetadata'>>): Promise<StoredUser | null>
|
|
218
|
+
/** Removes the user and their sessions, passkeys, providers, password, two-factor and memberships. */
|
|
219
|
+
delete(id: string): Promise<boolean>
|
|
220
|
+
ban(id: string): Promise<StoredUser | null>
|
|
221
|
+
unban(id: string): Promise<StoredUser | null>
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
sessions: {
|
|
225
|
+
list(userId: string): Promise<SessionInfo[]>
|
|
226
|
+
revoke(userId: string, sessionId: string): Promise<boolean>
|
|
227
|
+
revokeAll(userId: string): Promise<void>
|
|
228
|
+
/** Signs someone in without asking: your own sign-in methods, tests, or impersonation. */
|
|
229
|
+
create(userId: string, request: Request, options?: { method?: string; impersonatorId?: string | null }): Promise<{ cookie: string; token: string }>
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
accounts: {
|
|
233
|
+
list(userId: string): Promise<LinkedAccount[]>
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Null unless `passwords` is on. */
|
|
237
|
+
passwords: null | {
|
|
238
|
+
set(userId: string, password: string): Promise<void>
|
|
239
|
+
remove(userId: string): Promise<void>
|
|
240
|
+
has(userId: string): Promise<boolean>
|
|
241
|
+
hash(password: string): Promise<string>
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Null unless `twoFactor` is on. */
|
|
245
|
+
twoFactor: null | {
|
|
246
|
+
enabled(userId: string): Promise<boolean>
|
|
247
|
+
/** For support: when someone has lost their phone and their recovery codes. */
|
|
248
|
+
disable(userId: string): Promise<void>
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Null unless `organizations` is on. */
|
|
252
|
+
orgs: null | {
|
|
253
|
+
create(org: { name: string; metadata?: Record<string, unknown> }, ownerId?: string): Promise<Org>
|
|
254
|
+
get(orgId: string): Promise<Org | null>
|
|
255
|
+
update(orgId: string, patch: { name?: string; metadata?: Record<string, unknown> }): Promise<Org | null>
|
|
256
|
+
delete(orgId: string): Promise<void>
|
|
257
|
+
forUser(userId: string): Promise<(Org & { role: OrgRole })[]>
|
|
258
|
+
members(orgId: string): Promise<OrgMember[]>
|
|
259
|
+
role(orgId: string, userId: string): Promise<OrgRole | null>
|
|
260
|
+
addMember(orgId: string, userId: string, role?: OrgRole): Promise<boolean>
|
|
261
|
+
setRole(orgId: string, userId: string, role: OrgRole): Promise<boolean>
|
|
262
|
+
removeMember(orgId: string, userId: string): Promise<boolean>
|
|
263
|
+
/** Saves an invitation (it doesn't email it; POST /auth/orgs/<id>/invite does both). */
|
|
264
|
+
invite(orgId: string, email: string, role?: OrgRole, invitedBy?: string | null): Promise<OrgInvite>
|
|
265
|
+
revokeInvite(inviteId: string): Promise<boolean>
|
|
266
|
+
/** The signed-in member (with one of these org roles), or a 401/403 Response to return as it is. */
|
|
267
|
+
require(request: Request, orgId: string, options?: { role?: OrgRole | OrgRole[] }): Promise<(SlauthUser & { orgRole: OrgRole }) | Response>
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Needs `tokens` on. */
|
|
271
|
+
createToken(user: SlauthUser, extra?: Record<string, unknown>): Promise<{ token: string; expiresAt: number }>
|
|
272
|
+
/** The claims of a token slauth signed that hasn't expired, or null. Needs `tokens` on. */
|
|
273
|
+
verifyToken(token: string): Promise<TokenClaims | null>
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function slauth(config: SlauthConfig): Slauth
|
|
277
|
+
export function memory(): Store
|
|
278
|
+
export function consoleEmail(): EmailSender
|
|
279
|
+
export function hashPassword(password: string, iterations?: number): Promise<string>
|
|
280
|
+
export function verifyPassword(password: string, hash: string): Promise<boolean>
|
|
281
|
+
/** The TOTP code for a base32 secret at a 30-second step (default: now). */
|
|
282
|
+
export function totp(secret: string, step?: number): Promise<string>
|
|
283
|
+
|
|
284
|
+
/** What createAccess and createLock work with. */
|
|
285
|
+
export interface User {
|
|
286
|
+
/** Lower-cased email from the Access sign-in. */
|
|
287
|
+
email: string
|
|
288
|
+
/** Stable, opaque id: the same in every app for the same email. */
|
|
289
|
+
userId: string
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export interface AccessOptions {
|
|
293
|
+
/** "yourteam" or "yourteam.cloudflareaccess.com". */
|
|
294
|
+
teamDomain: string
|
|
295
|
+
/** The Application Audience (AUD) tag of the Access application(s). */
|
|
296
|
+
audience: string | string[]
|
|
297
|
+
/** Optional second check after the Access policy. */
|
|
298
|
+
allowedEmails?: string[]
|
|
299
|
+
/** email -> userId, to keep someone's data when their email changes. */
|
|
300
|
+
aliases?: Record<string, string>
|
|
301
|
+
/** Local development only: treat every request as this email. Never set in production. */
|
|
302
|
+
devUser?: string
|
|
303
|
+
fetch?: typeof fetch
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function createAccess(options: AccessOptions): {
|
|
307
|
+
/** The signed-in person, or null when the request did not come through Access. */
|
|
308
|
+
getUser(request: Request): Promise<User | null>
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function userIdFor(email: string): Promise<string>
|
|
312
|
+
|
|
313
|
+
export type Policy = 'always' | 'daily' | 'weekly' | 'never'
|
|
314
|
+
export const POLICIES: readonly Policy[]
|
|
315
|
+
|
|
316
|
+
export interface LockDoc {
|
|
317
|
+
pinHash?: string
|
|
318
|
+
salt?: string
|
|
319
|
+
sessionKey: string
|
|
320
|
+
failures: number
|
|
321
|
+
lockedUntil: number
|
|
322
|
+
policy?: Policy
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export interface LockStore {
|
|
326
|
+
get(userId: string): Promise<LockDoc | null>
|
|
327
|
+
put(userId: string, doc: LockDoc): Promise<void>
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export interface LockReply {
|
|
331
|
+
status: number
|
|
332
|
+
body: Record<string, unknown>
|
|
333
|
+
/** A Set-Cookie header value, when the reply changes the unlock. */
|
|
334
|
+
cookie?: string
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export interface LockOptions {
|
|
338
|
+
/** Cookie name; keep an app's old one so nobody is signed out twice. Default "slauth_lock". */
|
|
339
|
+
cookie?: string
|
|
340
|
+
/** For people who haven't chosen yet. Default "weekly". */
|
|
341
|
+
defaultPolicy?: Policy
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function createLock(
|
|
345
|
+
store: LockStore,
|
|
346
|
+
options?: LockOptions,
|
|
347
|
+
): {
|
|
348
|
+
/**
|
|
349
|
+
* Routes: GET session, POST login | setup | logout | pin | policy.
|
|
350
|
+
* Returns null for any other route.
|
|
351
|
+
*/
|
|
352
|
+
handle(
|
|
353
|
+
route: string,
|
|
354
|
+
method: string,
|
|
355
|
+
readBody: () => Promise<any>,
|
|
356
|
+
user: User,
|
|
357
|
+
cookieHeader: string | null | undefined,
|
|
358
|
+
secure: boolean,
|
|
359
|
+
): Promise<LockReply | null>
|
|
360
|
+
isUnlocked(user: User, cookieHeader: string | null | undefined): Promise<boolean>
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function validPin(pin: unknown): pin is string
|
|
364
|
+
export function validPolicy(policy: unknown): policy is Policy
|
package/src/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
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'
|
|
7
|
+
// Building blocks for your own tools and tests.
|
|
8
|
+
export { hashPassword, verifyPassword } from './password.js'
|
|
9
|
+
export { totp } from './two-factor.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
|
+
}
|