mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
138 lines
5.0 KiB
138 lines
5.0 KiB
import * as client from 'openid-client'
|
|
import type { AuthFlow, AuthFlowCallback, ProviderProfile } from '../../../models/authentication.ts'
|
|
|
|
/**
|
|
* Generic OpenID Connect / OAuth2
|
|
*
|
|
* The authorization code flow with PKCE, against any provider that speaks OpenID Connect. What makes
|
|
* it OIDC rather than bare OAuth2 is the ID token: a signed statement of who signed in, which is
|
|
* verified here against the provider's published keys — issuer, audience, nonce and signature — before
|
|
* anything is believed about the person behind it.
|
|
*
|
|
* That verification is why this goes through `openid-client` rather than a handful of `fetch` calls.
|
|
* The requests themselves are trivial; the checks around them are where a mistake is silent, because
|
|
* a token that is never verified still logs somebody in.
|
|
*/
|
|
export default class OidcAuthentication {
|
|
strategyId: string
|
|
conf: Record<string, any>
|
|
/** Set by `models/authentication.ts` right after construction. */
|
|
module?: string
|
|
|
|
/**
|
|
* The provider as `openid-client` sees it. Built once and kept: with discovery on it is a network
|
|
* round trip, and it is the same answer for every login until the strategy is saved again.
|
|
*/
|
|
private config: client.Configuration | null = null
|
|
|
|
constructor(strategyId: string, conf: Record<string, any>) {
|
|
this.strategyId = strategyId
|
|
this.conf = conf
|
|
}
|
|
|
|
/**
|
|
* Resolve the provider's metadata.
|
|
*
|
|
* Discovery is the path worth taking: the endpoints AND the signing keys come from the issuer
|
|
* itself, so a provider rotating either is followed without an administrator editing anything. The
|
|
* manual path exists for providers that publish no discovery document, and needs the JWKS URL for
|
|
* the same reason — without keys there is nothing to check the ID token against.
|
|
*/
|
|
private async configuration(): Promise<client.Configuration> {
|
|
if (this.config) {
|
|
return this.config
|
|
}
|
|
const { clientId, clientSecret, issuer } = this.conf
|
|
if (!clientId || !clientSecret || !issuer) {
|
|
throw new Error('ERR_STRATEGY_MISCONFIGURED')
|
|
}
|
|
if (this.conf.useDiscovery !== false) {
|
|
this.config = await client.discovery(new URL(issuer), clientId, clientSecret)
|
|
} else {
|
|
if (!this.conf.authorizationURL || !this.conf.tokenURL || !this.conf.jwksURL) {
|
|
throw new Error('ERR_STRATEGY_MISCONFIGURED')
|
|
}
|
|
this.config = new client.Configuration(
|
|
{
|
|
issuer,
|
|
authorization_endpoint: this.conf.authorizationURL,
|
|
token_endpoint: this.conf.tokenURL,
|
|
userinfo_endpoint: this.conf.userInfoURL || undefined,
|
|
jwks_uri: this.conf.jwksURL
|
|
},
|
|
clientId,
|
|
clientSecret
|
|
)
|
|
}
|
|
return this.config
|
|
}
|
|
|
|
/** Where to send the browser to sign in. */
|
|
async authorizationUrl({ redirectUri, state, nonce, codeVerifier }: AuthFlow): Promise<string> {
|
|
const config = await this.configuration()
|
|
return client
|
|
.buildAuthorizationUrl(config, {
|
|
redirect_uri: redirectUri,
|
|
scope: this.conf.scopes || 'openid profile email',
|
|
state,
|
|
nonce,
|
|
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
|
|
code_challenge_method: 'S256'
|
|
})
|
|
.toString()
|
|
}
|
|
|
|
/**
|
|
* Turn the code the provider sent back into who signed in.
|
|
*
|
|
* `authorizationCodeGrant` is what does the checking: it refuses a response whose state does not
|
|
* match the one this flow started with, exchanges the code with the PKCE verifier, and validates
|
|
* the ID token's signature, issuer, audience and nonce. Everything after it is reading claims.
|
|
*/
|
|
async profile({
|
|
currentUrl,
|
|
state,
|
|
nonce,
|
|
codeVerifier
|
|
}: AuthFlowCallback): Promise<ProviderProfile> {
|
|
const config = await this.configuration()
|
|
const tokens = await client.authorizationCodeGrant(config, new URL(currentUrl), {
|
|
expectedState: state,
|
|
expectedNonce: nonce,
|
|
pkceCodeVerifier: codeVerifier
|
|
})
|
|
const claims = tokens.claims()
|
|
if (!claims?.sub) {
|
|
throw new Error('ERR_NO_ID_TOKEN')
|
|
}
|
|
|
|
/*
|
|
The userinfo endpoint is consulted when the provider has one, because a provider is free to keep
|
|
claims out of the ID token and behind it — several put the email address there only. Its answer
|
|
is merged over the token's, and `fetchUserInfo` checks that it is about the same subject.
|
|
*/
|
|
let info: Record<string, any> = claims
|
|
if (config.serverMetadata().userinfo_endpoint) {
|
|
info = {
|
|
...claims,
|
|
...(await client.fetchUserInfo(config, tokens.access_token, claims.sub))
|
|
}
|
|
}
|
|
|
|
const email = info[this.conf.emailClaim || 'email']
|
|
if (!email || typeof email !== 'string') {
|
|
throw new Error('ERR_NO_EMAIL_FROM_PROVIDER')
|
|
}
|
|
return {
|
|
id: claims.sub,
|
|
email,
|
|
name: (info[this.conf.displayNameClaim || 'name'] as string) || email
|
|
}
|
|
}
|
|
|
|
/** Where a logout should continue, so that the session at the provider ends too. */
|
|
logoutUrl(): string | null {
|
|
return this.conf.logoutURL || null
|
|
}
|
|
}
|