Section 6: Creating a Consent
Creating a consent is the process of requesting authorization from a customer to access their data from a specific Data Provider. This section covers the multi-step process: creating PKCE parameters, creating a JSON Authorization Request (JAR), then submitting it via the Pushed Authorization Request (PAR) endpoint.
Overview
Consent creation follows the OAuth 2.0 authorization code flow with the following steps:
- Create a PKCE code verifier and code challenge - A security mechanism required by FAPI 2.0
- Create a JAR (JSON Authorization Request) - Sign a JWT containing consent details
- Submit via PAR endpoint - Push the signed JAR to obtain a request_uri
- Redirect user to authorize - User logs in and grants consent at the OFP
- Handle callback - Receive authorization code and exchange for access token
This section covers steps 1-2. Steps 3-5 are covered in Section 7: Consent Authorization and Token Exchange.
Step 1: Create a PKCE Code Verifier and Code Challenge Pair
What is PKCE?
PKCE (Proof Key for Code Exchange) is a security extension to the OAuth 2.0 authorization code flow that prevents authorization code interception attacks. It's especially important for mobile and desktop applications.
How it works:
- Your application generates a random
code_verifier(43-128 characters) - Your application creates a
code_challengeby hashing the code_verifier:code_challenge = base64url(SHA256(code_verifier)) - You include
code_challengeandcode_challenge_method(S256) in the authorization request - Later, when exchanging the authorization code for tokens, you send the original
code_verifier - The server verifies:
SHA256(code_verifier) == code_challengeto prevent code theft
FAPI 2.0 mandates PKCE with the S256 method (SHA-256 hash).
Generating PKCE Parameters
Generate a PKCE code verifier and challenge:
Node.js:
const crypto = require('crypto');
const base64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
// Generate random code_verifier (43-128 characters)
const codeVerifier = base64url(crypto.randomBytes(32));
// Generate code_challenge
const codeChallenge = base64url(crypto.createHash('sha256').update(codeVerifier).digest());
console.log('code_verifier:', codeVerifier);
console.log('code_challenge:', codeChallenge);
// Store both values securely:
// - code_verifier: needed for Step 5 (token exchange)
// - code_challenge: needed for Step 2 (JAR creation)
Python:
import hashlib
import secrets
import base64
def base64url(data):
return base64.urlsafe_b64encode(data).decode('utf-8').rstrip('=')
# Generate random code_verifier (43-128 characters)
code_verifier = base64url(secrets.token_bytes(32))
# Generate code_challenge
code_challenge = base64url(hashlib.sha256(code_verifier.encode()).digest())
print(f'code_verifier: {code_verifier}')
print(f'code_challenge: {code_challenge}')
# Store both values securely:
# - code_verifier: needed for Step 5 (token exchange)
# - code_challenge: needed for Step 2 (JAR creation)
Step 2: Create the JSON Authorization Request (JAR)
A JAR is a signed JWT that contains all the consent request details. It must be signed using your signing private key.
Prerequisites
Before creating a JAR, ensure you have:
| Item | Where to Find | Notes |
|---|---|---|
client_id | Issued by PayNet | Your OIDC client identifier |
dc_id | Issued by PayNet | Your legal Data Consumer identifier |
dp_id | From Section 5: Discovering Data Providers | The provider_id of the target Data Provider |
signing-key.pem | From your certificate files | Your private signing key in PEM format |
kid | Your JWKS endpoint | Key ID of your signing certificate |
redirect_uri | Your application | The URI where you'll receive the authorization code |
code_challenge | From Step 1 | Generated in the PKCE step above |
JAR Header
{
"alg": "PS256",
"kid": "{{KEY_ID}}"
}
Fields:
alg: Algorithm. Must bePS256(RSASSA-PSS with SHA-256)kid: Key ID of your signing certificate
JAR Body Claims
{
"iss": "{{CLIENT_ID}}",
"aud": "{{OFP_AUTHORIZATION_SERVER_URL}}",
"nbf": {{CURRENT_UNIX_TIMESTAMP}},
"exp": {{CURRENT_UNIX_TIMESTAMP + 600}},
"iat": {{CURRENT_UNIX_TIMESTAMP}},
"jti": "550e8400-e29b-41d4-a716-446655440000",
"client_id": "{{CLIENT_ID}}",
"response_type": "code",
"redirect_uri": "{{YOUR_REDIRECT_URI}}",
"scope": "openid accounts",
"state": "{{STATE_UUIDV4}}",
"code_challenge": "{{PKCE_CODE_CHALLENGE}}",
"code_challenge_method": "S256",
"response_mode": "query",
"authorization_details": [
{
"type": "urn:openfinance-ml:account-access-consent:v1.2",
"consent": {
"dc_id": "{{DC_ID}}",
"dp_id": "{{DP_ID}}",
"consent_type": "urn:openfinance-ml:account-access-consent:v1.2",
"consent_purpose": "pfm",
"permissions": [
"read_accounts",
"read_balances",
"read_transactions"
],
"expiration_datetime": "2025-12-31T23:59:59Z"
}
}
]
}
Claim Definitions:
| Claim | Description |
|---|---|
iss | Issuer. Your client_id |
aud | Audience. The OFP Authorization Server URL (from well-known endpoint) |
nbf | Not before. Unix timestamp before which this JWT cannot be used |
exp | Expiration. Unix timestamp (typically iat + 600 = 10 minutes) |
iat | Issued at. Current Unix timestamp |
jti | JWT ID. Unique UUIDv4 to prevent replay attacks |
client_id | Your OIDC client ID |
response_type | Must be code (authorization code flow) |
redirect_uri | Your application URI where the authorization code will be sent |
scope | Space-delimited. Must include openid accounts |
state | Opaque value for CSRF protection. You'll verify this in the callback. |
code_challenge | PKCE challenge: base64url(SHA-256(code_verifier)) |
code_challenge_method | Must be S256 |
response_mode | Response disposition. Can be query or omitted (defaults to query) |
authorization_details | OAuth 2.0 RAR object with consent details (see below) |
Authorization Details Object
Structured consent request:
{
"type": "urn:openfinance-ml:account-access-consent:v1.2",
"consent": {
"dc_id": "{{DC_ID}}",
"dp_id": "{{DP_ID}}",
"consent_type": "urn:openfinance-ml:account-access-consent:v1.2",
"consent_purpose": "pfm",
"permissions": [
"read_accounts",
"read_balances",
"read_transactions"
],
"expiration_datetime": "2025-12-31T23:59:59Z"
}
}
Consent Fields:
| Field | Description |
|---|---|
dc_id | Your legal Data Consumer ID |
dp_id | Target Data Provider ID (from Providers endpoint). Omit this claim if you want the user to choose the DP at the point of consent authorization |
consent_type | Type of consent. Must be: urn:openfinance-ml:account-access-consent:v1.2 |
consent_purpose | Purpose of consent. Allowed values: pfm (Personal Financial Management), credit_underwriting |
permissions | Array of data clusters to access. Allowed values: read_accounts, read_balances, read_transactions |
expiration_datetime | When this consent expires (ISO 8601 format) |
Signing the JAR
Sign the JWT using your signing private key (PS256 algorithm):
Node.js:
const jwt = require('jsonwebtoken');
const fs = require('fs');
const crypto = require('crypto');
const signingKey = fs.readFileSync('/path/to/signing-key.pem', 'utf8');
const jarHeader = {
alg: 'PS256',
kid: '{{KEY_ID}}'
};
const now = Math.floor(Date.now() / 1000);
const jarBody = {
iss: '{{CLIENT_ID}}',
aud: '{{OFP_AUTHORIZATION_SERVER_URL}}',
nbf: now,
exp: now + 600,
iat: now,
jti: crypto.randomUUID(),
client_id: '{{CLIENT_ID}}',
response_type: 'code',
redirect_uri: '{{YOUR_REDIRECT_URI}}',
scope: 'openid accounts',
state: crypto.randomUUID(),
code_challenge: codeChallenge,
code_challenge_method: 'S256',
response_mode: 'query',
authorization_details: [
{
type: 'urn:openfinance-ml:account-access-consent:v1.2',
consent: {
dc_id: '{{DC_ID}}',
dp_id: '{{DP_ID}}',
consent_type: 'urn:openfinance-ml:account-access-consent:v1.2',
consent_purpose: 'pfm',
permissions: ['read_accounts', 'read_balances', 'read_transactions'],
expiration_datetime: '2025-12-31T23:59:59Z'
}
}
]
};
const signedJar = jwt.sign(jarBody, signingKey, {
algorithm: 'PS256',
keyid: '{{KEY_ID}}',
header: jarHeader
});
console.log('Signed JAR:', signedJar);
Python:
import jwt
import json
from datetime import datetime
import uuid
signing_key = open('/path/to/signing-key.pem', 'r').read()
jar_header = {
'alg': 'PS256',
'kid': '{{KEY_ID}}'
}
now = int(datetime.utcnow().timestamp())
jar_body = {
'iss': '{{CLIENT_ID}}',
'aud': '{{OFP_AUTHORIZATION_SERVER_URL}}',
'nbf': now,
'exp': now + 600,
'iat': now,
'jti': str(uuid.uuid4()),
'client_id': '{{CLIENT_ID}}',
'response_type': 'code',
'redirect_uri': '{{YOUR_REDIRECT_URI}}',
'scope': 'openid accounts',
'state': str(uuid.uuid4()),
'code_challenge': code_challenge,
'code_challenge_method': 'S256',
'response_mode': 'query',
'authorization_details': [
{
'type': 'urn:openfinance-ml:account-access-consent:v1.2',
'consent': {
'dc_id': '{{DC_ID}}',
'dp_id': '{{DP_ID}}',
'consent_type': 'urn:openfinance-ml:account-access-consent:v1.2',
'consent_purpose': 'pfm',
'permissions': ['read_accounts', 'read_balances', 'read_transactions'],
'expiration_datetime': '2025-12-31T23:59:59Z'
}
}
]
}
signed_jar = jwt.encode(
jar_body,
signing_key,
algorithm='PS256',
headers=jar_header
)
print(f'Signed JAR: {signed_jar}')
Step 3: Call the PAR Endpoint
Once you have the signed JAR and client authentication ready, submit them to the OFP's PAR endpoint to obtain a request_uri.
Before calling the PAR endpoint, review the Client Authentication Methods section to understand how to authenticate this request. The PAR endpoint requires either private_key_jwt or tls_client_auth authentication.
Endpoint
POST {{par_endpoint}}
(Use the pushed_authorization_request_endpoint from the OFP's well-known endpoint - obtained in Section 2)
Transport Security
mTLS required - Use your transport certificate and key
Headers
Content-Type: application/x-www-form-urlencoded x-fapi-interaction-id: 550e8400-e29b-41d4-a716-446655440000
Request Body
Include the signed JAR and your chosen client authentication method:
client_id={{CLIENT_ID}}&request={{SIGNED_JAR}}
Plus authentication parameters from Client Authentication Methods:
- If using private_key_jwt: Add
client_assertion_typeandclient_assertion - If using tls_client_auth: No additional parameters needed
Example cURL Request (private_key_jwt)
curl -X POST "{{par_endpoint}}" \
--cert /path/to/transport-certificate.pem \
--key /path/to/transport-key.pem \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "x-fapi-interaction-id: 550e8400-e29b-41d4-a716-446655440000" \
-d "client_id={{CLIENT_ID}}" \
-d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
-d "client_assertion={{SIGNED_CLIENT_ASSERTION_JWT}}" \
-d "request={{SIGNED_JAR}}"
Example cURL Request (tls_client_auth)
curl -X POST "{{par_endpoint}}" \
--cert /path/to/transport-certificate.pem \
--key /path/to/transport-key.pem \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "x-fapi-interaction-id: 550e8400-e29b-41d4-a716-446655440000" \
-d "client_id={{CLIENT_ID}}" \
-d "request={{SIGNED_JAR}}"
Response (201 Created)
{
"request_uri": "urn:ietf:params:oauth:request_uri:550e8400-e29b-41d4-a716-446655440000",
"expires_in": 600
}
Response Fields:
request_uri: Unique identifier for this authorization request. Use this in the next redirect step.expires_in: How long the request_uri remains valid (typically 10 minutes = 600 seconds)
Important: Store the State and Code Verifier
Before proceeding to the next step, securely store:
state- You'll verify this matches the callback parametercode_verifier- You'll use this when exchanging the authorization code for tokensrequest_uri- You'll use this in the redirect step
Error Handling
The PAR endpoint may return errors if your request is malformed, authentication fails, or your consent details are invalid.
Common PAR Errors
400 Bad Request
| Error | Cause | Solution |
|---|---|---|
invalid_request | Missing client_id, request, or other required parameters | Verify all parameters are present and correctly formatted |
invalid_request_object | JAR signature invalid, expired, or malformed | Check JAR is signed with PS256; verify iat/exp timestamps within 10 minutes; validate JSON structure |
invalid_authorization_details | Consent details (RAR) malformed or invalid fields | Verify consent_type, dp_id, permissions, and expiration_datetime are correct |
invalid_scope | Invalid scope in JAR | Ensure scope is openid accounts |
401 Unauthorized
| Error | Cause | Solution |
|---|---|---|
invalid_client | Client authentication failed | Verify client_id is correct; check client assertion signature if using private_key_jwt |
invalid_client_assertion | Client assertion JWT invalid or expired | Ensure PS256 signature correct; verify iat and exp within valid range (10 minutes) |
403 Forbidden
| Error | Cause | Solution |
|---|---|---|
unauthorized_client | Client not authorized for this operation | Verify client is registered with PayNet and has OIDC registration approved |
Debugging PAR Request Issues
JAR Signature Invalid
- Confirm signing key is in PEM format
- Verify algorithm is PS256 (not RS256 or other)
- Check
kidin JAR header matches a key in your JWKS endpoint - Validate signed JAR by decoding with PayNet's public key (jwks_uri from well-known)
JAR Timestamps Out of Range
- Generate new JAR with current timestamp:
iat = current Unix timestamp (seconds) - Set
exp = iat + 600(10 minutes) - Verify system clock is synchronized
Consent Details Invalid
dp_idmust match a provider from the Providers endpointpermissionsmust be subset of:read_accounts,read_balances,read_transactionsexpiration_datetimemust be ISO 8601 format future dateconsent_purposemust be:pfmorcredit_underwriting
Retry Strategy
For transient errors:
- 400/401/403: Check request and don't retry (client error)
- 500: Retry with exponential backoff (server error)
- 503: Retry after waiting (service temporarily unavailable)
Next Steps
Once you have the request_uri from the PAR endpoint:
- Section 7: Consent Authorization and Token Exchange - Redirect the user to authorize and handle the callback
Related Documentation
- Client Authentication Methods - Details on both authentication approaches
- Section 5: Discovering Data Providers
- Section 2: Discovering OFP Endpoints via Well-Known
- Previous
- Providers