deno.land / x / oauth4webapi@v1.2.2 / conformance / runner.ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450import anyTest, { type TestFn } from 'ava'
export const test = anyTest as TestFn<{ instance: Test }>
import { getScope } from './ava.config.js'import * as oauth from '../src/index.js'import { createTestFromPlan, waitForState, getTestExposed, type ModulePrescription, type Plan, type Test,} from './api.js'
import { JWS_ALGORITHM } from './env.js'const conformance = JSON.parse(process.env.CONFORMANCE!)
const configuration: { alias: string client: { client_id: string client_secret?: string redirect_uri: string jwks: { keys: Array<JsonWebKey & { kid: string }> } }} = conformance.configuration
export const plan: Plan = conformance.planexport const variant: Record<string, string> = conformance.variant
let prefix = ''
switch (plan.name) { case 'fapi2-baseline-id2-client-test-plan': prefix = 'fapi2-baseline-id2-client-test-' break case 'fapi2-advanced-id1-client-test-plan': // TODO: https://gitlab.com/openid/conformance-suite/-/merge_requests/1173#note_1014001397 prefix = 'fapi2-baseline-id2-client-test-' break case 'oidcc-client-test-plan': case 'oidcc-client-basic-certification-test-plan': prefix = 'oidcc-client-test-' break default: throw new Error()}
const algorithms: Map<string, RsaHashedImportParams | EcKeyImportParams | Algorithm> = new Map([ [ 'PS256', { name: 'RSA-PSS', hash: { name: 'SHA-256' }, }, ], [ 'ES256', { name: 'ECDSA', namedCurve: 'P-256', }, ], [ 'RS256', { name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' }, }, ], ['EdDSA', { name: 'Ed25519' }],])
function importPrivateKey(alg: string, jwk: JsonWebKey) { return crypto.subtle.importKey('jwk', jwk, algorithms.get(alg)!, false, ['sign'])}
export function modules(name: string): ModulePrescription[] { if (name === prefix.slice(0, -1)) { return conformance.plan.modules.filter((x: ModulePrescription) => x.testModule === name) }
return conformance.plan.modules.filter( (x: ModulePrescription) => x.testModule === `${prefix}${name}`, )}
function usesJarm(plan: Plan) { return plan.name.startsWith('fapi2-advanced')}
function usesDpop(variant: Record<string, string>) { return variant.sender_constrain === 'dpop'}
function usesPar(plan: Plan) { return plan.name.startsWith('fapi2')}
function usesRequestObject(planName: string, variant: Record<string, string>) { if (planName.startsWith('fapi2-advanced')) { return true }
if (variant.request_type === 'request_object') { return true }
return false}
export const green = 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 = { ...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 as = await oauth .discoveryRequest(issuer) .then((response) => oauth.processDiscoveryResponse(issuer, response))
t.log('AS Metadata', as)
const client: oauth.Client = { client_id: configuration.client.client_id, client_secret: configuration.client.client_secret, }
client.token_endpoint_auth_method = variant.client_auth_type || 'client_secret_basic' if (instance.name.includes('client-secret-basic')) { client.token_endpoint_auth_method = 'client_secret_basic' }
let clientPrivateKey!: oauth.PrivateKey
switch (client.token_endpoint_auth_method) { case 'none': delete client.client_secret break case 'private_key_jwt': delete client.client_secret const jwk = configuration.client.jwks.keys.find((key) => key.alg === JWS_ALGORITHM)! clientPrivateKey = { kid: jwk.kid, key: await importPrivateKey(JWS_ALGORITHM, jwk), } }
const scope = getScope(variant) const code_verifier = oauth.generateRandomCodeVerifier() const code_challenge = await oauth.calculatePKCECodeChallenge(code_verifier) const code_challenge_method = 'S256'
let authorizationUrl = new URL(as.authorization_endpoint!) if (usesRequestObject(plan.name, variant) === false) { authorizationUrl.searchParams.set('client_id', client.client_id) authorizationUrl.searchParams.set('code_challenge', code_challenge) authorizationUrl.searchParams.set('code_challenge_method', code_challenge_method) authorizationUrl.searchParams.set('redirect_uri', configuration.client.redirect_uri) authorizationUrl.searchParams.set('response_type', 'code') authorizationUrl.searchParams.set('scope', scope) } else { authorizationUrl.searchParams.set('client_id', client.client_id) const params = new URLSearchParams() params.set('code_challenge', code_challenge) params.set('code_challenge_method', code_challenge_method) params.set('redirect_uri', configuration.client.redirect_uri) params.set('response_type', 'code') params.set('scope', scope)
const jwk = configuration.client.jwks.keys.find((key) => key.alg === JWS_ALGORITHM)! const privateKey = await importPrivateKey(JWS_ALGORITHM, jwk)
authorizationUrl.searchParams.set( 'request', await oauth.issueRequestObject(as, client, params, { kid: jwk.kid, key: privateKey }), ) authorizationUrl.searchParams.set('scope', scope) authorizationUrl.searchParams.set('response_type', 'code') }
let DPoP!: CryptoKeyPair if (usesDpop(variant)) { DPoP = await oauth.generateKeyPair(<oauth.JWSAlgorithm>JWS_ALGORITHM) authorizationUrl.searchParams.set( 'dpop_jkt', await oauth.calculateJwkThumbprint(DPoP.publicKey), ) }
if (usesPar(plan)) { t.log('PAR request with', Object.fromEntries(authorizationUrl.searchParams.entries())) const request = () => oauth.pushedAuthorizationRequest(as, client, authorizationUrl.searchParams, { DPoP, clientPrivateKey, }) let par = await request()
let challenges: oauth.WWWAuthenticateChallenge[] | undefined if ((challenges = oauth.parseWwwAuthenticateChallenges(par))) { for (const challenge of challenges) { t.log('challenge', challenge) } throw new Error() }
let result = await oauth.processPushedAuthorizationResponse(as, client, par) if (oauth.isOAuth2Error(result)) { t.log('error', result) if (result.error === 'use_dpop_nonce') { t.log('retrying with a newly obtained dpop nonce') par = await request() result = await oauth.processPushedAuthorizationResponse(as, client, par) } throw new Error() // Handle OAuth 2.0 response body error } t.log('PAR response', await par.clone().json()) authorizationUrl = new URL(as.authorization_endpoint!) authorizationUrl.searchParams.set('client_id', client.client_id) authorizationUrl.searchParams.set('request_uri', result.request_uri) }
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)
let sub: string let access_token: string { let params: ReturnType<typeof oauth.validateAuthResponse>
if (usesJarm(plan)) { params = await oauth.validateJwtAuthResponse(as, client, currentUrl) } else { params = oauth.validateAuthResponse(as, client, currentUrl) }
if (oauth.isOAuth2Error(params)) { t.log('error', params) throw new Error() // Handle OAuth 2.0 redirect error }
t.log('parsed callback parameters', Object.fromEntries(params.entries()))
const request = () => oauth.authorizationCodeGrantRequest( as, client, <Exclude<typeof params, oauth.OAuth2Error>>params, configuration.client.redirect_uri, code_verifier, { clientPrivateKey, DPoP }, ) let response = await request()
let challenges: oauth.WWWAuthenticateChallenge[] | undefined if ((challenges = oauth.parseWwwAuthenticateChallenges(response))) { for (const challenge of challenges) { t.log('challenge', challenge) } throw new Error() }
let result: | oauth.OAuth2TokenEndpointResponse | oauth.OpenIDTokenEndpointResponse | oauth.OAuth2Error if (scope.includes('openid')) { result = await oauth.processAuthorizationCodeOpenIDResponse(as, client, response) } else { result = await oauth.processAuthorizationCodeOAuth2Response(as, client, response) }
if (oauth.isOAuth2Error(result)) { t.log('error', result) if (result.error === 'use_dpop_nonce') { t.log('retrying with a newly obtained dpop nonce') response = await request() if (scope.includes('openid')) { result = await oauth.processAuthorizationCodeOpenIDResponse(as, client, response) } else { result = await oauth.processAuthorizationCodeOAuth2Response(as, client, response) } } throw new Error() // Handle OAuth 2.0 response body error }
t.log('token endpoint response body', await response.clone().json()) ;({ access_token } = result) if (result.id_token) { const claims = oauth.getValidatedIdTokenClaims(result) t.log('ID Token Claims', claims) ;({ sub } = claims) } }
if (scope.includes('openid') && as.userinfo_endpoint) { // fetch userinfo response const request = () => { t.log('fetching', as.userinfo_endpoint) return oauth.userInfoRequest(as, client, access_token, { DPoP }) } let response = await request()
let challenges: oauth.WWWAuthenticateChallenge[] | undefined if ((challenges = oauth.parseWwwAuthenticateChallenges(response))) { let retried = false for (const challenge of challenges) { t.log('challenge', challenge) if (challenge.scheme === 'dpop' && challenge.parameters.error === 'use_dpop_nonce') { t.log('retrying with a newly obtained dpop nonce') response = await request() retried = true } } if (!retried) { throw new Error() } }
try { t.log('userinfo endpoint response body', await response.clone().json()) } catch { t.log('userinfo endpoint response body', await response.clone().text()) }
await oauth.processUserInfoResponse(as, client, sub!, response) t.log('userinfo response passed validation') }
if (accounts_endpoint) { const request = () => { t.log('fetching', accounts_endpoint) return oauth.protectedResourceRequest( access_token, 'GET', new URL(accounts_endpoint), new Headers(), null, { DPoP }, ) } let accounts = await request()
let challenges: oauth.WWWAuthenticateChallenge[] | undefined if ((challenges = oauth.parseWwwAuthenticateChallenges(accounts))) { let retried = false for (const challenge of challenges) { t.log('challenge', challenge) if (challenge.scheme === 'dpop' && challenge.parameters.error === 'use_dpop_nonce') { t.log('retrying with a newly obtained dpop nonce') accounts = await request() retried = true } } if (!retried) { throw new Error() } }
try { t.log('accounts endpoint response body', await accounts.clone().json()) } catch { t.log('accounts endpoint response body', await accounts.clone().text()) } }
await waitForState(instance)
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}` },})
export const red = test.macro({ async exec( t, module: ModulePrescription, expectedMessage?: string | RegExp, expectedErrorName: string = 'OperationProcessingError', ) { await t .throwsAsync(() => <any>green.exec(t, module), { message: expectedMessage, name: expectedErrorName, }) .then((err) => { if (err) { t.log('rejected with', { message: err.message, name: err.name, }) } })
await waitForState(t.context.instance) t.log('Test Finished') t.pass() }, title: <any>green.title,})
export const skipped = test.macro({ async exec(t, module: ModulePrescription) { await Promise.allSettled([green.exec(t, module)])
await waitForState(t.context.instance, { results: new Set(['SKIPPED']) }) t.log('Test result is SKIPPED') t.pass() }, title: <any>green.title,})
Version Info