CommBank ID + income

Participants · participants/cba-issuer

ISS CommBank issuer

The issuer side decides whether a customer gets a credential offer or a trip through CDR consent. PingAuthorize sits in front of the offers API and returns a 302 into consent when the pod has no income yet, a 409 while it loads, and lets the call through once income is ready. The offers API creates the MATTR offer and doubles as MATTR's claim source.

What it does

  • On Railway the gateway is offers-gateway and reaches pod-runtime and offers-api over private networking. offers-api answers /offers only to the gateway (403 publicly); /claims/mattr stays public for MATTR.
  • Gateway policy is built by build-policy.py (deny-with-302 plus Location header), tested with PAP decision tests and an e2e harness.
  • MATTR VII tenant hackathon1: managed IACA, claim source cba-issuer-claims, auth provider = CBA PingFederate, mDoc configs au.com.cba.commbank-id and au.com.cba.commbank-id-income.
  • No issuer_state on MATTR auth-code offers - the customer is bound with login_hint = sub, and PF's sub flows into the claim source.
  • QR landing serves the listings, the QR, the /rent/verify fallback and the AASA for universal links.

Ports and endpoints

offers-gateway (PingAuthorize 11.1, the app calls this)7401 local · 1080 on Railway
offers-api7400 (/offers gateway-only, /claims/mattr public)
qr-landing7700

Railway

offers-apioffers-api-production.up.railway.app success
offers-gatewayoffers-gateway-production.up.railway.app success
qr-landingqr-landing-production.up.railway.app success

Components and versions

Read from participants/cba-issuer/docker-compose.yml at build time.

ServiceImageHost ports
offers-api(built from repo)7400→7400
offers-gateway(built from repo)7401→1080

participants/cba-issuer/mattr/README.md

MATTR VII - hackathon tenant

Tenant: https://hackathon1.vii.au01.mattr.global (au01). Credentials live in the gitignored root mattr.env (JSON: tenant_url, auth_url, audience, client_id, client_secret).

Setup

MATTR_ENV_FILE=../../../mattr.env python3 setup-tenant.py --dry-run
MATTR_ENV_FILE=../../../mattr.env python3 setup-tenant.py

Idempotent. Needs PyYAML. It creates/updates:

Step Resource Status
1 Managed IACA (MATTR provisions the document signer) 055d4474-ffb7-43f2-8ecb-e854e68a8cc2, active
2 Claim source cba-issuer-claims -> https://offers-api-production.up.railway.app/claims/mattr (x-api-key) c7cc4a5e-7b78-44e7-9a47-d7b735ddb550
3 Auth provider -> CBA PingFederate https://cba-pf-production.up.railway.app (client mattr-vii) ca408707-430c-4811-a1bf-5f54404e0fd4; redirect https://hackathon1.vii.au01.mattr.global/core/v1/oauth/authentication/callback
2b Claim source cba-issuer-identity-claims -> same URL with income=0 (identity only, no pod needed) b55a428d-bd8b-4182-8da2-1fe9684e59b9
4 mDoc configs au.com.cba.commbank-id / au.com.cba.commbank-id-income 434dffc5-c6e8-4c9b-85b2-9a858926b190 -> cba-issuer-identity-claims / fe332445-784c-4b5d-b412-73a4bd463b7e -> cba-issuer-claims
5 Wallet OAuth client check (read-only probe of /v1/oauth/authorize) ios-sample-mobile-credential-holder-app usable

Env for a full run (values in the root .env): OFFERS_API_URL=https://offers-api-production.up.railway.app, ADR_CBA_PF_URL=https://cba-pf-production.up.railway.app, INTERNAL_API_KEY, MATTR_PF_CLIENT_SECRET. A run missing the claim-source vars keeps the configs' existing claim source rather than unlinking it.

The identity config has its own claim source because /claims/mattr also asks the pod for income unless income=0, and fails (404 pod_not_found, 409 not ready) for a customer with no pod - MATTR then answers the wallet's credential request with 503 service_unavailable.

Re-run after steps 2-3 have their env vars: the configs are then updated with the claim source id. The auth-provider response prints the redirectUrl that CBA PF's mattr-vii client must allow (participants/adr-cba/pingfederate/terraform var.mattr_redirect_uris).

Wallet OAuth client (authorization-code issuance)

MATTR's /v1/oauth/authorize only takes a client it knows: anything else gets a 400 HTML page "client is invalid", and an unregistered redirect gets "redirect_uri did not match any of the client's registered redirect_uris". There is no API to register one. MATTR's docs say trusted wallet apps are set up "by contacting MATTR": either a client ID with white-listed redirect URLs (lower assurance), or Wallet Attestation - client ID, the wallet backend's root certificates and whether DPoP is required, configured "through out-of-band processes":

The Platform API spec (v12.2.0) has an "OpenID OAuth Client" tag and a bare /v1/openid/clients/{id} path with no operations; on this tenant GET/POST /v1/openid/clients and GET/PUT/DELETE /v1/openid/clients/{id} all answer 404 Resource Not Found - not enabled. Holder applications (POST /v1/holder/applications, clientId field) are something else: they register a MATTR Holder SDK app with the tenant's SDK backend (licence, App Attest, wallet-attestation tokens). Creating one for cba-hackathon-wallet did not make the client valid at /authorize (tried and deleted).

What works today: every tenant carries the sample clients MATTR's Holder SDK tutorials use (https://learn.mattr.global/docs/holding/credential-claiming-tutorial):

client_id redirect_uri
ios-sample-mobile-credential-holder-app io.mattrlabs.sample.mobilecredentialholderapp://credentials/callback
android-sample-mobile-credential-holder-app same

Public client (token_endpoint_auth_methods_supported: ["none"]), PKCE S256, no client attestation, DPoP optional (ES256; bearer also works). The iOS app uses the iOS one (apps/ios/CBAApp/Config/Base.xcconfig). For anything beyond the hackathon, ask MATTR support to register cba-hackathon-wallet with redirect cbaapp://callback and switch the xcconfig back.

MATTR's authorize also needs the configuration's scope (mso_mdoc:au.com.cba.commbank-id) in scope: openid alone gets through PingFederate and then comes back error=access_denied.

Proving it: test-issuance.py

MATTR_ENV_FILE=../../../mattr.env python3 test-issuance.py [--profile identity|income] [--customer cba-cust-002]
    [--scope ...] [--prompt login] [--no-dpop]

Plain HTTP, as the SDK does it: offer (POST /v1/openid/offers, login_hint) -> metadata -> authorize as the sample client -> CBA PF log-on (customer picker, OTP 000789) -> MATTR -> code -> token (PKCE + DPoP) -> key proof (iss = client_id; MATTR has no nonce endpoint) -> credential -> decoded mDoc namespaces. The income profile needs the customer's pod to be ready, or MATTR returns 503.

templates/ holds the resource bodies. platform-config/ is the old cba-demo1 mirror used by plan.sh (mattrctl) - not used for hackathon1.

Railway (project cba-hackathon)

Shared secrets (INTERNAL_API_KEY, OFFERS_SESSION_SECRET, MATTR_PF_CLIENT_SECRET, CBA_PF_ADMIN_PASSWORD) are in the gitignored root .env.

participants/cba-issuer/gateway/deploy/README.md

offers-gateway (PingAuthorize)

PingAuthorize 11.1 as an API gateway in front of offers-api, in embedded PDP mode with policy/cba-offers.deploymentpackage baked into the image. There's no Policy Editor at runtime.

Image deploy/gateway.Dockerfile, build context participants/cba-issuer/gateway
Port container 1080 (plain HTTP, Gateway servlet only), local 7401
Railway service offers-gateway, https://offers-gateway-production.up.railway.app (target port 1080). offers-api stays public for /claims/mattr, but answers /offers only over the private network (OFFERS_GATEWAY_ONLY).

Env

Var What
OFFERS_API_URL where permitted requests go, e.g. http://offers-api.railway.internal:7400
POD_RUNTIME_URL the policy's PodStatus information point
INTERNAL_API_KEY sent as Authorization: Bearer … to pod-runtime and to offers-api /internal/consent-location
CBA_JWKS_BASE_URL + CBA_JWKS_PATH CBA_JWKS_URL split in two, e.g. https://adr-cba-pf… + /ext/cba/jwks, or {ADR_CDR_KIT_URL} + /dev/jwks.json
PING_IDENTITY_DEVOPS_USER / _KEY evaluation licence, fetched on every start. Set them yourself; never bake them.

The three service values reach the policy as Gateway.podRuntimeUrl, Gateway.offersApiUrl and Gateway.internalAuthorization, through the endpoint's policy-request-attribute. That's how one package serves every environment.

Caveat: this means the internal key travels inside the policy request, so it can show up in PAZ decision or trace logs. Keep debug tracing off on Railway.

What it answers

Request Answer
no or invalid token 401 {"status":401,"error":"invalid_token",…}
commbank-id-income, pod created, loading or loaded 409 {"status":409,"error":"pod_loading","state":"loading"}
commbank-id-income, pod none, destroyed or failed (or pod-runtime down) 302 Location: {ADR_CDR_KIT_URL}/consent/start?return=cbaapp%3A%2F%2Fconsent-complete&listingId=…&session=… + {"error":"consent_required","state":…}
commbank-id, or income with pod income_ready forwarded to offers-api, which returns 200 {credentialOffer}

offers-api signs the 302 session handle (/internal/consent-location) because PAZ can't sign HS256 itself. The policy only fetches it when the 302 rule fires.

Changing the policy

# a Policy Editor in demo mode (e.g. the local pbd-pap on :47443), and the stub where it can reach it
node tests/stub-upstreams.mjs &
./build-policy.py --export policy                # rebuilds; runs 9 decision tests; exports only if all pass
docker build -f deploy/gateway.Dockerfile -t cba-offers-gateway .

The branch is seeded from policy/cba-offers.snapshot. The first build used --seed idp-pingauthorize/conformance/policy/authzen-certification.snapshot.

Run and test locally

node tests/stub-upstreams.mjs &                  # :7491 fake pod-runtime + JWKS + MATTR
(cd ../offers && PORT=7400 OFFERS_LOCAL_PDP=false CBA_JWKS_URL=http://localhost:7491/jwks.json \
  POD_RUNTIME_URL=http://localhost:7491 INTERNAL_API_KEY=stub-internal-key \
  OFFERS_SESSION_SECRET=«redacted»
  MATTR_TENANT_URL=http://localhost:7491 MATTR_AUTH_URL=http://localhost:7491 MATTR_CLIENT_ID=x MATTR_CLIENT_SECRET=y \
  npx tsx src/server.ts &)
set -a; . ~/.pingidentity/config; set +a
docker run -d --name cba-offers-gw -e PING_IDENTITY_DEVOPS_USER -e PING_IDENTITY_DEVOPS_KEY \
  -e OFFERS_API_URL=http://host.docker.internal:7400 -e POD_RUNTIME_URL=http://host.docker.internal:7491 \
  -e INTERNAL_API_KEY=stub-internal-key -e CBA_JWKS_BASE_URL=http://host.docker.internal:7491 \
  -e CBA_JWKS_PATH=/jwks.json -p 127.0.0.1:7401:1080 cba-offers-gateway
python3 -m unittest discover -s tests -p 'test_gateway.py' -v   # 7 end-to-end tests

Railway

Deployed to project cba-hackathon, environment production, service offers-gateway:

# redeploy, from the repo root
railway up ./participants/cba-issuer/gateway --path-as-root --service offers-gateway --detach
# prove it with a real CBA PF log-on (cba-cust-001 / 000789): 401, 200, 302, and direct offers-api 403
participants/cba-issuer/gateway/scripts/smoke-railway.sh

offers-api behind the gateway

offers-api runs with OFFERS_LOCAL_PDP=false (PAZ decides) and OFFERS_GATEWAY_ONLY=true. It's still publicly reachable, because MATTR has to call /claims/mattr. So /offers checks where the connection came from as well as verifying the CBA token itself. Railway's public edge connects from 100.64.0.0/10, and the gateway connects over the private network (10/8, fd00::/8). Anything not from a private address gets 403 {"error":"gateway_only"}. /claims/mattr (x-api-key), /internal/consent-location (internal key) and /health aren't affected.

I chose this over a shared header from PAZ. A header would need a modify-headers statement on the permit path and a new deployment package from a Policy Editor, and the socket address can't be forged through the edge. Even without the lock, the claim source refuses income without a ready pod, so going round the gateway could never issue an income credential.

To go back to running without PAZ, set OFFERS_LOCAL_PDP=true and OFFERS_GATEWAY_ONLY=false on offers-api.

Config browser

Read-only, from files tracked in git. Keys, keystores, .sec/, real env files and anything gitignored are left out; secret-looking values are shown as «redacted». 63 files.