Ozone

Open Finance Malaysia Developer Portal

Section 8: Exchanging Authorization Code for Tokens

After the user grants consent and you receive the authorization code in your callback, the next step is exchanging that code for access and ID tokens. The access token allows you to retrieve the user's financial data, and the ID token contains verified user identity information.


Overview

This section covers:

  1. Making the token exchange request to the OFP's token endpoint
  2. Understanding the response including access token, ID token, and authorization details
  3. Validating the ID token signature using the OFP's public keys
  4. Handling token lifetime and refresh

Prerequisites

Before making a token exchange request, ensure you have:

ItemWhere to FindNotes
authorization_codeFrom callback in Step 3 of Section 7The code parameter from redirect
code_verifierFrom Step 1 of Section 6PKCE code verifier generated earlier
redirect_uriYour applicationMust match the value in your original JAR request
client_idIssued by PayNetYour technical OIDC client identifier
token_endpointFrom well-known endpointThe OFP's token endpoint URL
Client authentication credentialsFrom Client Authentication MethodsEither private_key_jwt or tls_client_auth
mTLS transport certificate & keyFrom your certificate filesRequired for all requests

Making the Request

Endpoint

POST {{token_endpoint}}

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 (Form-Encoded)

Base parameters (always required):

grant_type=authorization_code
&client_id={{CLIENT_ID}}
&code={{AUTHORIZATION_CODE}}
&redirect_uri={{YOUR_REDIRECT_URI}}
&code_verifier={{PKCE_CODE_VERIFIER}}

Plus client authentication parameters:

  • If using private_key_jwt: See Method 1: private_key_jwt for how to add client_assertion_type and client_assertion
  • If using tls_client_auth: No additional parameters needed; mTLS connection proves identity

Example cURL Request (private_key_jwt)

curl -X POST "{{token_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 "grant_type=authorization_code" \
  -d "client_id={{CLIENT_ID}}" \
  -d "code={{AUTHORIZATION_CODE}}" \
  -d "redirect_uri={{YOUR_REDIRECT_URI}}" \
  -d "code_verifier={{PKCE_CODE_VERIFIER}}" \
  -d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
  -d "client_assertion={{SIGNED_CLIENT_ASSERTION_JWT}}"

Example cURL Request (tls_client_auth)

curl -X POST "{{token_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 "grant_type=authorization_code" \
  -d "client_id={{CLIENT_ID}}" \
  -d "code={{AUTHORIZATION_CODE}}" \
  -d "redirect_uri={{YOUR_REDIRECT_URI}}" \
  -d "code_verifier={{PKCE_CODE_VERIFIER}}"

Response

200 - Success Response

{
  "access_token": "550e8400-e29b-41d4-a716-446655440000",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid accounts",
  "id_token": "eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleTEifQ.eyJpc3MiOiJodHRwczovL2F1dGgxLnNhbmRib3gubXlhLm96b25lYXBpLmlvIiwic3ViIjoiZGVkZmJjM2MtMTAzNy00YzU4LWFjZDAtMDM2YzAxNzE1ZjBmIiwiYXVkIjoiNTUwZTg0MDAtZTI5Yi00MWQ0LWE3MTYtNDQ2NjU1NDQwMDAwIiwiaWF0IjoxNjczNjQ3ODAwLCJleHAiOjE2NzM2NTE0MDB9.signature...",
  "refresh_token": "550e8400-e29b-41d4-a716-446655440001",
  "authorization_details": [
    {
      "type": "urn:openfinance-ml:account-access-consent:v1.2",
      "consent": {
        "consent_id": "550e8400-e29b-41d4-a716-446655440002",
        "dc_id": "550e8400-e29b-41d4-a716-446655440003",
        "dp_id": "550e8400-e29b-41d4-a716-446655440004",
        "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",
        "status": "authorized",
        "accounts": [
          {
            "account_id": "550e8400-e29b-41d4-a716-446655440005",
            "account_number": "1234567890",
            "account_name": "My Savings Account"
          },
          {
            "account_id": "550e8400-e29b-41d4-a716-446655440006",
            "account_number": "0987654321",
            "account_name": "My Checking Account"
          }
        ],
        "created_at": "2025-01-15T10:30:00Z",
        "updated_at": "2025-01-15T10:35:00Z"
      }
    }
  ]
}

Response Fields:

FieldTypeDescription
access_tokenStringOpaque access token for retrieving account data. Use in Authorization: Bearer header
token_typeStringAlways Bearer for OAuth 2.0
expires_inIntegerToken lifetime in seconds (typically 3600 = 1 hour)
scopeStringScopes granted (openid accounts)
id_tokenStringJWT containing user identity information. Must validate signature.
refresh_tokenStringOptional token to obtain new access token after expiration
authorization_detailsArrayRAR object containing consent details, status, and accounts

Authorization Details Fields

The authorization_details field contains critical consent and account information:

FieldDescription
consent_idUnique consent identifier. Use this for consent management operations
statusConsent status. Should be authorized after successful user grant
accountsArray of accounts the user selected. Each has account_id, account_number, account_name
permissionsArray of permissions granted (subset of requested permissions)

Validating the ID Token

The ID token is a signed JWT containing verified information about the authenticated user. You must validate the signature before trusting the token's contents.

ID Token Structure

An ID token is a JWT with three parts (header.payload.signature):

Header:

{
  "alg": "PS256",
  "typ": "JWT",
  "kid": "key1"
}

Payload:

{
  "iss": "https://auth1.sandbox.mya.ozoneapi.io",
  "sub": "dedfbc3c-1037-4c58-acd0-036c01715f0f",
  "aud": "550e8400-e29b-41d4-a716-446655440000",
  "iat": 1673647800,
  "exp": 1673651400
}

Signature: Cryptographic proof using OFP's private key (you verify with their public key)

Validation Steps

  1. Extract the ID token from the response
  2. Decode the JWT (don't trust yet - this is unsigned)
  3. Extract the kid (Key ID) from the header
  4. Fetch OFP's JWKS using the jwks_uri from the well-known endpoint
  5. Find the matching public key by kid
  6. Verify the signature using PS256 algorithm
  7. Validate claims:
    • iss: Must match OFP's issuer (from well-known endpoint)
    • aud: Must match your client_id
    • exp: Must not be in the past (token not expired)
    • iat: Sanity check (issued at a reasonable time)

Example: Validating ID Token (Node.js)

const jwt = require('jsonwebtoken');
const axios = require('axios');

async function validateIdToken(idToken, wellKnownUrl, clientId) {
  try {
    // Step 1: Fetch OFP's well-known configuration
    const wellKnownResponse = await axios.get(wellKnownUrl);
    const jwksUri = wellKnownResponse.data.jwks_uri;
    const issuer = wellKnownResponse.data.issuer;

    // Step 2: Fetch OFP's JWKS (public keys)
    const jwksResponse = await axios.get(jwksUri);
    const jwks = jwksResponse.data;

    // Step 3: Decode without verification to get the header
    const decodedToken = jwt.decode(idToken, { complete: true });
    if (!decodedToken) {
      throw new Error('Invalid JWT structure');
    }

    const kid = decodedToken.header.kid;

    // Step 4: Find the public key with matching kid
    const key = jwks.keys.find(k => k.kid === kid);
    if (!key) {
      throw new Error(`Key with kid "${kid}" not found in JWKS`);
    }

    // Step 5: Convert JWK to PEM format
    const publicKey = jwkToSignatureBuffer(key);  // You'll need a JWK to PEM library

    // Step 6: Verify the signature
    const verified = jwt.verify(idToken, publicKey, {
      algorithms: ['PS256'],
      issuer: issuer,
      audience: clientId
    });

    console.log('ID Token validated successfully');
    console.log('User sub:', verified.sub);
    return verified;

  } catch (error) {
    console.error('ID Token validation failed:', error.message);
    throw error;
  }
}

// Call the function
validateIdToken(idToken, wellKnownUrl, clientId)
  .then(claims => {
    console.log('Trusted user claims:', claims);
  })
  .catch(err => {
    console.error('Authentication failed:', err);
  });

Example: Validating ID Token (Python)

import jwt
import requests
from jose import jwk
from jose.utils import base64url_decode

async def validate_id_token(id_token, well_known_url, client_id):
    try:
        # Step 1: Fetch OFP's well-known configuration
        well_known_response = requests.get(well_known_url)
        well_known = well_known_response.json()
        jwks_uri = well_known['jwks_uri']
        issuer = well_known['issuer']

        # Step 2: Fetch OFP's JWKS (public keys)
        jwks_response = requests.get(jwks_uri)
        jwks = jwks_response.json()

        # Step 3: Decode without verification to get the header
        unverified = jwt.get_unverified_header(id_token)
        kid = unverified.get('kid')

        # Step 4: Find the public key with matching kid
        key_data = None
        for key in jwks['keys']:
            if key['kid'] == kid:
                key_data = key
                break

        if not key_data:
            raise ValueError(f'Key with kid "{kid}" not found in JWKS')

        # Step 5: Convert JWK to PEM and verify
        public_key = jwk.construct(key_data)

        # Step 6: Verify the signature and claims
        verified = jwt.decode(
            id_token,
            public_key.to_pem(),
            algorithms=['PS256'],
            issuer=issuer,
            audience=client_id,
            options={'verify_signature': True}
        )

        print('ID Token validated successfully')
        print(f'User sub: {verified["sub"]}')
        return verified

    except Exception as error:
        print(f'ID Token validation failed: {str(error)}')
        raise

# Call the function
try:
    claims = validate_id_token(id_token, well_known_url, client_id)
    print(f'Trusted user claims: {claims}')
except Exception as e:
    print(f'Authentication failed: {e}')

Using the Access Token

Once you have the access token, proceed to:


Storing and Managing Tokens

Token Storage

  1. Access Token: Store securely in memory or session (valid for ~1 hour)
  2. Refresh Token: Store securely in encrypted storage (valid for longer period)
  3. ID Token: Extract claims and validate (for identity), can be discarded after validation
  4. Consent ID: Store with your application's session (needed for consent operations)

Token Refresh

When the access token expires (after expires_in seconds), use the refresh token:

curl -X POST "{{token_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 "grant_type=refresh_token" \
  -d "client_id={{CLIENT_ID}}" \
  -d "refresh_token={{REFRESH_TOKEN}}"

Common Errors

The token endpoint returns OAuth 2.0 and OpenID Connect error responses when the request fails.

HTTP 400 - Bad Request

Error CodeRFCDescriptionSolution
invalid_requestRFC 6749Request is malformed (missing parameters, invalid format)Verify all required parameters (code, redirect_uri, code_verifier) are present and correctly formatted
invalid_grantRFC 6749Authorization code is invalid, expired, or already usedUser must restart consent flow from Section 6; authorization codes expire after ~10 minutes
unsupported_grant_typeRFC 6749Grant type is not supported (should not occur with authorization_code)Ensure grant_type=authorization_code is used exactly
invalid_scopeRFC 6749Requested scope is invalid or malformedVerify scope matches original JAR request

HTTP 401 - Unauthorized

Error CodeRFCDescriptionSolution
invalid_clientRFC 6749Client authentication failed (invalid client_id or assertion)Verify client_id is correct; if using private_key_jwt, check client assertion signature is valid
invalid_client_assertionRFC 7521Client assertion JWT is invalid or expiredVerify client assertion: check PS256 signature, verify iat and exp are within valid range (10 minutes), check iss matches client_id

HTTP 403 - Forbidden

Error CodeRFCDescriptionSolution
unauthorized_clientRFC 6749Client is not authorized for this operationVerify client is registered with PayNet and authorization code was issued to this client

Validation Errors

ErrorDescriptionSolution
Code Verifier MismatchPKCE code_verifier doesn't match the code_challenge from original JAREnsure code_verifier is the exact same 43-128 character value from Step 1 of Section 6
Redirect URI MismatchRedirect URI in token request doesn't match original JAR requestUse exact same redirect_uri value that was submitted in the authorization request
Invalid ID Token SignatureToken was tampered with or signed by wrong keyReject token and require re-authentication; verify signature validation code is correct
ID Token Validation FailedToken claims validation failed (expired, wrong issuer, wrong audience)Check token is not expired (exp claim); verify iss matches well-known issuer; verify aud matches your client_id

Next Steps

Once you have obtained the access token:



Powered by ozoneapi

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