slauthjs 1.0.0 → 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/README.md +46 -9
- package/package.json +31 -7
- package/src/account-element.d.ts +8 -0
- package/src/account-element.js +244 -0
- package/src/client.d.ts +86 -7
- package/src/client.js +99 -6
- package/src/core.js +313 -182
- package/src/element.js +161 -59
- package/src/email/console.js +5 -4
- package/src/email/message.js +50 -10
- package/src/email-code.js +32 -18
- package/src/index.d.ts +221 -6
- package/src/index.js +3 -0
- package/src/oauth/flow.js +81 -0
- package/src/oauth/providers.js +171 -0
- package/src/orgs.js +184 -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 +98 -16
- package/src/stores/d1.js +14 -0
- package/src/stores/index.d.ts +2 -2
- package/src/stores/kv.js +9 -0
- package/src/stores/memory.js +5 -0
- package/src/stores/sqlite.js +8 -1
- package/src/tokens.js +119 -0
- package/src/two-factor.js +142 -0
- package/src/ui.js +49 -0
- package/src/users.js +92 -22
- package/src/util.js +37 -0
package/src/index.d.ts
CHANGED
|
@@ -4,19 +4,80 @@ export interface Store {
|
|
|
4
4
|
/** ttl is in seconds. */
|
|
5
5
|
put(key: string, value: unknown, options?: { ttl?: number }): Promise<void>
|
|
6
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 }>
|
|
7
9
|
}
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
export type EmailKind = 'sign-in' | 'email-change' | 'invite'
|
|
12
|
+
|
|
13
|
+
/** Sends slauth's emails. `subject`, `text` and `html` are ready to use. */
|
|
10
14
|
export interface EmailSender {
|
|
11
|
-
send(message: { to: string;
|
|
15
|
+
send(message: { to: string; kind: EmailKind; code?: string; link?: string; appName: string; subject: string; text: string; html: string }): Promise<void>
|
|
12
16
|
}
|
|
13
17
|
|
|
18
|
+
/** What the browser, auth.user() and your event handlers see. */
|
|
14
19
|
export interface SlauthUser {
|
|
15
20
|
id: string
|
|
16
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>
|
|
17
28
|
createdAt: number
|
|
18
29
|
}
|
|
19
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
|
+
|
|
20
81
|
export interface SlauthConfig {
|
|
21
82
|
/** A random string of 16+ characters. Make one with: openssl rand -base64 32 */
|
|
22
83
|
secret: string
|
|
@@ -24,16 +85,24 @@ export interface SlauthConfig {
|
|
|
24
85
|
store: Store
|
|
25
86
|
/** How codes are emailed. Defaults to printing them to the console. */
|
|
26
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>
|
|
27
90
|
/** Shown in emails and as the passkey's site name. */
|
|
28
91
|
appName?: string
|
|
29
92
|
/** 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>)
|
|
93
|
+
allow?: string[] | ((email: string, context: SignInContext) => boolean | Promise<boolean>)
|
|
31
94
|
/** Where the routes live. Default "/auth". */
|
|
32
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
|
|
33
100
|
/** Session cookie name. Default "slauth_session". */
|
|
34
101
|
cookie?: string
|
|
35
102
|
/** Sessions last this many days after the last visit. Default 30. */
|
|
36
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
|
|
37
106
|
/** Origins allowed to post to the routes (and to use passkeys). Default: the request's own origin. */
|
|
38
107
|
origins?: string | string[]
|
|
39
108
|
/** Passkey settings, or false to turn passkeys off. */
|
|
@@ -46,25 +115,171 @@ export interface SlauthConfig {
|
|
|
46
115
|
origin?: string | string[]
|
|
47
116
|
userVerification?: 'required' | 'preferred' | 'discouraged'
|
|
48
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
|
|
49
138
|
/** Also accept people signed in by Cloudflare Access. */
|
|
50
139
|
access?: { team: string; aud: string | string[]; devUser?: string; fetch?: typeof fetch }
|
|
51
140
|
/** Turn on the PIN app lock. true = off by default for each person; or set their default. */
|
|
52
141
|
lock?: true | { default?: Policy; cookie?: string }
|
|
53
142
|
}
|
|
54
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
|
+
|
|
55
200
|
export interface Slauth {
|
|
56
201
|
/** Answers every request under basePath (404 for anything else). */
|
|
57
202
|
handle(request: Request): Promise<Response>
|
|
58
|
-
/** The signed-in person, or null. Ignores the app lock. */
|
|
203
|
+
/** The signed-in person, or null. Ignores the app lock. Reads the cookie, a bearer session token, or (with tokens on) a JWT. */
|
|
59
204
|
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>
|
|
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>
|
|
62
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>
|
|
63
274
|
}
|
|
64
275
|
|
|
65
276
|
export function slauth(config: SlauthConfig): Slauth
|
|
66
277
|
export function memory(): Store
|
|
67
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>
|
|
68
283
|
|
|
69
284
|
/** What createAccess and createLock work with. */
|
|
70
285
|
export interface User {
|
package/src/index.js
CHANGED
|
@@ -4,3 +4,6 @@ export { consoleEmail } from './email/console.js'
|
|
|
4
4
|
// The Cloudflare Access check and the PIN lock, usable on their own too.
|
|
5
5
|
export { createAccess, userIdFor } from './access.js'
|
|
6
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'
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { base64Url, randomToken, readCookie, serializeCookie, sha256 } from '../util.js'
|
|
2
|
+
|
|
3
|
+
const STATE_SECONDS = 10 * 60
|
|
4
|
+
const COOKIE = 'slauth_oauth'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The OAuth 2.0 authorization-code flow with PKCE (RFC 7636) and a `state` that's tied
|
|
8
|
+
* to this browser by a short-lived cookie, so nobody can finish a sign-in someone else
|
|
9
|
+
* started (login CSRF).
|
|
10
|
+
*
|
|
11
|
+
* oauth:<state> { provider, verifier, redirect, returnTo, linkUserId } (lives 10 minutes)
|
|
12
|
+
*
|
|
13
|
+
* @param {{ store: import('../index.js').Store, fetch?: typeof fetch }} options
|
|
14
|
+
*/
|
|
15
|
+
export function createOAuthFlow({ store, fetch: doFetch = (...args) => fetch(...args) }) {
|
|
16
|
+
async function endpoints(provider) {
|
|
17
|
+
if (!provider.discover) return { authorization: provider.authorization, token: provider.token }
|
|
18
|
+
const config = await provider.discover(doFetch)
|
|
19
|
+
return { authorization: config.authorization_endpoint, token: config.token_endpoint }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
fetch: doFetch,
|
|
24
|
+
|
|
25
|
+
/** Where to send the browser, and the cookie that ties the flow to it. */
|
|
26
|
+
async start(provider, { callbackUrl, redirect, returnTo, linkUserId = null, secure }) {
|
|
27
|
+
const state = randomToken()
|
|
28
|
+
const verifier = randomToken()
|
|
29
|
+
await store.put(`oauth:${state}`, { provider: provider.id, verifier, redirect, returnTo, linkUserId }, { ttl: STATE_SECONDS })
|
|
30
|
+
const url = new URL((await endpoints(provider)).authorization)
|
|
31
|
+
url.searchParams.set('response_type', 'code')
|
|
32
|
+
url.searchParams.set('client_id', provider.clientId)
|
|
33
|
+
url.searchParams.set('redirect_uri', callbackUrl)
|
|
34
|
+
url.searchParams.set('state', state)
|
|
35
|
+
if (provider.scope) url.searchParams.set('scope', provider.scope)
|
|
36
|
+
if (provider.pkce) {
|
|
37
|
+
url.searchParams.set('code_challenge', base64Url(await sha256(verifier)))
|
|
38
|
+
url.searchParams.set('code_challenge_method', 'S256')
|
|
39
|
+
}
|
|
40
|
+
for (const [key, value] of Object.entries(provider.params ?? {})) url.searchParams.set(key, value)
|
|
41
|
+
return { location: url.toString(), cookie: serializeCookie(COOKIE, state, { maxAge: STATE_SECONDS, secure }) }
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Finishes the flow: checks state, swaps the code for tokens, reads the profile.
|
|
46
|
+
* @returns {Promise<{ ok: true, profile: object, tokens: object, state: object } | { ok: false, code: string, state?: object }>}
|
|
47
|
+
*/
|
|
48
|
+
async finish(provider, request, { callbackUrl }) {
|
|
49
|
+
const url = new URL(request.url)
|
|
50
|
+
const state = url.searchParams.get('state')
|
|
51
|
+
const fromCookie = readCookie(request.headers.get('cookie'), COOKIE)
|
|
52
|
+
const saved = state ? await store.get(`oauth:${state}`) : null
|
|
53
|
+
if (state) await store.delete(`oauth:${state}`)
|
|
54
|
+
if (!saved || saved.provider !== provider.id || !fromCookie || fromCookie !== state) return { ok: false, code: 'oauth-state' }
|
|
55
|
+
if (url.searchParams.get('error')) return { ok: false, code: url.searchParams.get('error') === 'access_denied' ? 'oauth-cancelled' : 'oauth-failed', state: saved }
|
|
56
|
+
const code = url.searchParams.get('code')
|
|
57
|
+
if (!code) return { ok: false, code: 'oauth-failed', state: saved }
|
|
58
|
+
|
|
59
|
+
const body = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: callbackUrl })
|
|
60
|
+
if (provider.pkce) body.set('code_verifier', saved.verifier)
|
|
61
|
+
const headers = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }
|
|
62
|
+
if (provider.tokenAuth === 'basic') headers.Authorization = `Basic ${btoa(`${encodeURIComponent(provider.clientId)}:${encodeURIComponent(provider.clientSecret ?? '')}`)}`
|
|
63
|
+
else {
|
|
64
|
+
body.set('client_id', provider.clientId)
|
|
65
|
+
if (provider.clientSecret) body.set('client_secret', provider.clientSecret)
|
|
66
|
+
}
|
|
67
|
+
const res = await doFetch((await endpoints(provider)).token, { method: 'POST', headers, body })
|
|
68
|
+
const tokens = await res.json().catch(() => ({}))
|
|
69
|
+
if (!res.ok || !tokens.access_token) {
|
|
70
|
+
console.error(`slauth: ${provider.id} wouldn't swap the code for a token:`, res.status, tokens.error ?? '')
|
|
71
|
+
return { ok: false, code: 'oauth-failed', state: saved }
|
|
72
|
+
}
|
|
73
|
+
const profile = await provider.profile(tokens, doFetch)
|
|
74
|
+
if (!profile?.id) return { ok: false, code: 'oauth-failed', state: saved }
|
|
75
|
+
if (provider.trustEmail && profile.email) profile.emailVerified = true
|
|
76
|
+
return { ok: true, profile, tokens, state: saved }
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
clearCookie: (secure) => serializeCookie(COOKIE, '', { maxAge: 0, secure }),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Sign in with …" providers. Each is a plain object, so adding your own is just
|
|
3
|
+
* oauth2({ ... }) or oidc({ ... }):
|
|
4
|
+
*
|
|
5
|
+
* import { github, google } from 'slauthjs/providers'
|
|
6
|
+
* slauth({ ..., providers: [github({ clientId, clientSecret }), google({ clientId, clientSecret })] })
|
|
7
|
+
*
|
|
8
|
+
* The callback URL to register with each provider is <your site>/auth/oauth/<id>/callback.
|
|
9
|
+
*
|
|
10
|
+
* A provider's `profile(tokens, fetch)` returns { id, email, emailVerified, name, image }.
|
|
11
|
+
* slauth only signs someone in by email when the provider says the email is verified,
|
|
12
|
+
* so nobody can claim your account by making a provider account with your address.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Any OAuth 2.0 provider.
|
|
17
|
+
*
|
|
18
|
+
* @param {{
|
|
19
|
+
* id: string, name: string, clientId: string, clientSecret?: string,
|
|
20
|
+
* authorization: string, token: string, scope?: string, params?: Record<string, string>,
|
|
21
|
+
* pkce?: boolean, tokenAuth?: 'post' | 'basic',
|
|
22
|
+
* profile(tokens: Record<string, any>, fetch: typeof fetch): Promise<{ id: string, email?: string | null, emailVerified?: boolean, name?: string | null, image?: string | null }>,
|
|
23
|
+
* trustEmail?: boolean, keepTokens?: boolean,
|
|
24
|
+
* }} options
|
|
25
|
+
*/
|
|
26
|
+
export function oauth2(options) {
|
|
27
|
+
for (const key of ['id', 'clientId', 'authorization', 'token', 'profile']) {
|
|
28
|
+
if (!options?.[key]) throw new Error(`slauth: the ${options?.id ?? 'OAuth'} provider needs \`${key}\`.`)
|
|
29
|
+
}
|
|
30
|
+
if (!/^[a-z0-9-]+$/.test(options.id)) throw new Error(`slauth: provider ids are lower-case letters, digits and dashes, not "${options.id}".`)
|
|
31
|
+
return { type: 'oauth2', name: options.id, pkce: true, tokenAuth: 'post', scope: '', params: {}, ...options }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Any OpenID Connect provider, found from its issuer's discovery document. The profile
|
|
36
|
+
* comes from the userinfo endpoint, using the access token we just got over TLS.
|
|
37
|
+
*
|
|
38
|
+
* @param {{ id: string, name: string, issuer: string, clientId: string, clientSecret?: string, scope?: string, params?: Record<string, string>, trustEmail?: boolean, keepTokens?: boolean, tokenAuth?: 'post' | 'basic' }} options
|
|
39
|
+
*/
|
|
40
|
+
export function oidc({ issuer, scope = 'openid email profile', ...options }) {
|
|
41
|
+
if (!issuer) throw new Error(`slauth: the ${options.id ?? 'OIDC'} provider needs \`issuer\`.`)
|
|
42
|
+
let discovered = null
|
|
43
|
+
const discover = (doFetch) =>
|
|
44
|
+
(discovered ??= doFetch(`${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`)
|
|
45
|
+
.then((res) => {
|
|
46
|
+
if (!res.ok) throw new Error(`slauth: couldn't read ${issuer}'s OpenID configuration (${res.status}).`)
|
|
47
|
+
return res.json()
|
|
48
|
+
})
|
|
49
|
+
.catch((error) => {
|
|
50
|
+
discovered = null
|
|
51
|
+
throw error
|
|
52
|
+
}))
|
|
53
|
+
return oauth2({
|
|
54
|
+
// Filled in from discovery when the flow starts (see flow.js).
|
|
55
|
+
authorization: 'discover',
|
|
56
|
+
token: 'discover',
|
|
57
|
+
discover,
|
|
58
|
+
scope,
|
|
59
|
+
async profile(tokens, doFetch) {
|
|
60
|
+
const config = await discover(doFetch)
|
|
61
|
+
const info = await getJson(doFetch, config.userinfo_endpoint, tokens.access_token)
|
|
62
|
+
return { id: String(info.sub), email: info.email ?? null, emailVerified: info.email_verified === true || info.email_verified === 'true', name: info.name ?? null, image: info.picture ?? null }
|
|
63
|
+
},
|
|
64
|
+
...options,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** GitHub. Make an OAuth app at github.com/settings/developers. */
|
|
69
|
+
export function github({ clientId, clientSecret, scope = 'read:user user:email', ...rest }) {
|
|
70
|
+
return oauth2({
|
|
71
|
+
id: 'github',
|
|
72
|
+
name: 'GitHub',
|
|
73
|
+
clientId,
|
|
74
|
+
clientSecret,
|
|
75
|
+
scope,
|
|
76
|
+
authorization: 'https://github.com/login/oauth/authorize',
|
|
77
|
+
token: 'https://github.com/login/oauth/access_token',
|
|
78
|
+
async profile(tokens, doFetch) {
|
|
79
|
+
const me = await getJson(doFetch, 'https://api.github.com/user', tokens.access_token)
|
|
80
|
+
// The profile email can be hidden or unverified; /user/emails says which is which.
|
|
81
|
+
const emails = await getJson(doFetch, 'https://api.github.com/user/emails', tokens.access_token).catch(() => [])
|
|
82
|
+
const primary = emails.find((e) => e.primary && e.verified) ?? emails.find((e) => e.verified)
|
|
83
|
+
return { id: String(me.id), email: primary?.email ?? null, emailVerified: Boolean(primary), name: me.name || me.login, image: me.avatar_url ?? null }
|
|
84
|
+
},
|
|
85
|
+
...rest,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Google. Make an OAuth client at console.cloud.google.com/apis/credentials. */
|
|
90
|
+
export function google({ clientId, clientSecret, scope = 'openid email profile', ...rest }) {
|
|
91
|
+
return oauth2({
|
|
92
|
+
id: 'google',
|
|
93
|
+
name: 'Google',
|
|
94
|
+
clientId,
|
|
95
|
+
clientSecret,
|
|
96
|
+
scope,
|
|
97
|
+
authorization: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
98
|
+
token: 'https://oauth2.googleapis.com/token',
|
|
99
|
+
params: { prompt: 'select_account' },
|
|
100
|
+
async profile(tokens, doFetch) {
|
|
101
|
+
const info = await getJson(doFetch, 'https://openidconnect.googleapis.com/v1/userinfo', tokens.access_token)
|
|
102
|
+
return { id: String(info.sub), email: info.email ?? null, emailVerified: info.email_verified === true, name: info.name ?? null, image: info.picture ?? null }
|
|
103
|
+
},
|
|
104
|
+
...rest,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Discord. Make an application at discord.com/developers/applications. */
|
|
109
|
+
export function discord({ clientId, clientSecret, scope = 'identify email', ...rest }) {
|
|
110
|
+
return oauth2({
|
|
111
|
+
id: 'discord',
|
|
112
|
+
name: 'Discord',
|
|
113
|
+
clientId,
|
|
114
|
+
clientSecret,
|
|
115
|
+
scope,
|
|
116
|
+
authorization: 'https://discord.com/oauth2/authorize',
|
|
117
|
+
token: 'https://discord.com/api/oauth2/token',
|
|
118
|
+
async profile(tokens, doFetch) {
|
|
119
|
+
const me = await getJson(doFetch, 'https://discord.com/api/users/@me', tokens.access_token)
|
|
120
|
+
const image = me.avatar ? `https://cdn.discordapp.com/avatars/${me.id}/${me.avatar}.png` : null
|
|
121
|
+
return { id: String(me.id), email: me.email ?? null, emailVerified: me.verified === true, name: me.global_name ?? me.username, image }
|
|
122
|
+
},
|
|
123
|
+
...rest,
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** GitLab (gitlab.com, or your own with `baseUrl`). */
|
|
128
|
+
export function gitlab({ clientId, clientSecret, baseUrl = 'https://gitlab.com', ...rest }) {
|
|
129
|
+
return oidc({ id: 'gitlab', name: 'GitLab', issuer: baseUrl, clientId, clientSecret, scope: 'openid email profile', ...rest })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Microsoft (personal accounts, and work or school accounts with `tenant`). Microsoft
|
|
134
|
+
* doesn't promise a work account's email belongs to the person, so by default only
|
|
135
|
+
* personal accounts' emails count as verified. With a single `tenant` you control,
|
|
136
|
+
* set trustEmail: true.
|
|
137
|
+
*/
|
|
138
|
+
export function microsoft({ clientId, clientSecret, tenant = 'common', ...rest }) {
|
|
139
|
+
const CONSUMERS = '9188040d-6c67-4c5b-b112-36a304b66dad'
|
|
140
|
+
return oauth2({
|
|
141
|
+
id: 'microsoft',
|
|
142
|
+
name: 'Microsoft',
|
|
143
|
+
clientId,
|
|
144
|
+
clientSecret,
|
|
145
|
+
scope: 'openid email profile User.Read',
|
|
146
|
+
authorization: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`,
|
|
147
|
+
token: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
|
|
148
|
+
async profile(tokens, doFetch) {
|
|
149
|
+
const info = await getJson(doFetch, 'https://graph.microsoft.com/oidc/userinfo', tokens.access_token)
|
|
150
|
+
const claims = decodeJwtPayload(tokens.id_token)
|
|
151
|
+
return { id: String(info.sub), email: info.email ?? null, emailVerified: claims?.tid === CONSUMERS, name: info.name ?? null, image: null }
|
|
152
|
+
},
|
|
153
|
+
...rest,
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function getJson(doFetch, url, accessToken) {
|
|
158
|
+
const res = await doFetch(url, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', 'User-Agent': 'slauth' } })
|
|
159
|
+
if (!res.ok) throw new Error(`slauth: ${new URL(url).host} said ${res.status} when asked for the profile.`)
|
|
160
|
+
return res.json()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Reads (doesn't check) a JWT's claims; only for tokens we got straight from the provider. */
|
|
164
|
+
function decodeJwtPayload(token) {
|
|
165
|
+
try {
|
|
166
|
+
const part = String(token).split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
|
|
167
|
+
return JSON.parse(atob(part + '='.repeat((4 - (part.length % 4)) % 4)))
|
|
168
|
+
} catch {
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
}
|