VA
Acceptance

OAuth Setup

End-to-end guide for integrating OAuth client_credentials with private_key_jwt for embedded components.

Client-side JWT signing shown here is for local development only. In production, the token exchange (private key signing) MUST happen on your server.

Overview

The OAuth flow for embedded components is a two-step process:

  1. Step Aclient_credentials grant with private_key_jwt (RFC 7523): Your backend signs a JWT assertion and exchanges it for an access_token at the Visa token endpoint.
  2. Step B — RFC 8693 token exchange: Your backend exchanges the access_token for an ec_token (embedded component token) scoped to a specific component type.

The ec_token is what your getToken() callback returns to the SDK.

┌──────────────┐     ┌──────────────┐     ┌─────────────────┐     ┌───────────────┐
│ Your Backend │────▶│ CGK2 Gateway │────▶│ PDS2 (validates │────▶│ apiauthservice│
│ (signs JWT)  │     │ (mTLS)       │     │ JWT signature)  │     │ (issues token)│
└──────┬───────┘     └──────────────┘     └─────────────────┘     └───────────────┘
       │                                                                    │
       │ access_token (Step A)                                              │
       ▼                                                                    ▼
┌──────────────┐                                                  { access_token }
│ Token        │
│ Exchange     │──── POST /sms/v1/tokens (Step B) ────────────────▶ { ec_token }
└──────┬───────┘
       │ ec_token

┌──────────────┐
│ SDK getToken │ ◀────────────────────────── returns ec_token
│ callback     │
└──────┬───────┘
       │ postMessage AUTH_CREDENTIALS

┌──────────────┐
│ Iframe       │ → Authorization: Bearer <ec_token> on every API call
└──────────────┘

Prerequisites

Register an RSA keypair with KMS

Generate a 2048-bit RSA key and register the public key on celtic KMS:

# Generate PKCS#8 private key
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private-key.pem

# Create CSR (KMS requires CSR format)
openssl req -new -key private-key.pem -out /tmp/my-app.csr -sha256 -subj "/CN=my-app"

# Register with KMS → returns key_id
curl -X POST "https://secureus-kms.ingress.celtic.visa.com:8443/kms/v2/keys-asym" \
  --cert <mtls_cert> --key <mtls_key> \
  -H "Content-Type: application/json" \
  -d '{ "keyInformation": [{ "organizationId": "<your_org>", "cert": "<csr_base64>" }] }'

Save the returned keyId — it becomes the kid in your JWT header.

Register an OAuth client

POST /apiauthservice/v4/oauth/clients
Headers:
  v-c-permissions: OauthCreatePermission
  v-c-associated-id: <your_org>

Body:
{
  "client_name": "My Embedded App",
  "client_type": "confidential",
  "application_type": "service",
  "authorized_grant_types": ["client_credentials"],
  "key_id": "<key_id_from_kms>",
  "key_type": "certificate",
  "key_login": "<your_org>",
  "associated_id": "<your_org>",
  "scope": ["<scope_id>"]
}

Response returns client_id and client_secret.

Links your client to an organization and scopes:

POST /apiauthservice/v4/oauth/client-credential-grants
Headers:
  v-c-permissions: OauthApplicationGrantPermission
  v-ic-eft-orgid: <portfolio_id>

Body:
{
  "clientId": "<client_id>",
  "orgId": "<portfolio_giving_consent>",
  "scope": ["<scope_id>"],
  "platformId": "v4.0",
  "status": "ACTIVE"
}

JWT Assertion Format

The client_assertion is a signed JWT (RS256) proving client identity.

{
  "kid": "<key_id_from_kms>",
  "typ": "JWT",
  "alg": "RS256"
}

Payload

{
  "sub": "<client_id>",
  "aud": "https://<cgk2_host>/oauth2/v4/token",
  "iss": "<your_org>",
  "exp": 1717200600,
  "iat": 1717200300,
  "jti": "<unique_uuid>",
  "scope": "<scope_name>",
  "v-c-merchant-id": "internal",
  "acr": "voice",
  "act": {
    "sub": "<your_org>",
    "org_id": "<your_org>",
    "sub_id": "<user_id>"
  }
}
ClaimValue
subYour client_id from registration
issYour associated_id (partner org)
audFull path to the token endpoint
kidThe key_id from KMS registration
v-c-merchant-id"internal" for service-to-service
expCurrent time + 300 seconds
jtiUnique per request (prevents replay)
act.org_idYour real portfolio ID (the org granting consent)

act.org_id must be a real portfolio ID — the same value used in the client-credential-grant consent. Do NOT use "internal" for this field. Using an incorrect org_id will return a valid access_token but the subsequent EC token exchange will fail with a scope mismatch.

Token Exchange Request

POST https://<cgk2_host>/oauth2/v4/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<signed_jwt>
&scope=<requested_scopes>

Response

{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "transaction_search"
}

EC Token Exchange (Step B)

After obtaining the access_token from Step A, exchange it for a component-scoped ec_token using RFC 8693 token exchange:

POST https://<ec_host>/sms/v1/tokens
Content-Type: application/x-www-form-urlencoded
Authorization: Bearer <access_token>

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<access_token>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&requested_token_type=urn:visa:params:oauth:token-type:ec-token
&component_type=<component_type>

Parameters

ParameterValue
grant_typeurn:ietf:params:oauth:grant-type:token-exchange (RFC 8693)
subject_tokenThe access_token from Step A
subject_token_typeurn:ietf:params:oauth:token-type:access_token
requested_token_typeurn:visa:params:oauth:token-type:ec-token
component_typeboarding, transaction_search, or user_management

Response

{
  "access_token": "eyJ...",
  "issued_token_type": "urn:visa:params:oauth:token-type:ec-token",
  "token_type": "Bearer",
  "expires_in": 1800,
  "component_type": "transaction_search"
}

The ec_token has a longer lifetime (30 minutes) than the initial access_token (5 minutes). Your getToken() callback should return the ec_token, not the raw access_token.

SDK Integration

// Server: /api/embed-token endpoint (Step A + Step B)
import { SignJWT, importPKCS8 } from 'jose';
import { readFileSync } from 'fs';

const TOKEN_ENDPOINT = 'https://<cgk2_host>/oauth2/v4/token';
const EC_TOKEN_ENDPOINT = 'https://<ec_host>/sms/v1/tokens';
const CLIENT_ID = '<client_id>';
const ORG_ID = '<your_org>';  // Must be a real portfolio ID
const KEY_ID = '<key_id_from_kms>';

const privateKeyPem = readFileSync('./private-key.pem', 'utf8');
const privateKey = await importPKCS8(privateKeyPem, 'RS256');

app.post('/api/embed-token', async (req, res) => {
  // Step A: Get access_token via client_credentials + private_key_jwt
  const assertion = await new SignJWT({
    scope: 'transaction_search',
    'v-c-merchant-id': 'internal',
    acr: 'voice',
    act: { sub: ORG_ID, org_id: ORG_ID, sub_id: req.user.id },
  })
    .setProtectedHeader({ alg: 'RS256', kid: KEY_ID, typ: 'JWT' })
    .setSubject(CLIENT_ID)
    .setIssuer(ORG_ID)
    .setAudience(TOKEN_ENDPOINT)
    .setIssuedAt()
    .setExpirationTime('5m')
    .setJti(crypto.randomUUID())
    .sign(privateKey);

  const tokenResp = await fetch(TOKEN_ENDPOINT, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
      client_assertion: assertion,
      scope: 'transaction_search',
    }),
  });

  const { access_token } = await tokenResp.json();

  // Step B: Exchange access_token for ec_token
  const ecResp = await fetch(EC_TOKEN_ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      'Authorization': `Bearer ${access_token}`,
    },
    body: new URLSearchParams({
      grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
      subject_token: access_token,
      subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
      requested_token_type: 'urn:visa:params:oauth:token-type:ec-token',
      component_type: 'transaction_search',
    }),
  });

  const { access_token: ec_token, expires_in } = await ecResp.json();
  res.json({ access_token: ec_token, expires_in });
});
@PostMapping("/api/embed-token")
public ResponseEntity<Map<String, Object>> getEmbedToken() {
    String assertion = Jwts.builder()
        .setHeaderParam("kid", keyId)
        .setSubject(clientId)
        .setIssuer(orgId)
        .setAudience(tokenEndpoint)
        .setIssuedAt(new Date())
        .setExpiration(Date.from(Instant.now().plusSeconds(300)))
        .setId(UUID.randomUUID().toString())
        .claim("scope", "transaction_search")
        .claim("v-c-merchant-id", "internal")
        .signWith(privateKey, SignatureAlgorithm.RS256)
        .compact();

    // Exchange assertion for access token
    MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
    body.add("grant_type", "client_credentials");
    body.add("client_assertion_type",
        "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
    body.add("client_assertion", assertion);

    Map<String, Object> response = restTemplate.postForObject(
        tokenEndpoint, new HttpEntity<>(body, headers), Map.class);

    return ResponseEntity.ok(response);
}
// Frontend: Initialize SDK with OAuth
VisaAcceptance.init({
  auth: {
    type: 'oauth',
    getToken: async () => {
      // Call YOUR backend — never sign JWTs client-side in production
      const resp = await fetch('/api/embed-token', { method: 'POST' });
      const { access_token } = await resp.json();
      return access_token;
    },
    refreshToken: async () => {
      const resp = await fetch('/api/embed-token', { method: 'POST' });
      const { access_token } = await resp.json();
      return access_token;
    },
    expiryBufferSeconds: 60, // Proactive refresh 60s before expiry
  },
});

VisaAcceptance.render('transactions', document.getElementById('container'));

Token Lifecycle

The SDK manages the full token lifecycle automatically:

  1. Initial acquisition — calls getToken() after IFRAME_READY, before component renders
  2. Proactive refresh — if the token is a JWT with exp, schedules refresh expiryBufferSeconds before expiry
  3. Reactive refresh — on AUTH_EXPIRED (401/403 from API), calls refreshToken() or getToken()
  4. Delivery — tokens are passed to the iframe via postMessage (never in URLs)
  5. Injection — inside the iframe, AuthProvider adds Authorization: Bearer <token> to all API requests

Local Development

For local development without a real Visa backend, the embed system includes dev stubs for both steps:

  1. Start vap-proxy (nx serve vap-proxy) — registers both:
    • POST /oauth2/v4/token — Step A stub (client_credentials → access_token)
    • POST /sms/v1/tokens — Step B stub (token exchange → ec_token)
  2. Start embed-host (nx serve embed-host) — serves the demo page
  3. Open http://localhost:8080/demo-oauth.html
  4. The demo page signs JWTs client-side using Web Crypto, exchanges for an access_token, then exchanges for an ec_token

The dev stubs validate JWT structure and claim presence but do NOT verify cryptographic signatures or mTLS. They are for integration testing only.

Dev stub endpoints

EndpointPurposeLifetime
http://localhost:3000/oauth2/v4/tokenIssues mock access_token (HS256)300s
http://localhost:3000/sms/v1/tokensExchanges access_token → mock ec_token1800s

Troubleshooting

ErrorCauseFix
Token exchange failed (HTTP 400)Malformed JWT assertionCheck all required claims are present (iss, sub, aud, exp, iat, jti, scope, v-c-merchant-id)
unsupported_grant_typeWrong grant_type valueMust be exactly client_credentials
invalid_grantJWT decoding failed or claims missingVerify JWT has 3 dot-separated base64url segments
invalid_client (401)Client assertion expiredEnsure exp is in the future
Token exchange timed outNetwork issue or endpoint downCheck vap-proxy is running on port 3000
SDK not loadedEmbed-host not runningStart with nx serve embed-host
Failed to fetch private keyKey file missingRun key generation: npx tsx apps/embed-host/src/generate-dev-key.ts
EC token exchange failed (400)Wrong grant_type for Step BMust be urn:ietf:params:oauth:grant-type:token-exchange
invalid_token on /sms/v1/tokensaccess_token expired or malformedEnsure Step A succeeded and token hasn't expired (5 min lifetime)
invalid_request on /sms/v1/tokensMissing or invalid component_typeMust be one of: boarding, transaction_search, user_management

On this page