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/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
- /** Sends the sign-in email. `subject`, `text` and `html` are ready to use. */
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; code: string; appName: string; subject: string; text: string; html: string }): Promise<void>
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
+ }