Skip to content

Commit

Permalink
feat: add experimental support for FAPI 1.0
Browse files Browse the repository at this point in the history
  • Loading branch information
panva committed Jan 11, 2024
1 parent 059de2c commit 6b6b496
Show file tree
Hide file tree
Showing 13 changed files with 430 additions and 31 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ jobs:
- plan: fapi1-advanced-final-client-test-plan
variant:
client_auth_type: 'mtls'
- plan: fapi1-advanced-final-client-test-plan
variant:
fapi_response_mode: 'plain_response'
fapi_auth_request_method: 'by_value'

# FAPI 2.0 Security Profile ID2
- plan: fapi2-security-profile-id2-client-test-plan
Expand Down
16 changes: 16 additions & 0 deletions conformance/ava.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,18 @@ function makePublicJwks(def: any) {
return client
}

function pushEncryptionKey(def: any) {
const client = structuredClone(def)
const key = client.jwks.keys[0]
client.jwks.keys.push({
...key,
kid: `enc-${key.kid}`,
use: 'enc',
alg: 'RSA-OEAP',
})
return client
}

function ensureTestFile(path: string, name: string) {
if (!exists(path)) {
writeFileSync(
Expand Down Expand Up @@ -178,6 +190,10 @@ export default async () => {
{
...configuration,
client: makePublicJwks(clientConfig),
client2: {
...pushEncryptionKey(makePublicJwks(clientConfig)),
id_token_encrypted_response_alg: 'RSA-OAEP',
},
},
variant,
)
Expand Down
5 changes: 5 additions & 0 deletions conformance/fapi/encrypted-idtoken-usingrsa15.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { test, red, modules } from '../runner.js'

for (const module of modules('encrypted-idtoken-usingrsa15')) {
test.serial(red, module, 'failed to decrypt ID Token')
}
5 changes: 5 additions & 0 deletions conformance/fapi/encrypted-idtoken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { test, green, modules } from '../runner.js'

for (const module of modules('encrypted-idtoken')) {
test.serial(green, module)
}
15 changes: 13 additions & 2 deletions conformance/fapi/iat-is-week-in-past.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
import test from 'ava'
import { test, red, modules, variant } from '../runner.js'

test.todo('iat-is-week-in-past')
for (const module of modules('iat-is-week-in-past')) {
if (variant.fapi_response_mode === 'jarm') {
// TODO: https://gitlab.com/openid/conformance-suite/-/merge_requests/1368
test.todo('iat-is-week-in-past')
} else {
test.serial(
red,
module,
'unexpected JWT "iat" (issued at) claim value, it is too far in the past',
)
}
}
5 changes: 5 additions & 0 deletions conformance/fapi/invalid-chash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { test, red, modules } from '../runner.js'

for (const module of modules('invalid-chash')) {
test.serial(red, module, 'invalid ID Token "c_hash" (code hash) claim value')
}
5 changes: 5 additions & 0 deletions conformance/fapi/invalid-missing-shash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { test, red, modules } from '../runner.js'

for (const module of modules('invalid-missing-shash')) {
test.serial(red, module, 'invalid ID Token "s_hash" (state hash) claim value')
}
5 changes: 5 additions & 0 deletions conformance/fapi/invalid-shash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { test, red, modules } from '../runner.js'

for (const module of modules('invalid-shash')) {
test.serial(red, module, 'invalid ID Token "s_hash" (state hash) claim value')
}
32 changes: 27 additions & 5 deletions conformance/run-certification.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

set -e

# Helper function to run a conformance test plan
declare -a pids

run_conformance() {
local plan_name=$1
local variant=$2
Expand All @@ -13,21 +14,42 @@ run_conformance() {
echo "===================================================================="
}

cleanup() {
for pid in "${pids[@]}"; do
kill "$pid"
done
exit 1
}

# Trap the exit signal
trap cleanup EXIT

# Basic RP
export PLAN_NAME=oidcc-client-basic-certification-test-plan
export VARIANT='{}'
run_conformance "$PLAN_NAME" "$VARIANT" &
pids+=($!)

export CLIENT_AUTH_TYPES=("mtls" "private_key_jwt")
export FAPI_CLIENT_TYPES=("oidc" "plain_oauth")

# FAPI 1.0 Advanced
export PLAN_NAME=fapi1-advanced-final-client-test-plan
export FAPI_RESPONSE_MODES=("plain_response" "jarm")
export FAPI_AUTH_REQUEST_METHODS=("pushed" "by_value")

for CLIENT_AUTH_TYPE in "${CLIENT_AUTH_TYPES[@]}"; do
for FAPI_CLIENT_TYPE in "${FAPI_CLIENT_TYPES[@]}"; do
export VARIANT="{\"client_auth_type\":\"$CLIENT_AUTH_TYPE\",\"fapi_client_type\":\"$FAPI_CLIENT_TYPE\"}"
run_conformance "$PLAN_NAME" "$VARIANT" &
for FAPI_RESPONSE_MODE in "${FAPI_RESPONSE_MODES[@]}"; do
for FAPI_AUTH_REQUEST_METHOD in "${FAPI_AUTH_REQUEST_METHODS[@]}"; do
for FAPI_CLIENT_TYPE in "${FAPI_CLIENT_TYPES[@]}"; do
if [[ "$FAPI_CLIENT_TYPE" == "plain_oauth" && "$FAPI_RESPONSE_MODE" != "jarm" ]]; then
continue
fi
export VARIANT="{\"client_auth_type\":\"$CLIENT_AUTH_TYPE\",\"fapi_response_mode\":\"$FAPI_RESPONSE_MODE\",\"fapi_auth_request_method\":\"$FAPI_AUTH_REQUEST_METHOD\",\"fapi_client_type\":\"$FAPI_CLIENT_TYPE\"}"
run_conformance "$PLAN_NAME" "$VARIANT" &
pids+=($!)
done
done
done
done

Expand All @@ -41,10 +63,10 @@ for PLAN_NAME in "${PLAN_NAMES[@]}"; do
for FAPI_CLIENT_TYPE in "${FAPI_CLIENT_TYPES[@]}"; do
export VARIANT="{\"client_auth_type\":\"$CLIENT_AUTH_TYPE\",\"sender_constrain\":\"$SENDER_CONSTRAIN\",\"fapi_client_type\":\"$FAPI_CLIENT_TYPE\"}"
run_conformance "$PLAN_NAME" "$VARIANT" &
pids+=($!)
done
done
done
done

# Wait for all runs to finish
wait
90 changes: 77 additions & 13 deletions conformance/runner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import anyTest, { type TestFn } from 'ava'
import { importJWK, type JWK, calculateJwkThumbprint, exportJWK } from 'jose'
import {
importJWK,
type JWK,
calculateJwkThumbprint,
exportJWK,
decodeProtectedHeader,
compactDecrypt,
} from 'jose'
import * as undici from 'undici'

export const test = anyTest as TestFn<{ instance: Test }>
Expand Down Expand Up @@ -70,16 +77,16 @@ export function modules(name: string): ModulePrescription[] {
)
}

function usesJarm(plan: Plan) {
return plan.name.startsWith('fapi2-message-signing') || plan.name.startsWith('fapi1')
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('fapi')
return plan.name.startsWith('fapi2') || variant.fapi_auth_request_method === 'pushed'
}

function usesRequestObject(planName: string, variant: Record<string, string>) {
Expand All @@ -106,6 +113,30 @@ 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 (!planName.startsWith('fapi1')) {
return 'code'
}

return variant.fapi_response_mode === 'jarm' ? 'code' : 'code id_token'
}

async function decryptIdToken(jwe: string) {
return new TextDecoder().decode(
(
await compactDecrypt(
jwe,
await importPrivateKey('RSA-OAEP', configuration.client.jwks.keys[0]),
{
keyManagementAlgorithms: ['RSA-OAEP'],
},
).catch((cause) => {
throw new oauth.OperationProcessingError('failed to decrypt ID Token', { cause })
})
).plaintext,
)
}

export const green = test.macro({
async exec(t, module: ModulePrescription) {
t.timeout(15000)
Expand All @@ -116,7 +147,7 @@ export const green = test.macro({
t.log('Test ID', instance.id)
t.log('Test Name', instance.name)

const variant = {
const variant: Record<string, string> = {
...conformance.variant,
...module.variant,
}
Expand Down Expand Up @@ -193,24 +224,27 @@ export const green = test.macro({
}
}

const response_type = responseType(plan.name, variant)
const scope = getScope(variant)
const code_verifier = oauth.generateRandomCodeVerifier()
const code_challenge = await oauth.calculatePKCECodeChallenge(code_verifier)
const code_challenge_method = 'S256'
let nonce = requiresNonce(plan.name, variant)
? oauth.generateRandomNonce()
: oauth.expectNoNonce
let state = requiresState(plan.name, variant)
? oauth.generateRandomState()
: oauth.expectNoState
let state =
requiresState(plan.name, variant) ||
(plan.name.startsWith('fapi1') && variant.fapi_response_mode !== 'jarm')
? oauth.generateRandomState()
: oauth.expectNoState

let authorizationUrl = new URL(as.authorization_endpoint!)
if (!usesRequestObject(plan.name, variant)) {
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('response_type', response_type)
authorizationUrl.searchParams.set('scope', scope)
if (typeof nonce === 'string') {
authorizationUrl.searchParams.set('nonce', nonce)
Expand All @@ -224,7 +258,7 @@ export const green = test.macro({
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('response_type', response_type)
params.set('scope', scope)
if (typeof nonce === 'string') {
params.set('nonce', nonce)
Expand All @@ -241,7 +275,7 @@ export const green = test.macro({
await oauth.issueRequestObject(as, client, params, { kid: jwk.kid, key: privateKey }),
)
authorizationUrl.searchParams.set('scope', scope)
authorizationUrl.searchParams.set('response_type', 'code')
authorizationUrl.searchParams.set('response_type', response_type)
}

let DPoP!: CryptoKeyPair
Expand Down Expand Up @@ -301,15 +335,28 @@ export const green = test.macro({
throw new Error()
}

const currentUrl = new URL(authorization_endpoint_response_redirect)
let currentUrl: URL | URLSearchParams = new URL(authorization_endpoint_response_redirect)

let sub: string
let access_token: string
{
let params: ReturnType<typeof oauth.validateAuthResponse>

if (usesJarm(plan)) {
if (usesJarm(variant)) {
params = await oauth.validateJwtAuthResponse(as, client, currentUrl, state)
} else if (response_type === 'code id_token') {
currentUrl = new URLSearchParams(currentUrl.hash.slice(1))
const idToken = currentUrl.get('id_token')!
if (decodeProtectedHeader(idToken).enc) {
currentUrl.set('id_token', await decryptIdToken(idToken))
}
params = await oauth.experimental_validateDetachedSignatureResponse(
as,
client,
currentUrl,
<string>nonce,
state,
)
} else {
params = oauth.validateAuthResponse(as, client, currentUrl, state)
}
Expand Down Expand Up @@ -344,6 +391,23 @@ export const green = test.macro({
throw new Error()
}

if (response_type === 'code id_token') {
try {
const body = await response.clone().json()
const { id_token } = body
if (decodeProtectedHeader(id_token).enc) {
const newResponse = new Response(
JSON.stringify({
...body,
id_token: await decryptIdToken(id_token),
}),
response,
)
response = newResponse
}
} catch {}
}

let result:
| oauth.OAuth2TokenEndpointResponse
| oauth.OpenIDTokenEndpointResponse
Expand Down
4 changes: 4 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@
- [OperationProcessingError](classes/OperationProcessingError.md)
- [UnsupportedOperationError](classes/UnsupportedOperationError.md)

### FAPI 1.0 Advanced

- [experimental\_validateDetachedSignatureResponse](functions/experimental_validateDetachedSignatureResponse.md)

### JWT Secured Authorization Response Mode for OAuth 2.0 (JARM)

- [validateJwtAuthResponse](functions/validateJwtAuthResponse.md)
Expand Down
33 changes: 33 additions & 0 deletions docs/functions/experimental_validateDetachedSignatureResponse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Function: experimental\_validateDetachedSignatureResponse

[💗 Help the project](https://github.com/sponsors/panva)

**experimental_validateDetachedSignatureResponse**(`as`, `client`, `parameters`, `expectedNonce`, `expectedState?`, `maxAge?`, `options?`): [`Promise`]( https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise )\<[`URLSearchParams`]( https://developer.mozilla.org/docs/Web/API/URLSearchParams ) \| [`OAuth2Error`](../interfaces/OAuth2Error.md)\>

This is an experimental feature, it is not subject to semantic versioning rules. Non-backward
compatible changes or removal may occur in any future release.

Same as [validateAuthResponse](validateAuthResponse.md) but for FAPI 1.0 Advanced Detached Signature authorization
responses.

#### Parameters

| Name | Type | Description |
| :------ | :------ | :------ |
| `as` | [`AuthorizationServer`](../interfaces/AuthorizationServer.md) | Authorization Server Metadata. |
| `client` | [`Client`](../interfaces/Client.md) | Client Metadata. |
| `parameters` | [`URLSearchParams`]( https://developer.mozilla.org/docs/Web/API/URLSearchParams ) | Authorization Response. |
| `expectedNonce` | `string` | Expected ID Token `nonce` claim value. |
| `expectedState?` | `string` \| typeof [`expectNoState`](../variables/expectNoState.md) | Expected `state` parameter value. Default is [expectNoState](../variables/expectNoState.md). |
| `maxAge?` | `number` \| typeof [`skipAuthTimeCheck`](../variables/skipAuthTimeCheck.md) | ID Token [`auth_time`](../interfaces/IDToken.md#auth_time) claim value will be checked to be present and conform to the `maxAge` value. Use of this option is required if you sent a `max_age` parameter in an authorization request. Default is [`client.default_max_age`](../interfaces/Client.md#default_max_age) and falls back to [skipAuthTimeCheck](../variables/skipAuthTimeCheck.md). |
| `options?` | [`HttpRequestOptions`](../interfaces/HttpRequestOptions.md) | - |

#### Returns

[`Promise`]( https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise )\<[`URLSearchParams`]( https://developer.mozilla.org/docs/Web/API/URLSearchParams ) \| [`OAuth2Error`](../interfaces/OAuth2Error.md)\>

Validated Authorization Response parameters or Authorization Error Response.

**`See`**

[Financial-grade API Security Profile 1.0 - Part 2: Advanced](https://openid.net/specs/openid-financial-api-part-2-1_0.html#id-token-as-detached-signature)

0 comments on commit 6b6b496

Please sign in to comment.