Ozone

Open Finance Malaysia Developer Portal

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:

  1. Create a PKCE code verifier and code challenge - A security mechanism required by FAPI 2.0
  2. Create a JAR (JSON Authorization Request) - Sign a JWT containing consent details
  3. Submit via PAR endpoint - Push the signed JAR to obtain a request_uri
  4. Redirect user to authorize - User logs in and grants consent at the OFP
  5. 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:

  1. Your application generates a random code_verifier (43-128 characters)
  2. Your application creates a code_challenge by hashing the code_verifier: code_challenge = base64url(SHA256(code_verifier))
  3. You include code_challenge and code_challenge_method (S256) in the authorization request
  4. Later, when exchanging the authorization code for tokens, you send the original code_verifier
  5. The server verifies: SHA256(code_verifier) == code_challenge to 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:

ItemWhere to FindNotes
client_idIssued by PayNetYour OIDC client identifier
dc_idIssued by PayNetYour legal Data Consumer identifier
dp_idFrom Section 5: Discovering Data ProvidersThe provider_id of the target Data Provider
signing-key.pemFrom your certificate filesYour private signing key in PEM format
kidYour JWKS endpointKey ID of your signing certificate
redirect_uriYour applicationThe URI where you'll receive the authorization code
code_challengeFrom Step 1Generated in the PKCE step above

JAR Header

{
  "alg": "PS256",
  "kid": "{{KEY_ID}}"
}

Fields:

  • alg: Algorithm. Must be PS256 (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:

ClaimDescription
issIssuer. Your client_id
audAudience. The OFP Authorization Server URL (from well-known endpoint)
nbfNot before. Unix timestamp before which this JWT cannot be used
expExpiration. Unix timestamp (typically iat + 600 = 10 minutes)
iatIssued at. Current Unix timestamp
jtiJWT ID. Unique UUIDv4 to prevent replay attacks
client_idYour OIDC client ID
response_typeMust be code (authorization code flow)
redirect_uriYour application URI where the authorization code will be sent
scopeSpace-delimited. Must include openid accounts
stateOpaque value for CSRF protection. You'll verify this in the callback.
code_challengePKCE challenge: base64url(SHA-256(code_verifier))
code_challenge_methodMust be S256
response_modeResponse disposition. Can be query or omitted (defaults to query)
authorization_detailsOAuth 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:

FieldDescription
dc_idYour legal Data Consumer ID
dp_idTarget 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_typeType of consent. Must be: urn:openfinance-ml:account-access-consent:v1.2
consent_purposePurpose of consent. Allowed values: pfm (Personal Financial Management), credit_underwriting
permissionsArray of data clusters to access. Allowed values: read_accounts, read_balances, read_transactions
expiration_datetimeWhen 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_type and client_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:

  1. state - You'll verify this matches the callback parameter
  2. code_verifier - You'll use this when exchanging the authorization code for tokens
  3. request_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

ErrorCauseSolution
invalid_requestMissing client_id, request, or other required parametersVerify all parameters are present and correctly formatted
invalid_request_objectJAR signature invalid, expired, or malformedCheck JAR is signed with PS256; verify iat/exp timestamps within 10 minutes; validate JSON structure
invalid_authorization_detailsConsent details (RAR) malformed or invalid fieldsVerify consent_type, dp_id, permissions, and expiration_datetime are correct
invalid_scopeInvalid scope in JAREnsure scope is openid accounts

401 Unauthorized

ErrorCauseSolution
invalid_clientClient authentication failedVerify client_id is correct; check client assertion signature if using private_key_jwt
invalid_client_assertionClient assertion JWT invalid or expiredEnsure PS256 signature correct; verify iat and exp within valid range (10 minutes)

403 Forbidden

ErrorCauseSolution
unauthorized_clientClient not authorized for this operationVerify client is registered with PayNet and has OIDC registration approved

Debugging PAR Request Issues

JAR Signature Invalid

  1. Confirm signing key is in PEM format
  2. Verify algorithm is PS256 (not RS256 or other)
  3. Check kid in JAR header matches a key in your JWKS endpoint
  4. Validate signed JAR by decoding with PayNet's public key (jwks_uri from well-known)

JAR Timestamps Out of Range

  1. Generate new JAR with current timestamp: iat = current Unix timestamp (seconds)
  2. Set exp = iat + 600 (10 minutes)
  3. Verify system clock is synchronized

Consent Details Invalid

  1. dp_id must match a provider from the Providers endpoint
  2. permissions must be subset of: read_accounts, read_balances, read_transactions
  3. expiration_datetime must be ISO 8601 format future date
  4. consent_purpose must be: pfm or credit_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:



Powered by ozoneapi

© 2026 Open Finance Malaysia Developer Portal. All rights reserved.