@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.
- package/LICENSE.md +165 -0
- package/package.json +25 -0
- package/src/ban-filter.ts +127 -0
- package/src/ban-service.ts +71 -0
- package/src/case-fold.ts +3 -0
- package/src/credential-proof.ts +17 -0
- package/src/crypto/base64url.ts +53 -0
- package/src/crypto/legacy.ts +127 -0
- package/src/crypto/password.ts +92 -0
- package/src/crypto/tokens.ts +28 -0
- package/src/federation/catalog.ts +115 -0
- package/src/federation/github.ts +117 -0
- package/src/federation/http.ts +48 -0
- package/src/federation/jwt.ts +198 -0
- package/src/federation/oidc.ts +175 -0
- package/src/federation/pkce.ts +20 -0
- package/src/federation/service.ts +198 -0
- package/src/federation/types.ts +50 -0
- package/src/index.ts +223 -0
- package/src/member-settings.ts +390 -0
- package/src/memory-bans.ts +88 -0
- package/src/memory-repos.ts +665 -0
- package/src/policy.ts +104 -0
- package/src/ports.ts +415 -0
- package/src/register-fields.ts +17 -0
- package/src/service.ts +684 -0
- package/src/session-service.ts +134 -0
- package/src/test-support.fixture.ts +8 -0
- package/src/totp/base32.ts +52 -0
- package/src/totp/secret-box.ts +83 -0
- package/src/totp/service.ts +252 -0
- package/src/totp/totp.ts +120 -0
- package/src/webauthn/authenticator.fixture.ts +268 -0
- package/src/webauthn/cbor.ts +110 -0
- package/src/webauthn/cose.ts +130 -0
- package/src/webauthn/service.ts +266 -0
- package/src/webauthn/verify.ts +231 -0
|
@@ -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
|
+
}
|