@meith/accounts 0.16.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.
@@ -0,0 +1,28 @@
1
+ import { sha256 } from 'hash-wasm'
2
+
3
+ const TOKEN_BYTES = 32
4
+
5
+ export function generateToken(): string {
6
+ const bytes = new Uint8Array(TOKEN_BYTES)
7
+ crypto.getRandomValues(bytes)
8
+ return toHex(bytes)
9
+ }
10
+
11
+ export async function hashToken(token: string): Promise<string> {
12
+ return sha256(token)
13
+ }
14
+
15
+ export function timingSafeEqual(a: string, b: string): boolean {
16
+ if (a.length !== b.length) return false
17
+ let diff = 0
18
+ for (let i = 0; i < a.length; i++) {
19
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
20
+ }
21
+ return diff === 0
22
+ }
23
+
24
+ function toHex(bytes: Uint8Array): string {
25
+ let out = ''
26
+ for (const b of bytes) out += b.toString(16).padStart(2, '0')
27
+ return out
28
+ }
@@ -0,0 +1,115 @@
1
+ import { githubProvider } from './github'
2
+ import { DEFAULT_OIDC_SCOPES, oidcProvider } from './oidc'
3
+ import type {
4
+ FederationOptions,
5
+ Fetcher,
6
+ IdentityProvider,
7
+ OidcCredentials,
8
+ ProviderCredentials,
9
+ ProviderKind,
10
+ } from './types'
11
+
12
+ export const PROVIDER_KINDS: readonly ProviderKind[] = ['github', 'google', 'oidc']
13
+
14
+ /**
15
+ * What a link is called when the provider behind it is switched off: the
16
+ * identity stays on the account, so the row still has to say something a
17
+ * member recognises rather than the key it is stored under.
18
+ */
19
+ const PROVIDER_LABELS: Readonly<Record<ProviderKind, string>> = {
20
+ github: 'GitHub',
21
+ google: 'Google',
22
+ oidc: 'Single sign-on',
23
+ }
24
+
25
+ export function providerLabel(id: string): string {
26
+ return isProviderKind(id) ? PROVIDER_LABELS[id] : id
27
+ }
28
+
29
+ const GOOGLE_ISSUER = 'https://accounts.google.com'
30
+
31
+ export interface ProviderBuildDeps {
32
+ readonly fetcher?: Fetcher
33
+ readonly clock?: () => Date
34
+ }
35
+
36
+ export function isProviderKind(value: string): value is ProviderKind {
37
+ return (PROVIDER_KINDS as readonly string[]).includes(value)
38
+ }
39
+
40
+ export function configuredProviders(
41
+ options: FederationOptions,
42
+ deps: ProviderBuildDeps = {},
43
+ ): readonly IdentityProvider[] {
44
+ return PROVIDER_KINDS.map((kind) => providerFor(kind, options, deps)).filter(
45
+ (provider): provider is IdentityProvider => provider !== null,
46
+ )
47
+ }
48
+
49
+ export function providerFor(
50
+ kind: ProviderKind,
51
+ options: FederationOptions,
52
+ deps: ProviderBuildDeps = {},
53
+ ): IdentityProvider | null {
54
+ if (kind === 'github') {
55
+ if (!usable(options.github)) return null
56
+ return githubProvider({
57
+ clientId: options.github.clientId.trim(),
58
+ clientSecret: options.github.clientSecret,
59
+ ...(deps.fetcher === undefined ? {} : { fetcher: deps.fetcher }),
60
+ })
61
+ }
62
+
63
+ if (kind === 'google') {
64
+ if (!usable(options.google)) return null
65
+ return oidcProvider({
66
+ id: 'google',
67
+ label: 'Google',
68
+ issuer: GOOGLE_ISSUER,
69
+ clientId: options.google.clientId.trim(),
70
+ clientSecret: options.google.clientSecret,
71
+ scopes: DEFAULT_OIDC_SCOPES,
72
+ ...(deps.fetcher === undefined ? {} : { fetcher: deps.fetcher }),
73
+ ...(deps.clock === undefined ? {} : { clock: deps.clock }),
74
+ })
75
+ }
76
+
77
+ if (!usable(options.oidc) || !usableIssuer(options.oidc)) return null
78
+
79
+ return oidcProvider({
80
+ id: 'oidc',
81
+ label: options.oidc.label.trim() === '' ? 'Single sign-on' : options.oidc.label.trim(),
82
+ issuer: options.oidc.issuer.trim(),
83
+ clientId: options.oidc.clientId.trim(),
84
+ clientSecret: options.oidc.clientSecret,
85
+ scopes: parseScopes(options.oidc.scopes),
86
+ ...(deps.fetcher === undefined ? {} : { fetcher: deps.fetcher }),
87
+ ...(deps.clock === undefined ? {} : { clock: deps.clock }),
88
+ })
89
+ }
90
+
91
+ export function parseScopes(raw: string): readonly string[] {
92
+ const requested = raw.split(/[\s,]+/u).filter((scope) => scope !== '')
93
+ const merged = ['openid', ...requested.filter((scope) => scope !== 'openid')]
94
+ return merged.length === 1 ? [...DEFAULT_OIDC_SCOPES] : merged
95
+ }
96
+
97
+ function usable(credentials: ProviderCredentials): boolean {
98
+ return (
99
+ credentials.enabled &&
100
+ credentials.clientId.trim() !== '' &&
101
+ credentials.clientSecret.trim() !== ''
102
+ )
103
+ }
104
+
105
+ function usableIssuer(credentials: OidcCredentials): boolean {
106
+ const issuer = credentials.issuer.trim()
107
+ if (issuer === '') return false
108
+
109
+ try {
110
+ const url = new URL(issuer)
111
+ return url.protocol === 'https:' || url.protocol === 'http:'
112
+ } catch {
113
+ return false
114
+ }
115
+ }
@@ -0,0 +1,117 @@
1
+ import { ValidationError } from '@meith/core'
2
+ import { msg } from '@meith/i18n'
3
+
4
+ import { fetchJson, readBoolean, readString } from './http'
5
+ import type {
6
+ AuthorizeInput,
7
+ ExchangeInput,
8
+ Fetcher,
9
+ IdentityProvider,
10
+ ProviderProfile,
11
+ } from './types'
12
+
13
+ const AUTHORIZE = 'https://github.com/login/oauth/authorize'
14
+ const TOKEN = 'https://github.com/login/oauth/access_token'
15
+ const USER = 'https://api.github.com/user'
16
+ const EMAILS = 'https://api.github.com/user/emails'
17
+
18
+ const SCOPES = 'read:user user:email'
19
+
20
+ export interface GithubProviderConfig {
21
+ readonly clientId: string
22
+ readonly clientSecret: string
23
+ readonly label?: string
24
+ readonly fetcher?: Fetcher
25
+ }
26
+
27
+ export function githubProvider(config: GithubProviderConfig): IdentityProvider {
28
+ const fetcher = config.fetcher ?? fetch
29
+
30
+ return {
31
+ id: 'github',
32
+ label: config.label ?? 'GitHub',
33
+
34
+ async authorizationUrl(input: AuthorizeInput): Promise<string> {
35
+ const url = new URL(AUTHORIZE)
36
+ url.searchParams.set('client_id', config.clientId)
37
+ url.searchParams.set('redirect_uri', input.redirectUri)
38
+ url.searchParams.set('scope', SCOPES)
39
+ url.searchParams.set('state', input.state)
40
+ url.searchParams.set('allow_signup', 'false')
41
+ return url.toString()
42
+ },
43
+
44
+ async exchange(input: ExchangeInput): Promise<ProviderProfile> {
45
+ const token = await fetchJson(
46
+ fetcher,
47
+ TOKEN,
48
+ {
49
+ method: 'POST',
50
+ headers: {
51
+ accept: 'application/json',
52
+ 'content-type': 'application/x-www-form-urlencoded',
53
+ },
54
+ body: new URLSearchParams({
55
+ client_id: config.clientId,
56
+ client_secret: config.clientSecret,
57
+ code: input.code,
58
+ redirect_uri: input.redirectUri,
59
+ }).toString(),
60
+ },
61
+ 'exchange the sign-in code',
62
+ )
63
+
64
+ const accessToken = readString(token, 'access_token')
65
+ if (accessToken === null) {
66
+ throw new ValidationError(msg('error.accounts.github-refused-sign-in-code-start'))
67
+ }
68
+
69
+ const headers = {
70
+ accept: 'application/vnd.github+json',
71
+ authorization: `Bearer ${accessToken}`,
72
+ 'x-github-api-version': '2022-11-28',
73
+ }
74
+
75
+ const account = await fetchJson(fetcher, USER, { headers }, 'read the account profile')
76
+
77
+ const subject = readString(account, 'id')
78
+ if (subject === null) {
79
+ throw new ValidationError(msg('error.accounts.github-sent-account-with-stable'))
80
+ }
81
+
82
+ const verified = await verifiedEmail(fetcher, headers)
83
+
84
+ return {
85
+ subject,
86
+ email: verified?.email ?? readString(account, 'email'),
87
+ emailVerified: verified !== null,
88
+ username: readString(account, 'login'),
89
+ displayName: readString(account, 'name'),
90
+ }
91
+ },
92
+ }
93
+ }
94
+
95
+ async function verifiedEmail(
96
+ fetcher: Fetcher,
97
+ headers: Record<string, string>,
98
+ ): Promise<{ email: string } | null> {
99
+ let body: unknown
100
+ try {
101
+ body = await fetchJson(fetcher, EMAILS, { headers }, 'read the account addresses')
102
+ } catch {
103
+ return null
104
+ }
105
+
106
+ if (!Array.isArray(body)) return null
107
+
108
+ const usable = body.filter(
109
+ (entry) => readBoolean(entry, 'verified') && readString(entry, 'email') !== null,
110
+ )
111
+
112
+ const chosen = usable.find((entry) => readBoolean(entry, 'primary')) ?? usable[0]
113
+ if (chosen === undefined) return null
114
+
115
+ const email = readString(chosen, 'email')
116
+ return email === null ? null : { email }
117
+ }
@@ -0,0 +1,48 @@
1
+ import { InternalError } from '@meith/core'
2
+
3
+ import type { Fetcher } from './types'
4
+
5
+ const TIMEOUT_MS = 10_000
6
+
7
+ export async function fetchJson(
8
+ fetcher: Fetcher,
9
+ url: string,
10
+ init: RequestInit,
11
+ what: string,
12
+ ): Promise<unknown> {
13
+ let response: Response
14
+ try {
15
+ response = await fetcher(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) })
16
+ } catch (cause) {
17
+ throw new InternalError(`Could not reach the identity provider to ${what}.`, {
18
+ cause,
19
+ })
20
+ }
21
+
22
+ if (!response.ok) {
23
+ throw new InternalError(`The identity provider refused to ${what} (HTTP ${response.status}).`)
24
+ }
25
+
26
+ try {
27
+ return (await response.json()) as unknown
28
+ } catch (cause) {
29
+ throw new InternalError(
30
+ `The identity provider answered with something that is not JSON when asked to ${what}.`,
31
+ { cause },
32
+ )
33
+ }
34
+ }
35
+
36
+ export function readString(source: unknown, key: string): string | null {
37
+ if (typeof source !== 'object' || source === null) return null
38
+ const value = (source as Record<string, unknown>)[key]
39
+ if (typeof value === 'string') return value.trim() === '' ? null : value
40
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value)
41
+ return null
42
+ }
43
+
44
+ export function readBoolean(source: unknown, key: string): boolean {
45
+ if (typeof source !== 'object' || source === null) return false
46
+ const value = (source as Record<string, unknown>)[key]
47
+ return value === true || value === 'true'
48
+ }
@@ -0,0 +1,198 @@
1
+ import { InternalError, ValidationError } from '@meith/core'
2
+ import { msg } from '@meith/i18n'
3
+
4
+ import { decodeBase64Url, decodeBase64UrlText } from '../crypto/base64url'
5
+ import { fetchJson } from './http'
6
+ import type { Fetcher } from './types'
7
+
8
+ interface VerifyAlgorithm {
9
+ readonly importParams: RsaHashedImportParams | EcKeyImportParams
10
+ readonly verifyParams: AlgorithmIdentifier | RsaPssParams | EcdsaParams
11
+ }
12
+
13
+ const ALGORITHMS: Readonly<Record<string, VerifyAlgorithm>> = {
14
+ RS256: {
15
+ importParams: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
16
+ verifyParams: { name: 'RSASSA-PKCS1-v1_5' },
17
+ },
18
+ RS384: {
19
+ importParams: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-384' },
20
+ verifyParams: { name: 'RSASSA-PKCS1-v1_5' },
21
+ },
22
+ RS512: {
23
+ importParams: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-512' },
24
+ verifyParams: { name: 'RSASSA-PKCS1-v1_5' },
25
+ },
26
+ PS256: {
27
+ importParams: { name: 'RSA-PSS', hash: 'SHA-256' },
28
+ verifyParams: { name: 'RSA-PSS', saltLength: 32 },
29
+ },
30
+ ES256: {
31
+ importParams: { name: 'ECDSA', namedCurve: 'P-256' },
32
+ verifyParams: { name: 'ECDSA', hash: 'SHA-256' },
33
+ },
34
+ ES384: {
35
+ importParams: { name: 'ECDSA', namedCurve: 'P-384' },
36
+ verifyParams: { name: 'ECDSA', hash: 'SHA-384' },
37
+ },
38
+ }
39
+
40
+ const CLOCK_SKEW_SECONDS = 120
41
+
42
+ export interface IdTokenClaims {
43
+ readonly [claim: string]: unknown
44
+ }
45
+
46
+ export interface VerifyIdTokenInput {
47
+ readonly token: string
48
+ readonly issuer: string
49
+ readonly audience: string
50
+ readonly nonce: string | null
51
+ readonly jwksUri: string
52
+ readonly fetcher: Fetcher
53
+ readonly now: Date
54
+ }
55
+
56
+ export async function verifyIdToken(input: VerifyIdTokenInput): Promise<IdTokenClaims> {
57
+ const parts = input.token.split('.')
58
+ if (parts.length !== 3) {
59
+ throw new ValidationError(msg('error.accounts.identity-provider-sent-token-jwt'))
60
+ }
61
+
62
+ const [encodedHeader, encodedPayload, encodedSignature] = parts as [string, string, string]
63
+ const header = parseJson(encodedHeader, 'header')
64
+ const claims = parseJson(encodedPayload, 'payload')
65
+
66
+ const algorithm = ALGORITHMS[stringClaim(header, 'alg') ?? '']
67
+ if (algorithm === undefined) {
68
+ throw new ValidationError(msg('error.accounts.identity-provider-signed-its-token'))
69
+ }
70
+
71
+ const key = await importSigningKey({
72
+ jwksUri: input.jwksUri,
73
+ fetcher: input.fetcher,
74
+ kid: stringClaim(header, 'kid'),
75
+ algorithm,
76
+ })
77
+
78
+ const signed = new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`)
79
+ const signature = decodeBase64Url(encodedSignature)
80
+
81
+ const verified = await crypto.subtle.verify(
82
+ algorithm.verifyParams,
83
+ key,
84
+ signature as unknown as BufferSource,
85
+ signed as unknown as BufferSource,
86
+ )
87
+ if (!verified) {
88
+ throw new ValidationError(msg('error.accounts.identity-provider-sent-token-with'))
89
+ }
90
+
91
+ assertClaims(claims, input)
92
+ return claims
93
+ }
94
+
95
+ function assertClaims(claims: IdTokenClaims, input: VerifyIdTokenInput): void {
96
+ const issuer = stringClaim(claims, 'iss')
97
+ if (issuer === null || !sameIssuer(issuer, input.issuer)) {
98
+ throw new ValidationError(msg('error.accounts.identity-provider-sent-token-issued'))
99
+ }
100
+
101
+ const audience = claims.aud
102
+ const audiences = Array.isArray(audience) ? audience : [audience]
103
+ if (!audiences.includes(input.audience)) {
104
+ throw new ValidationError(msg('error.accounts.identity-provider-sent-token-meant'))
105
+ }
106
+
107
+ const seconds = Math.floor(input.now.getTime() / 1000)
108
+
109
+ const expiry = numberClaim(claims, 'exp')
110
+ if (expiry === null || expiry + CLOCK_SKEW_SECONDS < seconds) {
111
+ throw new ValidationError(msg('error.accounts.identity-provider-sent-token-expired'))
112
+ }
113
+
114
+ const issuedAt = numberClaim(claims, 'iat')
115
+ if (issuedAt !== null && issuedAt - CLOCK_SKEW_SECONDS > seconds) {
116
+ throw new ValidationError(msg('error.accounts.identity-provider-sent-token-issued-2'))
117
+ }
118
+
119
+ if (input.nonce !== null && stringClaim(claims, 'nonce') !== input.nonce) {
120
+ throw new ValidationError(msg('error.accounts.identity-provider-sent-token-for'))
121
+ }
122
+ }
123
+
124
+ function sameIssuer(left: string, right: string): boolean {
125
+ return left.replace(/\/$/, '') === right.replace(/\/$/, '')
126
+ }
127
+
128
+ async function importSigningKey(input: {
129
+ readonly jwksUri: string
130
+ readonly fetcher: Fetcher
131
+ readonly kid: string | null
132
+ readonly algorithm: VerifyAlgorithm
133
+ }): Promise<CryptoKey> {
134
+ const document = await fetchJson(
135
+ input.fetcher,
136
+ input.jwksUri,
137
+ { headers: { accept: 'application/json' } },
138
+ 'fetch its signing keys',
139
+ )
140
+
141
+ const keys = (document as { keys?: unknown }).keys
142
+ if (!Array.isArray(keys)) {
143
+ throw new InternalError('The identity provider published no signing keys.')
144
+ }
145
+
146
+ const candidates = keys.filter(
147
+ (key): key is JsonWebKey & { kid?: string } => typeof key === 'object' && key !== null,
148
+ )
149
+
150
+ const matched =
151
+ input.kid === null
152
+ ? candidates
153
+ : candidates.filter((key) => key.kid === undefined || key.kid === input.kid)
154
+
155
+ for (const jwk of matched.length > 0 ? matched : candidates) {
156
+ try {
157
+ return await crypto.subtle.importKey(
158
+ 'jwk',
159
+ { ...jwk, ext: true, key_ops: ['verify'] },
160
+ input.algorithm.importParams,
161
+ false,
162
+ ['verify'],
163
+ )
164
+ } catch {}
165
+ }
166
+
167
+ throw new InternalError('None of the identity provider signing keys could verify this token.')
168
+ }
169
+
170
+ function parseJson(segment: string, what: string): IdTokenClaims {
171
+ try {
172
+ const parsed = JSON.parse(decodeBase64UrlText(segment)) as unknown
173
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
174
+ throw new Error('not an object')
175
+ }
176
+ return parsed as IdTokenClaims
177
+ } catch (cause) {
178
+ throw new ValidationError(
179
+ `The identity provider sent a token whose ${what} is unreadable.`,
180
+ {},
181
+ {
182
+ cause,
183
+ },
184
+ )
185
+ }
186
+ }
187
+
188
+ export function stringClaim(claims: IdTokenClaims, key: string): string | null {
189
+ const value = claims[key]
190
+ if (typeof value === 'string') return value.trim() === '' ? null : value
191
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value)
192
+ return null
193
+ }
194
+
195
+ function numberClaim(claims: IdTokenClaims, key: string): number | null {
196
+ const value = claims[key]
197
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
198
+ }
@@ -0,0 +1,175 @@
1
+ import { ConfigurationError, ValidationError } from '@meith/core'
2
+ import { msg } from '@meith/i18n'
3
+
4
+ import { fetchJson, readBoolean, readString } from './http'
5
+ import { type IdTokenClaims, stringClaim, verifyIdToken } from './jwt'
6
+ import { codeChallenge } from './pkce'
7
+ import type {
8
+ AuthorizeInput,
9
+ ExchangeInput,
10
+ Fetcher,
11
+ IdentityProvider,
12
+ ProviderKind,
13
+ ProviderProfile,
14
+ } from './types'
15
+
16
+ export interface OidcProviderConfig {
17
+ readonly id: ProviderKind
18
+ readonly label: string
19
+ readonly issuer: string
20
+ readonly clientId: string
21
+ readonly clientSecret: string
22
+ readonly scopes: readonly string[]
23
+ readonly fetcher?: Fetcher
24
+ readonly clock?: () => Date
25
+ }
26
+
27
+ interface Discovery {
28
+ readonly issuer: string
29
+ readonly authorizationEndpoint: string
30
+ readonly tokenEndpoint: string
31
+ readonly jwksUri: string
32
+ readonly userinfoEndpoint: string | null
33
+ }
34
+
35
+ export const DEFAULT_OIDC_SCOPES = ['openid', 'email', 'profile'] as const
36
+
37
+ export function oidcProvider(config: OidcProviderConfig): IdentityProvider {
38
+ const fetcher = config.fetcher ?? fetch
39
+ const clock = config.clock ?? (() => new Date())
40
+ const issuer = config.issuer.replace(/\/$/, '')
41
+
42
+ return {
43
+ id: config.id,
44
+ label: config.label,
45
+
46
+ async authorizationUrl(input: AuthorizeInput): Promise<string> {
47
+ const discovery = await discover(fetcher, issuer)
48
+ const url = new URL(discovery.authorizationEndpoint)
49
+
50
+ url.searchParams.set('response_type', 'code')
51
+ url.searchParams.set('client_id', config.clientId)
52
+ url.searchParams.set('redirect_uri', input.redirectUri)
53
+ url.searchParams.set('scope', config.scopes.join(' '))
54
+ url.searchParams.set('state', input.state)
55
+ url.searchParams.set('nonce', input.nonce)
56
+ url.searchParams.set('code_challenge', await codeChallenge(input.codeVerifier))
57
+ url.searchParams.set('code_challenge_method', 'S256')
58
+
59
+ return url.toString()
60
+ },
61
+
62
+ async exchange(input: ExchangeInput): Promise<ProviderProfile> {
63
+ const discovery = await discover(fetcher, issuer)
64
+
65
+ const token = await fetchJson(
66
+ fetcher,
67
+ discovery.tokenEndpoint,
68
+ {
69
+ method: 'POST',
70
+ headers: {
71
+ accept: 'application/json',
72
+ 'content-type': 'application/x-www-form-urlencoded',
73
+ },
74
+ body: new URLSearchParams({
75
+ grant_type: 'authorization_code',
76
+ code: input.code,
77
+ redirect_uri: input.redirectUri,
78
+ client_id: config.clientId,
79
+ client_secret: config.clientSecret,
80
+ code_verifier: input.codeVerifier,
81
+ }).toString(),
82
+ },
83
+ 'exchange the sign-in code',
84
+ )
85
+
86
+ const idToken = readString(token, 'id_token')
87
+ if (idToken === null) {
88
+ throw new ValidationError(msg('error.accounts.identity-provider-answered-code-exchange'))
89
+ }
90
+
91
+ const claims = await verifyIdToken({
92
+ token: idToken,
93
+ issuer: discovery.issuer,
94
+ audience: config.clientId,
95
+ nonce: input.nonce,
96
+ jwksUri: discovery.jwksUri,
97
+ fetcher,
98
+ now: clock(),
99
+ })
100
+
101
+ const accessToken = readString(token, 'access_token')
102
+ const userinfo =
103
+ discovery.userinfoEndpoint === null || accessToken === null
104
+ ? {}
105
+ : await userInfo(fetcher, discovery.userinfoEndpoint, accessToken)
106
+
107
+ return profileFrom(claims, userinfo)
108
+ },
109
+ }
110
+ }
111
+
112
+ async function userInfo(
113
+ fetcher: Fetcher,
114
+ endpoint: string,
115
+ accessToken: string,
116
+ ): Promise<IdTokenClaims> {
117
+ try {
118
+ const body = await fetchJson(
119
+ fetcher,
120
+ endpoint,
121
+ { headers: { accept: 'application/json', authorization: `Bearer ${accessToken}` } },
122
+ 'read the account profile',
123
+ )
124
+ return typeof body === 'object' && body !== null ? (body as IdTokenClaims) : {}
125
+ } catch {
126
+ return {}
127
+ }
128
+ }
129
+
130
+ function profileFrom(claims: IdTokenClaims, userinfo: IdTokenClaims): ProviderProfile {
131
+ const merged: IdTokenClaims = { ...userinfo, ...claims }
132
+
133
+ const subject = stringClaim(merged, 'sub')
134
+ if (subject === null) {
135
+ throw new ValidationError(msg('error.accounts.identity-provider-sent-account-with'))
136
+ }
137
+
138
+ return {
139
+ subject,
140
+ email: stringClaim(merged, 'email') ?? stringClaim(userinfo, 'email'),
141
+ emailVerified: readBoolean(merged, 'email_verified') || readBoolean(userinfo, 'email_verified'),
142
+ username:
143
+ stringClaim(merged, 'preferred_username') ??
144
+ stringClaim(merged, 'nickname') ??
145
+ stringClaim(userinfo, 'preferred_username'),
146
+ displayName: stringClaim(merged, 'name') ?? stringClaim(userinfo, 'name'),
147
+ }
148
+ }
149
+
150
+ async function discover(fetcher: Fetcher, issuer: string): Promise<Discovery> {
151
+ const document = await fetchJson(
152
+ fetcher,
153
+ `${issuer}/.well-known/openid-configuration`,
154
+ { headers: { accept: 'application/json' } },
155
+ 'read its OpenID configuration',
156
+ )
157
+
158
+ const authorizationEndpoint = readString(document, 'authorization_endpoint')
159
+ const tokenEndpoint = readString(document, 'token_endpoint')
160
+ const jwksUri = readString(document, 'jwks_uri')
161
+
162
+ if (authorizationEndpoint === null || tokenEndpoint === null || jwksUri === null) {
163
+ throw new ConfigurationError(
164
+ `The OpenID configuration at ${issuer} is missing an endpoint this board needs.`,
165
+ )
166
+ }
167
+
168
+ return {
169
+ issuer: readString(document, 'issuer') ?? issuer,
170
+ authorizationEndpoint,
171
+ tokenEndpoint,
172
+ jwksUri,
173
+ userinfoEndpoint: readString(document, 'userinfo_endpoint'),
174
+ }
175
+ }
@@ -0,0 +1,20 @@
1
+ import { encodeBase64Url, randomBase64Url } from '../crypto/base64url'
2
+
3
+ export interface HandshakeSecrets {
4
+ readonly state: string
5
+ readonly nonce: string
6
+ readonly codeVerifier: string
7
+ }
8
+
9
+ export function newHandshake(): HandshakeSecrets {
10
+ return {
11
+ state: randomBase64Url(32),
12
+ nonce: randomBase64Url(32),
13
+ codeVerifier: randomBase64Url(32),
14
+ }
15
+ }
16
+
17
+ export async function codeChallenge(verifier: string): Promise<string> {
18
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
19
+ return encodeBase64Url(new Uint8Array(digest))
20
+ }