mirror of
https://github.com/hanzoai/openid-client.git
synced 2026-09-24 17:01:10 +00:00
629 lines
17 KiB
TypeScript
629 lines
17 KiB
TypeScript
import anyTest from 'ava'
|
|
import type { ExecutionContext } from 'ava'
|
|
import type { Macro, TestFn } from 'ava'
|
|
import { importJWK, type JWK } from 'jose'
|
|
import * as undici from 'undici'
|
|
import { inspect } from 'node:util'
|
|
|
|
export const test = anyTest as TestFn<{ instance: Test }>
|
|
|
|
import { getScope, makePublicJwks } from './ava.config.js'
|
|
import * as client from '../src/index.js'
|
|
import {
|
|
createTestFromPlan,
|
|
waitForState,
|
|
getTestExposed,
|
|
type ModulePrescription,
|
|
type Plan,
|
|
type Test,
|
|
} from './api.js'
|
|
|
|
const conformance = JSON.parse(process.env.CONFORMANCE!)
|
|
|
|
const configuration: {
|
|
alias: string
|
|
client: {
|
|
client_id: string
|
|
client_secret?: string
|
|
redirect_uri: string
|
|
use_mtls_endpoint_aliases: boolean
|
|
jwks: {
|
|
keys: Array<JWK & { kid: string }>
|
|
}
|
|
}
|
|
client2: {
|
|
jwks: {
|
|
keys: Array<JWK & { kid: string }>
|
|
}
|
|
}
|
|
} = conformance.configuration
|
|
|
|
const ALG = conformance.ALG as string
|
|
export const plan: Plan = conformance.plan
|
|
export const variant: Record<string, string> = conformance.variant
|
|
export const mtls: { key: string; cert: string } = conformance.mtls || {}
|
|
|
|
let prefix = ''
|
|
|
|
switch (plan.name) {
|
|
case 'fapi1-advanced-final-client-test-plan':
|
|
case 'fapi2-security-profile-final-client-test-plan':
|
|
prefix = plan.name.slice(0, -4)
|
|
break
|
|
case 'fapi2-message-signing-final-client-test-plan':
|
|
prefix = 'fapi2-security-profile-final-client-test-'
|
|
break
|
|
case 'oidcc-client-test-plan':
|
|
case 'oidcc-client-basic-certification-test-plan':
|
|
case 'oidcc-client-implicit-certification-test-plan':
|
|
case 'oidcc-client-hybrid-certification-test-plan':
|
|
prefix = 'oidcc-client-test-'
|
|
break
|
|
default:
|
|
throw new Error()
|
|
}
|
|
|
|
async function importPrivateKey(alg: string, jwk: JWK) {
|
|
const key = await importJWK(jwk, alg)
|
|
if (!('type' in key)) {
|
|
throw new Error()
|
|
}
|
|
return key
|
|
}
|
|
|
|
export function modules(metaUrl: string): ModulePrescription[] {
|
|
const name = metaUrl.split('/').reverse()[0].replace('.ts', '')
|
|
return conformance.plan.modules.filter((x: ModulePrescription) => {
|
|
switch (x.variant?.response_type) {
|
|
case 'id_token token':
|
|
case 'code token':
|
|
case 'code id_token token':
|
|
return false
|
|
}
|
|
|
|
return (
|
|
x.testModule ===
|
|
(name === prefix.slice(0, -1) ? name : `${prefix}${name}`)
|
|
)
|
|
})
|
|
}
|
|
|
|
function usesJarm(variant: Record<string, string>) {
|
|
return variant.fapi_response_mode === 'jarm'
|
|
}
|
|
|
|
function usesDpop(variant: Record<string, string>) {
|
|
return variant.sender_constrain === 'dpop'
|
|
}
|
|
|
|
function usesPar(plan: Plan) {
|
|
return (
|
|
plan.name.startsWith('fapi2') ||
|
|
variant.fapi_auth_request_method === 'pushed'
|
|
)
|
|
}
|
|
|
|
export function nonRepudiation(plan: Plan) {
|
|
return (
|
|
plan.name.startsWith('fapi2-message-signing') ||
|
|
plan.name.startsWith('fapi1')
|
|
)
|
|
}
|
|
|
|
function usesRequestObject(planName: string, variant: Record<string, string>) {
|
|
if (planName.startsWith('fapi1')) {
|
|
return true
|
|
}
|
|
|
|
if (planName.startsWith('fapi2-message-signing')) {
|
|
return true
|
|
}
|
|
|
|
if (variant.request_type === 'request_object') {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
function requiresNonce(planName: string, variant: Record<string, string>) {
|
|
return (
|
|
responseType(planName, variant).includes('id_token') ||
|
|
(planName.startsWith('fapi1') && getScope(variant).includes('openid'))
|
|
)
|
|
}
|
|
|
|
function requiresState(planName: string, variant: Record<string, string>) {
|
|
return planName.startsWith('fapi1') && !getScope(variant).includes('openid')
|
|
}
|
|
|
|
function responseType(planName: string, variant: Record<string, string>) {
|
|
if (variant.response_type) {
|
|
return variant.response_type
|
|
}
|
|
|
|
if (!planName.startsWith('fapi1')) {
|
|
return 'code'
|
|
}
|
|
|
|
return variant.fapi_response_mode === 'jarm' ? 'code' : 'code id_token'
|
|
}
|
|
|
|
function dcr(variant: Record<string, string>) {
|
|
return variant.client_registration === 'dynamic_client'
|
|
}
|
|
|
|
export interface MacroOptions {
|
|
useNonce?: boolean
|
|
useState?: boolean
|
|
}
|
|
|
|
export const flow = (options?: MacroOptions) => {
|
|
return test.macro({
|
|
async exec(t, module: ModulePrescription) {
|
|
t.timeout(15000)
|
|
|
|
const instance = await createTestFromPlan(plan, module)
|
|
t.context.instance = instance
|
|
|
|
t.log('Test ID', instance.id)
|
|
t.log('Test Name', instance.name)
|
|
|
|
const variant: Record<string, string> = {
|
|
...conformance.variant,
|
|
...module.variant,
|
|
}
|
|
t.log('variant', variant)
|
|
|
|
const { issuer: issuerIdentifier, accounts_endpoint } =
|
|
await getTestExposed(instance)
|
|
|
|
if (!issuerIdentifier) {
|
|
throw new Error()
|
|
}
|
|
|
|
const issuer = new URL(issuerIdentifier)
|
|
|
|
const response_type = responseType(plan.name, variant)
|
|
const metadata: client.ClientMetadata = makePublicJwks({
|
|
client_id: dcr(variant) ? undefined : configuration.client.client_id,
|
|
client_secret: dcr(variant)
|
|
? undefined
|
|
: configuration.client.client_secret,
|
|
use_mtls_endpoint_aliases:
|
|
configuration.client.use_mtls_endpoint_aliases,
|
|
jwks: configuration.client.jwks,
|
|
redirect_uris: [configuration.client.redirect_uri],
|
|
response_types: [response_type],
|
|
grant_types:
|
|
response_type === 'code'
|
|
? ['authorization_code']
|
|
: ['authorization_code', 'implicit'],
|
|
})
|
|
|
|
switch (variant.client_auth_type) {
|
|
case 'mtls':
|
|
metadata.token_endpoint_auth_method = 'self_signed_tls_client_auth'
|
|
break
|
|
case 'none':
|
|
case 'private_key_jwt':
|
|
case 'client_secret_basic':
|
|
case 'client_secret_post':
|
|
metadata.token_endpoint_auth_method = variant.client_auth_type
|
|
break
|
|
}
|
|
|
|
if (instance.name.includes('client-secret-basic')) {
|
|
metadata.token_endpoint_auth_method = 'client_secret_basic'
|
|
}
|
|
|
|
// @ts-expect-error
|
|
const mtlsFetch: typeof fetch = async (
|
|
...args: Parameters<typeof fetch>
|
|
) => {
|
|
// @ts-expect-error
|
|
let response = await undici.fetch(args[0], {
|
|
...args[1],
|
|
dispatcher: new undici.Agent({
|
|
connect: {
|
|
key: mtls.key,
|
|
cert: mtls.cert,
|
|
},
|
|
}),
|
|
})
|
|
|
|
if (dcr(variant)) {
|
|
// TODO: remove when https://gitlab.com/openid/conformance-suite/-/issues/1503 is fixed
|
|
if (response.ok && (args[0] as string).endsWith('/register')) {
|
|
const body = (await response.json()) as any
|
|
if (body.client_secret) {
|
|
body.client_secret_expires_at = 0
|
|
}
|
|
// @ts-expect-error
|
|
response = new Response(JSON.stringify(body), response)
|
|
}
|
|
}
|
|
|
|
return response
|
|
}
|
|
|
|
let dcrFetch: typeof fetch | undefined
|
|
if (dcr(variant)) {
|
|
// TODO: remove when https://gitlab.com/openid/conformance-suite/-/issues/1503 is fixed
|
|
dcrFetch = async (...args: Parameters<typeof fetch>) => {
|
|
let response = await fetch(...args)
|
|
if (response.ok && (args[0] as string).endsWith('/register')) {
|
|
const body = await response.json()
|
|
if (body.client_secret) {
|
|
body.client_secret_expires_at = 0
|
|
}
|
|
response = new Response(JSON.stringify(body), response)
|
|
}
|
|
return response
|
|
}
|
|
}
|
|
|
|
const mtlsAuth = variant.client_auth_type === 'mtls'
|
|
const mtlsConstrain =
|
|
plan.name.startsWith('fapi1') || variant.sender_constrain === 'mtls'
|
|
|
|
const execute: Array<(config: client.Configuration) => void> = []
|
|
|
|
if (nonRepudiation(plan)) {
|
|
execute.push(client.enableNonRepudiationChecks)
|
|
}
|
|
|
|
if (usesJarm(variant)) {
|
|
execute.push(client.useJwtResponseMode)
|
|
}
|
|
|
|
if (response_type === 'code id_token') {
|
|
execute.push(client.useCodeIdTokenResponseType)
|
|
if (plan.name.startsWith('fapi1')) {
|
|
execute.push(client.enableDetachedSignatureResponseChecks)
|
|
}
|
|
}
|
|
|
|
if (response_type === 'id_token') {
|
|
execute.push(client.useIdTokenResponseType)
|
|
}
|
|
|
|
const [jwk] = configuration.client.jwks.keys
|
|
const clientPrivateKey = {
|
|
kid: jwk.kid,
|
|
key: await importPrivateKey(ALG, jwk),
|
|
}
|
|
const client_secret = dcr(variant) ? undefined : metadata.client_secret
|
|
|
|
let clientAuth: client.ClientAuth | undefined
|
|
if (metadata.token_endpoint_auth_method === 'private_key_jwt') {
|
|
clientAuth = client.PrivateKeyJwt(clientPrivateKey)
|
|
} else if (
|
|
metadata.token_endpoint_auth_method === 'client_secret_basic'
|
|
) {
|
|
clientAuth = client.ClientSecretBasic(client_secret)
|
|
}
|
|
|
|
let config: client.Configuration
|
|
if (dcr(variant)) {
|
|
config = await client.dynamicClientRegistration(
|
|
issuer,
|
|
metadata,
|
|
clientAuth,
|
|
{
|
|
execute,
|
|
// @ts-ignore
|
|
[client.customFetch]:
|
|
mtlsAuth || mtlsConstrain || metadata.use_mtls_endpoint_aliases
|
|
? mtlsFetch
|
|
: dcr(variant)
|
|
? dcrFetch
|
|
: undefined,
|
|
},
|
|
)
|
|
} else {
|
|
config = await client.discovery(
|
|
issuer,
|
|
configuration.client.client_id,
|
|
metadata,
|
|
clientAuth,
|
|
{
|
|
execute,
|
|
// @ts-ignore
|
|
[client.customFetch]:
|
|
mtlsAuth || mtlsConstrain || metadata.use_mtls_endpoint_aliases
|
|
? mtlsFetch
|
|
: undefined,
|
|
},
|
|
)
|
|
}
|
|
|
|
if (module.testModule.includes('encrypted')) {
|
|
const jwk = configuration.client.jwks.keys[0]
|
|
const key = await importPrivateKey('RSA-OAEP', jwk)
|
|
client.enableDecryptingResponses(config, undefined, {
|
|
key,
|
|
kid: `enc-${jwk.kid}`,
|
|
})
|
|
}
|
|
|
|
t.log('AS Metadata discovered for', issuer.href)
|
|
if (dcr(variant)) {
|
|
t.log(
|
|
'Client Metadata registered for',
|
|
config.clientMetadata().client_id,
|
|
)
|
|
}
|
|
|
|
const DPoP = usesDpop(variant)
|
|
? client.getDPoPHandle(config, await client.randomDPoPKeyPair(ALG))
|
|
: undefined
|
|
|
|
const code_verifier = client.randomPKCECodeVerifier()
|
|
const code_challenge =
|
|
await client.calculatePKCECodeChallenge(code_verifier)
|
|
const code_challenge_method = 'S256'
|
|
|
|
if (
|
|
!config.serverMetadata().supportsPKCE() &&
|
|
!response_type.includes('id_token')
|
|
) {
|
|
options ||= {}
|
|
options.useState = true
|
|
}
|
|
|
|
const scope = getScope(variant)
|
|
let nonce =
|
|
options?.useNonce || requiresNonce(plan.name, variant)
|
|
? client.randomNonce()
|
|
: undefined
|
|
let state =
|
|
options?.useState || requiresState(plan.name, variant)
|
|
? client.randomState()
|
|
: undefined
|
|
|
|
let params: URLSearchParams = new URLSearchParams()
|
|
if (code_challenge) {
|
|
params.set('code_challenge', code_challenge)
|
|
}
|
|
if (code_challenge_method) {
|
|
params.set('code_challenge_method', code_challenge_method)
|
|
}
|
|
params.set('redirect_uri', configuration.client.redirect_uri)
|
|
params.set('scope', scope)
|
|
if (typeof nonce === 'string') {
|
|
params.set('nonce', nonce)
|
|
}
|
|
if (typeof state === 'string') {
|
|
params.set('state', state)
|
|
}
|
|
|
|
if (usesRequestObject(plan.name, variant)) {
|
|
;({ searchParams: params } = await client.buildAuthorizationUrlWithJAR(
|
|
config,
|
|
params,
|
|
clientPrivateKey,
|
|
))
|
|
}
|
|
|
|
let authorizationUrl: URL
|
|
|
|
if (usesPar(plan)) {
|
|
t.log('PAR request with', Object.fromEntries(params.entries()))
|
|
authorizationUrl = await client.buildAuthorizationUrlWithPAR(
|
|
config,
|
|
params,
|
|
{
|
|
DPoP,
|
|
},
|
|
)
|
|
t.log(
|
|
'PAR request_uri',
|
|
authorizationUrl.searchParams.get('request_uri'),
|
|
)
|
|
} else {
|
|
if (params.has('request') && plan.name.startsWith('fapi1')) {
|
|
const plain = client.buildAuthorizationUrl(config, {})
|
|
params.set('response_type', plain.searchParams.get('response_type')!)
|
|
params.set('scope', 'openid')
|
|
}
|
|
authorizationUrl = client.buildAuthorizationUrl(config, params)
|
|
}
|
|
|
|
await Promise.allSettled([
|
|
fetch(authorizationUrl.href, { redirect: 'manual' }),
|
|
])
|
|
|
|
t.log(
|
|
'redirect with',
|
|
Object.fromEntries(authorizationUrl.searchParams.entries()),
|
|
)
|
|
|
|
const { authorization_endpoint_response_redirect } =
|
|
await getTestExposed(instance)
|
|
|
|
if (!authorization_endpoint_response_redirect) {
|
|
throw new Error()
|
|
}
|
|
|
|
const currentUrl = new URL(authorization_endpoint_response_redirect)
|
|
|
|
t.log('response redirect to', currentUrl.href)
|
|
|
|
if (response_type === 'id_token') {
|
|
const response = await client.implicitAuthentication(
|
|
config,
|
|
currentUrl,
|
|
nonce!,
|
|
{
|
|
expectedState: state,
|
|
},
|
|
)
|
|
|
|
t.log('validated ID Token Claims Set', {
|
|
...response,
|
|
})
|
|
} else {
|
|
const response = await client.authorizationCodeGrant(
|
|
config,
|
|
currentUrl,
|
|
{
|
|
expectedNonce: nonce,
|
|
expectedState: state,
|
|
pkceCodeVerifier: code_verifier,
|
|
},
|
|
undefined,
|
|
{ DPoP },
|
|
)
|
|
|
|
t.log('token endpoint response', { ...response })
|
|
|
|
if (
|
|
!plan.name.startsWith('fapi1') &&
|
|
scope.includes('openid') &&
|
|
config.serverMetadata().userinfo_endpoint
|
|
) {
|
|
// fetch userinfo response
|
|
t.log('fetching', config.serverMetadata().userinfo_endpoint)
|
|
const userinfo = await client.fetchUserInfo(
|
|
config,
|
|
response.access_token,
|
|
response.claims()?.sub!,
|
|
{
|
|
DPoP,
|
|
},
|
|
)
|
|
t.log('userinfo endpoint response', { ...userinfo })
|
|
}
|
|
|
|
if (accounts_endpoint) {
|
|
const resource = await client.fetchProtectedResource(
|
|
config,
|
|
response.access_token,
|
|
new URL(accounts_endpoint),
|
|
'GET',
|
|
null,
|
|
undefined,
|
|
{ DPoP },
|
|
)
|
|
|
|
const result = await resource.text()
|
|
try {
|
|
t.log('accounts endpoint response', JSON.parse(result))
|
|
} catch {
|
|
t.log('accounts endpoint response body', result)
|
|
}
|
|
}
|
|
}
|
|
|
|
await waitForState(instance)
|
|
if (module.skipLogTestFinished !== true) {
|
|
t.log('Test Finished')
|
|
}
|
|
t.pass()
|
|
},
|
|
title(providedTitle = '', module: ModulePrescription) {
|
|
if (module.variant) {
|
|
return `${providedTitle}${plan.name} (${plan.id}) - ${module.testModule} - ${JSON.stringify(
|
|
module.variant,
|
|
)}`
|
|
}
|
|
return `${providedTitle}${plan.name} (${plan.id}) - ${module.testModule}`
|
|
},
|
|
})
|
|
}
|
|
|
|
interface ErrorAssertion {
|
|
name: string
|
|
code: string
|
|
message: string | RegExp
|
|
}
|
|
|
|
export type CodeErrorAssertion = Partial<ErrorAssertion> &
|
|
Pick<ErrorAssertion, 'code'>
|
|
export type NameErrorAssertion = Partial<ErrorAssertion> &
|
|
Pick<ErrorAssertion, 'name'>
|
|
export type MessageErrorAssertion = Partial<ErrorAssertion> &
|
|
Pick<ErrorAssertion, 'message'>
|
|
|
|
function assertError(
|
|
t: ExecutionContext,
|
|
actual: unknown,
|
|
expected: Partial<ErrorAssertion>,
|
|
) {
|
|
if (!(actual instanceof Error)) {
|
|
t.fail('expected and Error instance')
|
|
}
|
|
|
|
// @ts-ignore
|
|
if (expected.code) t.is(actual.code, expected.code)
|
|
if (expected.name) t.is(actual.name, expected.name)
|
|
if (expected.message) {
|
|
if (typeof expected.message === 'string') {
|
|
t.is(actual.message, expected.message)
|
|
} else {
|
|
t.regex(actual.message, expected.message)
|
|
}
|
|
}
|
|
}
|
|
|
|
export const rejects = (
|
|
macro: Macro<[module: ModulePrescription], { instance: Test }>,
|
|
) => {
|
|
return test.macro({
|
|
async exec(
|
|
t,
|
|
module: ModulePrescription,
|
|
expected: CodeErrorAssertion | MessageErrorAssertion,
|
|
cause?: CodeErrorAssertion | MessageErrorAssertion,
|
|
) {
|
|
const err = await t.throwsAsync(
|
|
() => macro.exec(t, { ...module, skipLogTestFinished: true }) as any,
|
|
)
|
|
t.log('rejected with', inspect(err, { depth: Infinity }))
|
|
|
|
expected.name ||= 'ClientError'
|
|
assertError(t, err, expected)
|
|
|
|
if (cause) {
|
|
cause.name ||= 'OperationProcessingError'
|
|
if (!(err.cause instanceof Error)) {
|
|
t.fail('expected and Error instance')
|
|
}
|
|
t.truthy(
|
|
err.cause,
|
|
'expected err to have a [cause] that is an Error instance',
|
|
)
|
|
|
|
if (typeof cause !== 'boolean') {
|
|
assertError(t, err.cause, cause)
|
|
}
|
|
}
|
|
|
|
await waitForState(t.context.instance)
|
|
t.log('Test Finished')
|
|
t.pass()
|
|
},
|
|
title: macro.title as any,
|
|
})
|
|
}
|
|
|
|
export const skippable = (
|
|
macro: Macro<[module: ModulePrescription], { instance: Test }>,
|
|
) => {
|
|
return test.macro({
|
|
async exec(t, module: ModulePrescription) {
|
|
await Promise.allSettled([
|
|
macro.exec(t, { ...module, skipLogTestFinished: true }),
|
|
])
|
|
|
|
await waitForState(t.context.instance, {
|
|
results: new Set(['SKIPPED', 'PASSED']),
|
|
})
|
|
t.log('Test Finished')
|
|
t.pass()
|
|
},
|
|
title: macro.title as any,
|
|
})
|
|
}
|