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:
- Making the token exchange request to the OFP's token endpoint
- Understanding the response including access token, ID token, and authorization details
- Validating the ID token signature using the OFP's public keys
- Handling token lifetime and refresh
Prerequisites
Before making a token exchange request, ensure you have:
| Item | Where to Find | Notes |
|---|---|---|
authorization_code | From callback in Step 3 of Section 7 | The code parameter from redirect |
code_verifier | From Step 1 of Section 6 | PKCE code verifier generated earlier |
redirect_uri | Your application | Must match the value in your original JAR request |
client_id | Issued by PayNet | Your technical OIDC client identifier |
token_endpoint | From well-known endpoint | The OFP's token endpoint URL |
| Client authentication credentials | From Client Authentication Methods | Either private_key_jwt or tls_client_auth |
| mTLS transport certificate & key | From your certificate files | Required 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_typeandclient_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:
| Field | Type | Description |
|---|---|---|
access_token | String | Opaque access token for retrieving account data. Use in Authorization: Bearer header |
token_type | String | Always Bearer for OAuth 2.0 |
expires_in | Integer | Token lifetime in seconds (typically 3600 = 1 hour) |
scope | String | Scopes granted (openid accounts) |
id_token | String | JWT containing user identity information. Must validate signature. |
refresh_token | String | Optional token to obtain new access token after expiration |
authorization_details | Array | RAR object containing consent details, status, and accounts |
Authorization Details Fields
The authorization_details field contains critical consent and account information:
| Field | Description |
|---|---|
consent_id | Unique consent identifier. Use this for consent management operations |
status | Consent status. Should be authorized after successful user grant |
accounts | Array of accounts the user selected. Each has account_id, account_number, account_name |
permissions | Array 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
- Extract the ID token from the response
- Decode the JWT (don't trust yet - this is unsigned)
- Extract the
kid(Key ID) from the header - Fetch OFP's JWKS using the
jwks_urifrom the well-known endpoint - Find the matching public key by
kid - Verify the signature using PS256 algorithm
- Validate claims:
iss: Must match OFP's issuer (from well-known endpoint)aud: Must match yourclient_idexp: 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:
- Section 9: Retrieving Account, Balance & Transaction Information - Use the access token to fetch account data, balances, and transaction history
Storing and Managing Tokens
Token Storage
- Access Token: Store securely in memory or session (valid for ~1 hour)
- Refresh Token: Store securely in encrypted storage (valid for longer period)
- ID Token: Extract claims and validate (for identity), can be discarded after validation
- 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 Code | RFC | Description | Solution |
|---|---|---|---|
invalid_request | RFC 6749 | Request is malformed (missing parameters, invalid format) | Verify all required parameters (code, redirect_uri, code_verifier) are present and correctly formatted |
invalid_grant | RFC 6749 | Authorization code is invalid, expired, or already used | User must restart consent flow from Section 6; authorization codes expire after ~10 minutes |
unsupported_grant_type | RFC 6749 | Grant type is not supported (should not occur with authorization_code) | Ensure grant_type=authorization_code is used exactly |
invalid_scope | RFC 6749 | Requested scope is invalid or malformed | Verify scope matches original JAR request |
HTTP 401 - Unauthorized
| Error Code | RFC | Description | Solution |
|---|---|---|---|
invalid_client | RFC 6749 | Client 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_assertion | RFC 7521 | Client assertion JWT is invalid or expired | Verify client assertion: check PS256 signature, verify iat and exp are within valid range (10 minutes), check iss matches client_id |
HTTP 403 - Forbidden
| Error Code | RFC | Description | Solution |
|---|---|---|---|
unauthorized_client | RFC 6749 | Client is not authorized for this operation | Verify client is registered with PayNet and authorization code was issued to this client |
Validation Errors
| Error | Description | Solution |
|---|---|---|
| Code Verifier Mismatch | PKCE code_verifier doesn't match the code_challenge from original JAR | Ensure code_verifier is the exact same 43-128 character value from Step 1 of Section 6 |
| Redirect URI Mismatch | Redirect URI in token request doesn't match original JAR request | Use exact same redirect_uri value that was submitted in the authorization request |
| Invalid ID Token Signature | Token was tampered with or signed by wrong key | Reject token and require re-authentication; verify signature validation code is correct |
| ID Token Validation Failed | Token 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:
- Retrieve Account Data - Use the token to fetch account details, balances, and transactions
Related Documentation
- Client Authentication Methods - Token exchange authentication options
- Section 7: Consent Authorization - How to obtain the authorization code
- Section 6: Creating a Consent - PKCE code verifier generation
- Section 2: Discovering OFP Endpoints - Well-known endpoint and jwks_uri
- Previous
- Authorizing a Consent