Loyalty platform · M2M integration · architecture review

How partner data travels between a POS client and WHEEL — under each authentication approach

Six chapters, one per approach. Every chapter walks the same seven stages — actors, onboarding, token issue, request pipeline, sync /rs/v2/sync/*, transactions, revocation, and the proposed data-out flow — so the approaches can be compared stage by stage. Chapter A is what runs today (drawn from the code); B–F are what would change if that approach were adopted.

ACognito client credentials + HMAC — as implemented today

The partner holds a reusable clientSecret. auth-service proxies Cognito's client_credentials grant and the pre-token lambda embeds allowed_apis into a 15-minute token.

Shared secret to partnerYes — clientSecret
Token issuerCognito (proxied by auth-service)
New componentsNone
API Gateway changeNone

Actors

ActorRole
Partner POSM2M client (Samsung, Apple, POS vendor). Holds clientId + clientSecret.
WHEEL AdminOperator with a wheel-admin token. Onboards and manages clients.
API GatewayAWS API Gateway + Lambda authorizer (JWT signature / issuer / audience / expiry).
auth-service/v1/m2m-clients CRUD and /v1/m2m-token/refresh-token.
Cognito + pre-token lambdaM2M user pool; lambda embeds allowed_apis, role=m2m-client.
DynamoDB m2m_clientsRegistry: client, allowed APIs, secret hash, status.
retailer-service · transactions-serviceHost /rs/v2/sync/* and /v1/transactions.
RabbitMQ · search-service · customer-serviceEvent fan-out targets.

0Onboarding

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor Admin as WHEEL Admin
  participant GW as API Gateway
  participant Auth as auth-service
  participant Cog as Cognito (M2M pool)
  participant DDB as DynamoDB m2m_clients
  actor POS as Partner POS
  Admin->>GW: POST /v1/m2m-clients {clientName, allowedApis, workflows}
  Note right of Admin: Bearer wheel-admin + X-HMAC-Code
  GW->>Auth: authorizer ok
  Auth->>Cog: ensure shared scope m2m-client, CreateUserPoolClient (client_credentials, TTL 15 min)
  Cog-->>Auth: clientId + clientSecret
  Auth->>DDB: PutItem {client_id, allowed_apis, workflows, client_secret_hash, client_secret_enc, status ACTIVE}
  Auth-->>Admin: 201 {clientId, clientSecret}  (one time only)
  Admin-->>POS: hand over clientId + clientSecret (secure channel)
  Note over POS: Stores the secret in plain form. This is the step the architect wants removed.
A-0 — The reusable secret leaves WHEEL as a string.

1Issue token

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  participant GW as API Gateway
  participant Auth as auth-service
  participant DDB as DynamoDB m2m_clients
  participant Cog as Cognito /oauth2/token
  participant Pre as Pre-token lambda
  POS->>GW: POST /v1/m2m-token/refresh-token {clientId, clientSecretHash = SHA256(secret)}
  GW->>Auth: public route
  Auth->>DDB: GetItem client_id
  DDB-->>Auth: record
  Auth->>Auth: status ACTIVE, hash matches, decrypt client_secret_enc
  Auth->>Cog: grant_type=client_credentials, client_id, client_secret
  Cog->>Pre: pre-token trigger
  Pre->>DDB: GetItem client_id
  Pre-->>Cog: claims role=m2m-client, allowed_apis[...]
  Cog-->>Auth: access_token (exp 15 min)
  Auth->>Auth: hmacCode = HMAC-SHA256(HMAC_SECRET_KEY, accessToken)
  Auth-->>POS: 200 {accessToken, expiresIn, tokenType Bearer, hmacCode}
  Note over POS: Repeat after expiry. No refresh-token grant exists for client_credentials.

2Request pipeline

API GatewayTLS termination (server cert only)
Lambda authorizerJWT sig / iss / aud / exp against Cognito JWKS
CognitoAuthGuardDecode JWT, X-HMAC-Code = HMAC(secret, token)
M2mEnforcementGuardBlocklist by client_id + iat; route ∈ allowed_apis
PartnerApiHistoryLog call (DISTRIBUTOR / RETAILER / STORE)
Controller → ServiceBusiness logic
Headers on every call

Authorization: Bearer <accessToken> · X-HMAC-Code: <hmacCode>

3Sync distributor → retailer → store

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  participant GW as API Gateway + authorizer
  participant RS as retailer-service
  participant DB as Retailer DB
  participant MQ as RabbitMQ
  participant SS as search-service → OpenSearch
  POS->>GW: POST /rs/v2/sync/distributor {externalId, name, status NEW|MODIFY|DELETE}  [Bearer + X-HMAC-Code]
  GW->>RS: authorizer ok → guards ok → history(DISTRIBUTOR)
  RS->>DB: upsert distributor
  RS-->>POS: 201 {distributorId}  (409 if external id exists)
  POS->>GW: POST /rs/v2/sync/retailer {distributorExternalId, retailer, status}
  GW->>RS: pipeline ok → history(RETAILER)
  RS->>DB: create / update / soft-delete retailer
  RS-->>POS: 201 {retailerId}  (404 if distributor missing)
  POS->>GW: POST /rs/v2/sync/store {retailerExternalId, store, geo_coordinates, working_hours, status}
  GW->>RS: pipeline ok → history(STORE)
  RS->>DB: create or update store
  RS-)MQ: emit store.created / store.updated
  RS-->>POS: 200 {storeId}
  MQ-)SS: index store (location geo_point from geo_coordinates[0])
A-3 — Order is enforced by the service: distributor before retailer, retailer before store.

4Transactions

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  participant GW as API Gateway + authorizer
  participant TX as transactions-service
  participant TDB as Transactions DB
  participant MQ as RabbitMQ
  participant CS as customer-service · notification-service
  POS->>GW: POST /v1/transactions {TransactionType 0 REDEEM | 1 COLLECT | 2 REFUND, storeId, customer ref, amount}  [Bearer + X-HMAC-Code]
  GW->>TX: authorizer ok → CognitoAuthGuard → M2mEnforcementGuard
  TX->>TX: validate store / customer / coupon, compute points or cashback
  TX->>TDB: persist (REFUND references DocumentGuidRef)
  TX-->>POS: 201 {transactionId, status, points}
  TX-)MQ: emit transaction.completed
  MQ-)CS: wallet update, push notification
  opt Reads
    POS->>GW: GET /v1/transactions or /v1/receipts
    GW->>TX: same pipeline
    TX-->>POS: 200 list
  end
A-4 — POST /v1/transactions is currently @Roles(RETAIL_FORCE_ONLY); a dynamic client carries role=m2m-client, so that decorator must be relaxed for partners regardless of approach.

5Revocation

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor Admin as WHEEL Admin
  participant Auth as auth-service
  participant DDB as DynamoDB m2m_clients
  participant MQ as RabbitMQ fanout
  participant Svc as every service instance
  actor POS as Partner POS
  Admin->>Auth: PATCH /v1/m2m-clients/{clientId} {allowedApis | status disabled}  or DELETE
  Auth->>DDB: UpdateItem / DeleteItem (+ Cognito Update/DeleteUserPoolClient)
  Auth-)MQ: auth.m2m.client.revoked {clientId, revokedAt}
  MQ-)Svc: blocklist clientId in memory (expires after token TTL)
  POS->>Svc: call with token minted before revokedAt
  Svc-->>POS: 401
  POS->>Auth: POST /v1/m2m-token/refresh-token
  Auth-->>POS: new token with new grants — or rejected if disabled
  Note over Admin,POS: A leaked clientSecret must be rotated by deleting and re-onboarding the client.

6Data out (proposed)

Two shapes. Both authorise the partner the same way steps 1–2 do; only the direction changes.

Option A · WHEEL pushes (webhook)

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#F7EBDA","primaryBorderColor":"#A8681C","actorBkg":"#F7EBDA","actorBorder":"#A8681C","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor Admin as WHEEL Admin
  participant Auth as auth-service
  participant DDB as DynamoDB m2m_clients
  participant MQ as RabbitMQ
  participant Out as outbound-sync worker (new)
  actor POS as Partner endpoint
  Admin->>Auth: PATCH /v1/m2m-clients/{clientId} {webhookUrl, webhookSecret, workflows [transaction-export, store-export]}
  Auth->>DDB: store subscription
  MQ-)Out: transaction.completed / store.updated
  Out->>DDB: subscribers for this workflow
  Out->>POS: POST webhookUrl {eventId, event, payload, ts}   X-WHEEL-Signature = HMAC-SHA256(webhookSecret, body)
  POS-->>Out: 2xx
  Out->>Out: non-2xx → retry with backoff → dead-letter + alert
A-6A — Note this introduces a second shared secret (webhookSecret) in the other direction.

Option B · Partner pulls (export API)

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#F7EBDA","primaryBorderColor":"#A8681C","actorBkg":"#F7EBDA","actorBorder":"#A8681C","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  participant GW as API Gateway + authorizer
  participant Exp as export endpoint (new)
  participant DB as Service DB
  POS->>GW: GET /v1/export/transactions?since=...&cursor=...   [Bearer + X-HMAC-Code, GET:/v1/export/** in allowedApis]
  GW->>Exp: pipeline ok
  Exp->>DB: rows for this client_id only, since watermark, page by cursor
  Exp-->>POS: 200 {items[], nextCursor, asOf}
  loop until nextCursor is null
    POS->>GW: GET ... cursor=nextCursor
    Exp-->>POS: next page
  end

BmTLS (CA-issued client certificate) + Cognito

API Gateway's custom domain requires a client certificate on every connection. The certificate authenticates the connection; the Cognito token still authorises the call. The clientSecret is still needed to mint the token.

Shared secret to partnerStill yes — for the Cognito token
Token issuerCognito (unchanged)
New componentsPrivate CA (ACM PCA or partner CA), S3 truststore, cert fields in registry
API Gateway changeEnable mTLS on custom domain; authorizer reads clientCert

Actors

ActorWhat changes vs A
Partner POSAdditionally holds a private key + CA-issued client certificate. Private key never leaves the POS.
Private CANew. ACM Private CA (WHEEL-owned) or the partner's corporate CA whose root WHEEL trusts.
S3 truststoreNew. PEM bundle of trusted CA certs referenced by the API Gateway custom domain.
API GatewayCustom domain with mTLS enabled; rejects handshakes whose cert does not chain to the truststore; forwards cert to the authorizer.
Lambda authorizerAlso reads requestContext.identity.clientCert and checks fingerprint ↔ registry.
DynamoDB m2m_clientsAdds cert_fingerprint, cert_serial, cert_not_after.

0Onboarding

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  actor Admin as WHEEL Admin
  participant Auth as auth-service
  participant CA as Private CA (ACM PCA)
  participant S3 as S3 truststore
  participant GW as API Gateway custom domain
  participant Cog as Cognito
  participant DDB as DynamoDB m2m_clients
  POS->>POS: generate key pair (private key stays on device / HSM)
  POS->>Admin: CSR (contains public key, CN = samsung-pos-001)
  Admin->>Auth: POST /v1/m2m-clients {clientName, allowedApis, workflows, csr}
  Auth->>CA: IssueCertificate(csr, validity 1 year)
  CA-->>Auth: client certificate (chained to WHEEL CA)
  Auth->>Cog: CreateUserPoolClient (client_credentials)
  Cog-->>Auth: clientId + clientSecret
  Auth->>DDB: PutItem {client_id, allowed_apis, cert_fingerprint, cert_serial, cert_not_after, client_secret_hash, status ACTIVE}
  Note over Auth,GW: One-time setup: CA root cert placed in S3 truststore, custom domain mTLS enabled pointing at it
  Auth-->>Admin: 201 {clientId, clientSecret, certificatePem}
  Admin-->>POS: certificate + clientId + clientSecret
  Note over POS: Secret is still a string handed over. Certificate is public — only the key is sensitive.
B-0 — If the partner's own corporate CA is used instead, their CA root goes into the truststore and steps 3–5 collapse to "register the cert fingerprint".

1Issue token

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  participant GW as API Gateway (mTLS domain)
  participant Auth as auth-service
  participant DDB as DynamoDB m2m_clients
  participant Cog as Cognito + pre-token lambda
  POS->>GW: TLS handshake — presents client certificate, proves possession of private key
  GW->>GW: verify chain against S3 truststore, not expired, not on CRL
  POS->>GW: POST /v1/m2m-token/refresh-token {clientId, clientSecretHash}
  GW->>Auth: forward + clientCert fingerprint in request context
  Auth->>DDB: GetItem client_id
  Auth->>Auth: cert_fingerprint matches presented cert? status ACTIVE? hash matches?
  Auth->>Cog: client_credentials (client_id, client_secret)
  Cog-->>Auth: access_token (role m2m-client, allowed_apis)
  Auth-->>POS: 200 {accessToken, expiresIn, hmacCode}
  Note over POS: Two factors now: something possessed (private key) + something known (secret). The secret is still shared.

2Request pipeline

TLS handshakeClient cert required; chain to truststore; CRL check
Lambda authorizerJWT as before + cert fingerprint ↔ registry(client_id in token)
CognitoAuthGuardHMAC check
M2mEnforcementGuardBlocklist + allowed_apis
PartnerApiHistoryLog
Controller → ServiceBusiness logic
On every call

TLS: client certificate + private key · HTTP: Authorization: Bearer <accessToken> · X-HMAC-Code

3Sync distributor → retailer → store

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  participant GW as API Gateway (mTLS)
  participant LA as Lambda authorizer
  participant RS as retailer-service
  participant MQ as RabbitMQ → search-service
  POS->>GW: mTLS handshake (client cert)
  GW->>GW: cert chains to truststore
  POS->>GW: POST /rs/v2/sync/distributor {externalId, name, status}  [Bearer + X-HMAC-Code]
  GW->>LA: JWT + clientCert
  LA->>LA: JWT valid, cert fingerprint == registry[client_id].cert_fingerprint
  LA-->>GW: allow
  GW->>RS: guards ok → upsert distributor
  RS-->>POS: 201 {distributorId}
  POS->>GW: POST /rs/v2/sync/retailer {distributorExternalId, retailer, status}   (same connection, cert already verified)
  GW->>RS: authorizer + guards ok → create / update / soft-delete retailer
  RS-->>POS: 201 {retailerId}
  POS->>GW: POST /rs/v2/sync/store {retailerExternalId, store, geo_coordinates, working_hours, status}
  GW->>RS: authorizer + guards ok → create or update store
  RS-)MQ: store.created / store.updated → index
  RS-->>POS: 200 {storeId}
  Note over POS,GW: A stolen access token cannot be replayed from another machine — the TLS handshake fails without the private key.

4Transactions

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  participant GW as API Gateway (mTLS)
  participant TX as transactions-service
  participant MQ as RabbitMQ → customer / notification
  POS->>GW: mTLS handshake (client cert)
  POS->>GW: POST /v1/transactions {TransactionType, storeId, customer ref, amount}  [Bearer + X-HMAC-Code]
  GW->>GW: authorizer: JWT + cert fingerprint ↔ registry
  GW->>TX: guards ok → validate → persist
  TX-->>POS: 201 {transactionId, status, points}
  TX-)MQ: transaction.completed → wallet, notification

5Revocation

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor Admin as WHEEL Admin
  participant Auth as auth-service
  participant CA as Private CA
  participant S3 as S3 truststore / CRL
  participant GW as API Gateway
  participant DDB as DynamoDB m2m_clients
  participant MQ as RabbitMQ fanout → services
  actor POS as Partner POS
  Admin->>Auth: PATCH /v1/m2m-clients/{clientId} {status disabled}  or DELETE  or POST .../rotate-certificate
  Auth->>CA: RevokeCertificate(serial)
  CA->>S3: publish updated CRL
  Auth->>DDB: status DISABLED (or new cert_fingerprint on rotation)
  Auth-)MQ: auth.m2m.client.revoked → blocklist
  POS->>GW: mTLS handshake with revoked cert
  GW-->>POS: TLS failure (cert on CRL) — request never reaches a service
  Note over Admin,POS: Grant changes (allowedApis) still use the token blocklist + re-mint path from A-5. Rotation: new CSR → new cert → old serial revoked after overlap window.
B-5 — CRL propagation on API Gateway is not instant; the token blocklist remains the fast path for grant changes.

6Data out (proposed)

Option A · WHEEL pushes over mTLS

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#F7EBDA","primaryBorderColor":"#A8681C","actorBkg":"#F7EBDA","actorBorder":"#A8681C","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  participant MQ as RabbitMQ
  participant Out as outbound-sync worker (new)
  participant DDB as DynamoDB m2m_clients
  actor POS as Partner mTLS endpoint
  Note over Out,POS: Setup: WHEEL holds an outbound client certificate issued by the same CA — partner adds WHEEL CA root to its truststore
  MQ-)Out: transaction.completed / store.updated
  Out->>DDB: subscribers + webhookUrl
  Out->>POS: TLS handshake — WHEEL presents its client certificate
  POS->>POS: verify chain to WHEEL CA
  Out->>POS: POST webhookUrl {eventId, event, payload, ts}
  POS-->>Out: 2xx
  Note over Out,POS: No webhookSecret needed — the certificate proves origin. Retry / dead-letter as in A-6A.

Option B · Partner pulls

Identical to A-6B, over the mTLS connection: GET /v1/export/… with Bearer + X-HMAC-Code; the authorizer additionally matches the cert fingerprint.

CSelf-signed client certificate + mTLS + Cognito

Same runtime as B, but no CA: the partner generates its own certificate and WHEEL trusts that specific certificate by placing it in the truststore and pinning its fingerprint in the registry. Cheapest way to get mTLS; still needs the Cognito secret.

Shared secret to partnerStill yes — for the Cognito token
Token issuerCognito (unchanged)
New componentsS3 truststore bundle managed by auth-service; cert fields in registry
API Gateway changeEnable mTLS; re-point truststore version on each onboard/revoke

Actors

ActorWhat changes vs B
Partner POSGenerates its own key pair and self-signed certificate. Sends the certificate (public) to WHEEL.
Private CANot needed.
auth-serviceOwns the truststore bundle: appends / removes leaf certs and bumps the API Gateway truststore version.
S3 truststoreContains the partners' leaf certificates directly (API Gateway supports self-signed certs in the bundle).
Lambda authorizerLooks the client up by fingerprint — the fingerprint is the identity, since there is no CA-issued subject to trust.

0Onboarding

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  actor Admin as WHEEL Admin
  participant Auth as auth-service
  participant S3 as S3 truststore bundle
  participant GW as API Gateway custom domain
  participant Cog as Cognito
  participant DDB as DynamoDB m2m_clients
  POS->>POS: openssl req -x509 -newkey ec ... → private key + self-signed cert (CN samsung-pos-001, 1 year)
  POS->>Admin: certificate PEM (public)
  Admin->>Auth: POST /v1/m2m-clients {clientName, allowedApis, workflows, certificatePem}
  Auth->>Auth: parse cert, fingerprint = SHA-256(DER), check not_after
  Auth->>S3: append cert to truststore.pem (new object version)
  Auth->>GW: UpdateDomainName truststoreVersion = new version
  Auth->>Cog: CreateUserPoolClient
  Cog-->>Auth: clientId + clientSecret
  Auth->>DDB: PutItem {client_id, allowed_apis, cert_fingerprint, cert_not_after, client_secret_hash, status ACTIVE}
  Auth-->>Admin: 201 {clientId, clientSecret}
  Admin-->>POS: clientId + clientSecret
  Note over POS: Private key never left the POS. Secret still did.
C-0 — The registry, not a CA, is the trust anchor: a certificate is valid because its fingerprint is registered and it sits in the bundle.

1Issue token

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  participant GW as API Gateway (mTLS domain)
  participant Auth as auth-service
  participant DDB as DynamoDB m2m_clients
  participant Cog as Cognito + pre-token lambda
  POS->>GW: TLS handshake — presents self-signed certificate
  GW->>GW: cert is present verbatim in truststore bundle → accept
  POS->>GW: POST /v1/m2m-token/refresh-token {clientId, clientSecretHash}
  GW->>Auth: forward + clientCert
  Auth->>DDB: GetItem client_id
  Auth->>Auth: SHA-256(presented cert) == cert_fingerprint? status ACTIVE? hash matches?
  Auth->>Cog: client_credentials (client_id, client_secret)
  Cog-->>Auth: access_token (role m2m-client, allowed_apis)
  Auth-->>POS: 200 {accessToken, expiresIn, hmacCode}

2Request pipeline

TLS handshakeClient cert must be in truststore bundle (exact match)
Lambda authorizerJWT as before + SHA-256(cert) ↔ registry[client_id].cert_fingerprint
CognitoAuthGuardHMAC check
M2mEnforcementGuardBlocklist + allowed_apis
PartnerApiHistoryLog
Controller → ServiceBusiness logic

3Sync distributor → retailer → store

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  participant GW as API Gateway (mTLS)
  participant LA as Lambda authorizer
  participant RS as retailer-service
  participant MQ as RabbitMQ → search-service
  POS->>GW: mTLS handshake (self-signed cert)
  GW->>GW: cert ∈ truststore bundle
  POS->>GW: POST /rs/v2/sync/distributor {...}  [Bearer + X-HMAC-Code]
  GW->>LA: JWT + clientCert
  LA->>LA: SHA-256(cert) == registry[token.client_id].cert_fingerprint
  GW->>RS: guards ok → upsert distributor
  RS-->>POS: 201 {distributorId}
  POS->>GW: POST /rs/v2/sync/retailer {...}
  GW->>RS: authorizer + guards ok → retailer
  RS-->>POS: 201 {retailerId}
  POS->>GW: POST /rs/v2/sync/store {...}
  GW->>RS: authorizer + guards ok → store
  RS-)MQ: store.created / updated → index
  RS-->>POS: 200 {storeId}

4Transactions

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  participant GW as API Gateway (mTLS)
  participant TX as transactions-service
  participant MQ as RabbitMQ → customer / notification
  POS->>GW: mTLS handshake (self-signed cert)
  POS->>GW: POST /v1/transactions {...}  [Bearer + X-HMAC-Code]
  GW->>GW: authorizer: JWT + fingerprint ↔ registry
  GW->>TX: guards ok → validate → persist
  TX-->>POS: 201 {transactionId, status, points}
  TX-)MQ: transaction.completed

5Revocation

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor Admin as WHEEL Admin
  participant Auth as auth-service
  participant S3 as S3 truststore bundle
  participant GW as API Gateway
  participant DDB as DynamoDB m2m_clients
  participant MQ as RabbitMQ fanout → services
  actor POS as Partner POS
  Admin->>Auth: PATCH /v1/m2m-clients/{clientId} {status disabled}  or  {certificatePem: new}  (rotation)
  Auth->>S3: rebuild truststore.pem without old cert (with new cert on rotation) → new version
  Auth->>GW: UpdateDomainName truststoreVersion
  Auth->>DDB: status DISABLED / new cert_fingerprint
  Auth-)MQ: auth.m2m.client.revoked → blocklist
  POS->>GW: handshake with removed cert
  GW-->>POS: TLS failure
  Note over Auth,GW: No CRL — removal from the bundle is the revocation. Truststore update takes effect on the next deployment of the domain config (minutes).

6Data out (proposed)

Same two options as B-6. For push, WHEEL uses its own self-signed outbound certificate and the partner pins its fingerprint — the mirror image of onboarding. For pull, A-6B over mTLS.

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#F7EBDA","primaryBorderColor":"#A8681C","actorBkg":"#F7EBDA","actorBorder":"#A8681C","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  participant Out as outbound-sync worker (new)
  actor POS as Partner mTLS endpoint
  Note over Out,POS: Setup: WHEEL shares its self-signed outbound cert — partner pins its fingerprint
  Out->>POS: TLS handshake — WHEEL presents self-signed cert
  POS->>POS: SHA-256(cert) == pinned WHEEL fingerprint
  Out->>POS: POST webhookUrl {eventId, event, payload, ts}
  POS-->>Out: 2xx

Dprivate_key_jwt (RFC 7523) — WHEEL issues its own M2M tokens

The partner authenticates by signing a short-lived JWT (client_assertion) with its private key; WHEEL verifies it with the registered public key and mints an access token signed with WHEEL's own key. No secret is ever handed to the partner. Cognito is bypassed for partner tokens because it does not support this client-auth method.

Shared secret to partnerNone
Token issuerWHEEL M2M Auth (in auth-service), signing key in KMS
New componentsToken endpoint, JWKS endpoint, jti replay cache, KMS signing key
API Gateway changeAdd WHEEL issuer + JWKS to authorizer auth.config.ts

Actors

ActorWhat changes vs A
Partner POSHolds an RSA/EC private key. Builds and signs a client_assertion for each token request.
WHEEL M2M AuthNew module in auth-service: POST /v1/m2m-token (RFC 7523 client auth) and GET /.well-known/jwks.json.
KMS signing keyNew. WHEEL's asymmetric key (e.g. RSA-2048 / ES256). Private half never leaves KMS; public half is published in JWKS.
DynamoDB m2m_clientsStores public_jwk (or jwks_uri) and key_id; no secret fields.
jti replay cacheNew. DynamoDB TTL table or in-memory set of seen assertion ids.
CognitoNot involved for partner tokens (still used for internal service-to-service and user pools).
Lambda authorizerVerifies WHEEL-issued tokens against WHEEL JWKS (new issuer entry).

0Onboarding

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  actor Admin as WHEEL Admin
  participant Auth as auth-service
  participant DDB as DynamoDB m2m_clients
  POS->>POS: generate key pair (RS256 or ES256). Private key → secure store / HSM
  POS->>Admin: public key as JWK {kty, n, e | crv, x, y, kid}  or a JWKS URL
  Admin->>Auth: POST /v1/m2m-clients {clientName, allowedApis, workflows, jwks: [publicJwk]}
  Auth->>Auth: validate JWK, derive kid, reject if private material present
  Auth->>DDB: PutItem {client_id, allowed_apis, workflows, public_jwks, status ACTIVE}
  Auth-->>Admin: 201 {clientId, tokenEndpoint, audience}
  Admin-->>POS: clientId + token endpoint URL + expected audience
  Note over POS,DDB: Nothing secret crosses the boundary. WHEEL only ever holds public keys.

1Issue token

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  participant GW as API Gateway
  participant Auth as WHEEL M2M Auth (auth-service)
  participant DDB as DynamoDB m2m_clients
  participant JTI as jti replay cache
  participant KMS as KMS signing key
  POS->>POS: build client_assertion {iss clientId, sub clientId, aud token endpoint, jti uuid, iat now, exp now+60s}, header {alg, kid}
  POS->>POS: sign with private key
  POS->>GW: POST /v1/m2m-token  grant_type=client_credentials, client_id, client_assertion_type=jwt-bearer, client_assertion
  GW->>Auth: public route
  Auth->>DDB: GetItem client_id
  Auth->>Auth: pick JWK by kid, verify signature, check iss==sub==client_id, aud, exp ≤ 60 s, status ACTIVE
  Auth->>JTI: seen jti? (reject replay) → store with TTL
  Auth->>KMS: Sign access token {iss wheel-auth, sub client_id, aud wheel-api, role m2m-client, allowed_apis, iat, exp +15 min, kid}
  KMS-->>Auth: signature
  Auth->>Auth: hmacCode = HMAC-SHA256(HMAC_SECRET_KEY, accessToken)  (optional — keeps guards unchanged)
  Auth-->>POS: 200 {accessToken, expiresIn 900, tokenType Bearer, hmacCode}
  Note over POS: Re-mint after expiry with a fresh assertion (new jti). Assertions are single-use.
D-1 — Two key pairs: the partner's key authenticates the partner; WHEEL's KMS key signs the access token that API Gateway will verify.
Request
POST /v1/m2m-token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=samsung-pos-001
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJhbGciOiJSUzI1NiIsImtpZCI6InNhbXN1bmctMDEifQ…
Response
200 OK

{
  "accessToken": "eyJraWQiOiJ3aGVlbC0yMDI2LTA5In0…",
  "expiresIn": 900,
  "tokenType": "Bearer",
  "hmacCode": "…"
}

2Request pipeline

API GatewayTLS (server cert)
Lambda authorizeriss = wheel-auth → fetch WHEEL JWKS → verify sig / aud / exp
CognitoAuthGuardDecode JWT (issuer-aware) + HMAC check
M2mEnforcementGuardBlocklist + allowed_apis (claims identical to today)
PartnerApiHistoryLog
Controller → ServiceBusiness logic
On every call

Authorization: Bearer <WHEEL-signed accessToken> · X-HMAC-Code — the same headers as today, so the sync and transaction services need no change beyond trusting the new issuer.

3Sync distributor → retailer → store

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  participant GW as API Gateway
  participant LA as Lambda authorizer
  participant RS as retailer-service
  participant MQ as RabbitMQ → search-service
  POS->>GW: POST /rs/v2/sync/distributor {...}  [Bearer WHEEL token + X-HMAC-Code]
  GW->>LA: token
  LA->>LA: iss wheel-auth → WHEEL JWKS (cached) → signature, aud, exp ok
  GW->>RS: guards ok → upsert distributor
  RS-->>POS: 201 {distributorId}
  POS->>GW: POST /rs/v2/sync/retailer {...}
  GW->>RS: authorizer + guards ok → retailer
  RS-->>POS: 201 {retailerId}
  POS->>GW: POST /rs/v2/sync/store {...}
  GW->>RS: authorizer + guards ok → store
  RS-)MQ: store.created / updated → index
  RS-->>POS: 200 {storeId}

4Transactions

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  participant GW as API Gateway + authorizer (WHEEL JWKS)
  participant TX as transactions-service
  participant MQ as RabbitMQ → customer / notification
  POS->>GW: POST /v1/transactions {...}  [Bearer WHEEL token + X-HMAC-Code]
  GW->>TX: token valid → guards ok → validate → persist
  TX-->>POS: 201 {transactionId, status, points}
  TX-)MQ: transaction.completed

5Revocation and key rotation

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor Admin as WHEEL Admin
  participant Auth as auth-service
  participant DDB as DynamoDB m2m_clients
  participant MQ as RabbitMQ fanout → services
  actor POS as Partner POS
  Admin->>Auth: PATCH /v1/m2m-clients/{clientId} {status disabled | allowedApis | jwks: [newJwk, oldJwk]}
  Auth->>DDB: update record
  Auth-)MQ: auth.m2m.client.revoked → blocklist (existing mechanism)
  POS->>Auth: POST /v1/m2m-token with assertion signed by old key
  Auth-->>POS: 401 invalid_client (key removed) — or 200 if old key is still in the overlap list
  Note over Admin,POS: Partner-side compromise: partner generates a new pair, admin swaps the JWK. No re-onboarding, no secret to rotate. WHEEL-side rotation: add new KMS kid to JWKS, sign with it, retire old kid after 15 min.

6Data out (proposed)

Option A · WHEEL pushes, signed with WHEEL's private key

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#F7EBDA","primaryBorderColor":"#A8681C","actorBkg":"#F7EBDA","actorBorder":"#A8681C","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  participant MQ as RabbitMQ
  participant Out as outbound-sync worker (new)
  participant KMS as KMS signing key
  actor POS as Partner endpoint
  MQ-)Out: transaction.completed / store.updated
  Out->>KMS: sign JWT {iss wheel-auth, aud clientId, jti, iat, exp +5 min, body_hash SHA-256(payload)}
  Out->>POS: POST webhookUrl {eventId, event, payload, ts}   Authorization: Bearer signed JWT
  POS->>POS: fetch WHEEL JWKS (cached) → verify signature, aud, exp, body_hash
  POS-->>Out: 2xx
  Note over Out,POS: Symmetric with inbound: partner verifies WHEEL with a public key. No shared webhook secret.

Option B · Partner pulls

A-6B unchanged — the export endpoint sits behind the same authorizer and guards, using the WHEEL-issued token.

EDPoP (RFC 9449) — sender-constrained tokens on top of D

Client authentication is still private_key_jwt. DPoP adds a second, per-request proof so the access token is bound to a key the partner holds: a stolen token is useless without it. Best fit when token theft in transit or in logs is the threat being addressed.

Shared secret to partnerNone
Token issuerWHEEL M2M Auth (as D) — issues token_type: DPoP
New componentsEverything in D + DPoP proof verification in authorizer/guard + proof jti cache
API Gateway changeAuthorizer must read the DPoP header and verify the proof

Actors

ActorWhat changes vs D
Partner POSHolds two keys: the registered client key (for client_assertion) and a DPoP key (may be generated per device / per session, not registered). Builds a fresh DPoP proof for every HTTP request.
WHEEL M2M AuthAlso verifies the DPoP proof on the token request and embeds cnf.jkt (thumbprint of the DPoP public key) in the token.
Lambda authorizer / DpopGuardNew logic. Verifies the per-request proof: signature with the JWK in the proof header, htm/htu match, ath = hash(token), jkt match, iat window, jti not replayed.
Proof jti cacheNew. Short-TTL store (seconds to minutes) shared across instances — ElastiCache or DynamoDB TTL.

0Onboarding

Identical to D-0: only the client public key is registered. The DPoP key is not registered — it is proven at use time via the jwk carried in every proof and pinned by cnf.jkt in the token.

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  actor Admin as WHEEL Admin
  participant Auth as auth-service
  participant DDB as DynamoDB m2m_clients
  POS->>POS: generate client key pair (registered) and DPoP key pair (per device, unregistered)
  POS->>Admin: client public JWK
  Admin->>Auth: POST /v1/m2m-clients {clientName, allowedApis, workflows, jwks: [clientJwk], dpopRequired: true}
  Auth->>DDB: PutItem {client_id, allowed_apis, public_jwks, dpop_required true, status ACTIVE}
  Auth-->>Admin: 201 {clientId, tokenEndpoint, audience}
  Admin-->>POS: clientId + endpoints

1Issue token

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  participant Auth as WHEEL M2M Auth
  participant DDB as DynamoDB m2m_clients
  participant JTI as jti caches (assertion + proof)
  participant KMS as KMS signing key
  POS->>POS: client_assertion signed with client key (as D-1)
  POS->>POS: DPoP proof {jti, htm POST, htu https://api.wheel.com/v1/m2m-token, iat}, header {typ dpop+jwt, alg ES256, jwk = DPoP public key} — signed with DPoP private key
  POS->>Auth: POST /v1/m2m-token  (form: grant_type, client_id, client_assertion_type, client_assertion)   header DPoP: proof
  Auth->>DDB: GetItem client_id → verify client_assertion with registered JWK (D-1 steps)
  Auth->>Auth: verify DPoP proof: signature with header.jwk, htm == POST, htu == this URL, iat within ±60 s
  Auth->>JTI: assertion jti and proof jti unseen → store
  Auth->>Auth: jkt = base64url(SHA-256(canonical JWK))
  Auth->>KMS: sign access token {..., role m2m-client, allowed_apis, cnf: {jkt}}
  Auth-->>POS: 200 {access_token, token_type DPoP, expires_in 900}
  Note over POS: Token is now bound to the DPoP key. HMAC code becomes redundant — the proof already binds the token to the sender.
DPoP proof (decoded)
header  {"typ":"dpop+jwt","alg":"ES256",
         "jwk":{"kty":"EC","crv":"P-256","x":"…","y":"…"}}
payload {"jti":"e1f2…","htm":"POST",
         "htu":"https://api.wheel.com/v1/m2m-token",
         "iat":1790000000}
Access token claim added
"cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" }
"token_type": "DPoP"

2Request pipeline

API GatewayTLS (server cert)
Lambda authorizerVerify WHEEL JWT (D) + verify DPoP header: sig, htm/htu, ath, jkt == cnf.jkt, iat, jti unseen
DpopGuard (or in authorizer)Same checks in-service for defence in depth; replaces HMAC guard
M2mEnforcementGuardBlocklist + allowed_apis
PartnerApiHistoryLog
Controller → ServiceBusiness logic
On every call

Authorization: DPoP <accessToken> · DPoP: <fresh proof with htm, htu of this request, ath = base64url(SHA-256(accessToken))>

3Sync distributor → retailer → store

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  participant GW as API Gateway
  participant LA as Lambda authorizer (DPoP-aware)
  participant JTI as proof jti cache
  participant RS as retailer-service
  participant MQ as RabbitMQ → search-service
  POS->>POS: proof #1 {htm POST, htu .../rs/v2/sync/distributor, ath, jti, iat}
  POS->>GW: POST /rs/v2/sync/distributor {...}   Authorization: DPoP token, DPoP: proof #1
  GW->>LA: token + proof
  LA->>LA: token sig ok → proof sig ok with header.jwk → thumbprint(jwk) == token.cnf.jkt → htm/htu match → ath == hash(token)
  LA->>JTI: jti unseen → store (TTL 5 min)
  GW->>RS: guards ok → upsert distributor
  RS-->>POS: 201 {distributorId}
  POS->>POS: proof #2 for POST .../rs/v2/sync/retailer
  POS->>GW: POST /rs/v2/sync/retailer {...}   DPoP: proof #2
  GW->>RS: proof verified → retailer
  RS-->>POS: 201 {retailerId}
  POS->>POS: proof #3 for POST .../rs/v2/sync/store
  POS->>GW: POST /rs/v2/sync/store {...}   DPoP: proof #3
  GW->>RS: proof verified → store
  RS-)MQ: store.created / updated → index
  RS-->>POS: 200 {storeId}
  Note over POS,LA: Replaying request #1 from another host fails: the attacker has the token but cannot sign a new proof.

4Transactions

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  participant GW as API Gateway + authorizer (DPoP-aware)
  participant TX as transactions-service
  participant MQ as RabbitMQ → customer / notification
  POS->>POS: proof {htm POST, htu .../v1/transactions, ath, jti, iat}
  POS->>GW: POST /v1/transactions {...}   Authorization: DPoP token, DPoP: proof
  GW->>GW: token + proof verified, jti unseen
  GW->>TX: guards ok → validate → persist
  TX-->>POS: 201 {transactionId, status, points}
  TX-)MQ: transaction.completed

5Revocation

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor Admin as WHEEL Admin
  participant Auth as auth-service
  participant DDB as DynamoDB m2m_clients
  participant MQ as RabbitMQ fanout → services
  actor POS as Partner POS
  actor Bad as Attacker with stolen token
  Admin->>Auth: PATCH /v1/m2m-clients/{clientId} {status | allowedApis | jwks}
  Auth->>DDB: update
  Auth-)MQ: auth.m2m.client.revoked → blocklist (as D-5)
  Bad->>Auth: GET /v1/... Authorization: DPoP stolen token, no valid proof
  Auth-->>Bad: 401 invalid_dpop_proof — without the DPoP private key no proof can be produced
  POS->>Auth: re-mint with new assertion + proof
  Auth-->>POS: new DPoP-bound token
  Note over Admin,Bad: Token theft alone no longer requires revocation. Compromise of the DPoP key: partner rotates it locally and re-mints — nothing to update at WHEEL.

6Data out (proposed)

Push: D-6A — WHEEL signs each webhook with its KMS key; adding a DPoP proof outbound would require the partner to run DPoP verification, which is unusual for a webhook receiver. Pull: A-6B with Authorization: DPoP + a fresh proof per page request, verified as in E-2.

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#F7EBDA","primaryBorderColor":"#A8681C","actorBkg":"#F7EBDA","actorBorder":"#A8681C","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  participant GW as API Gateway + authorizer (DPoP-aware)
  participant Exp as export endpoint (new)
  POS->>POS: proof {htm GET, htu .../v1/export/transactions, ath, jti}
  POS->>GW: GET /v1/export/transactions?since=...&cursor=...   Authorization: DPoP token, DPoP: proof
  GW->>Exp: token + proof verified → rows for this client_id
  Exp-->>POS: 200 {items[], nextCursor, asOf}
  loop each page
    POS->>GW: new proof (new jti, same htu with cursor query) → next page
  end

FCertificate-bound access token (RFC 8705) — mTLS + WHEEL-issued token

Combines B/C with D: the partner authenticates to the token endpoint with its client certificate (no secret, no assertion), and WHEEL mints a token carrying the certificate thumbprint (cnf.x5t#S256). Every API call must arrive over mTLS with the same certificate.

Shared secret to partnerNone
Token issuerWHEEL M2M Auth (as D), cert-authenticated
New componentsD's token service + B/C's truststore + thumbprint comparison in authorizer
API Gateway changemTLS on custom domain; authorizer compares presented cert to cnf.x5t#S256

Actors

ActorWhat changes vs B / D
Partner POSHolds private key + client certificate (CA-issued or self-signed). No client secret, no assertion signing.
WHEEL M2M AuthToken endpoint accepts tls_client_auth / self_signed_tls_client_auth: identity = certificate presented on the connection. Embeds cnf.x5t#S256.
API Gateway + authorizermTLS as in B/C. Authorizer computes SHA-256 of the presented cert and requires it to equal the token's cnf.x5t#S256.
DynamoDB m2m_clientsStores cert_fingerprint (as B/C); no keys, no secrets.

0Onboarding

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  actor Admin as WHEEL Admin
  participant Auth as auth-service
  participant Trust as CA or S3 truststore
  participant GW as API Gateway custom domain
  participant DDB as DynamoDB m2m_clients
  POS->>POS: generate key pair + CSR (CA path) or self-signed cert (self-signed path)
  POS->>Admin: CSR or certificate PEM
  Admin->>Auth: POST /v1/m2m-clients {clientName, allowedApis, workflows, csr | certificatePem}
  Auth->>Trust: issue cert via CA  — or —  append leaf cert to truststore bundle
  Auth->>GW: truststore version updated (self-signed path only)
  Auth->>DDB: PutItem {client_id, allowed_apis, cert_fingerprint = SHA-256(DER), cert_not_after, status ACTIVE}
  Auth-->>Admin: 201 {clientId, certificatePem?}
  Admin-->>POS: clientId (+ issued certificate on the CA path)
  Note over POS,DDB: No secret and no Cognito app client are created.

1Issue token

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  participant GW as API Gateway (mTLS domain)
  participant Auth as WHEEL M2M Auth
  participant DDB as DynamoDB m2m_clients
  participant KMS as KMS signing key
  POS->>GW: TLS handshake — client certificate + proof of private key
  GW->>GW: chain / bundle check
  POS->>GW: POST /v1/m2m-token  grant_type=client_credentials, client_id   (no secret, no assertion)
  GW->>Auth: forward + clientCert PEM in request context
  Auth->>Auth: x5t = base64url(SHA-256(DER(cert)))
  Auth->>DDB: GetItem client_id
  Auth->>Auth: x5t == cert_fingerprint, status ACTIVE, cert not expired
  Auth->>KMS: sign access token {iss wheel-auth, sub client_id, role m2m-client, allowed_apis, exp +15 min, cnf: {x5t#S256: x5t}}
  Auth-->>POS: 200 {access_token, token_type Bearer, expires_in 900}
  Note over POS: The certificate is the credential. The token is only usable over a connection authenticated with that same certificate.
Request
POST /v1/m2m-token        (over mTLS)
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=samsung-pos-001
Access token claim added
"cnf": { "x5t#S256": "bwcK0esc3ACC3DB2Y5_lESsXE8o9ltc05O89jdN-dg2" }

2Request pipeline

TLS handshakeClient cert required (CA chain or bundle)
Lambda authorizerVerify WHEEL JWT via JWKS; SHA-256(presented cert) == token.cnf.x5t#S256
CognitoAuthGuardIssuer-aware decode; HMAC optional
M2mEnforcementGuardBlocklist + allowed_apis
PartnerApiHistoryLog
Controller → ServiceBusiness logic
On every call

TLS: same client certificate as at token time · HTTP: Authorization: Bearer <cert-bound token>

3Sync distributor → retailer → store

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  participant GW as API Gateway (mTLS)
  participant LA as Lambda authorizer
  participant RS as retailer-service
  participant MQ as RabbitMQ → search-service
  POS->>GW: mTLS handshake (client cert)
  POS->>GW: POST /rs/v2/sync/distributor {...}  [Bearer cert-bound token]
  GW->>LA: token + clientCert
  LA->>LA: JWT sig via WHEEL JWKS → SHA-256(clientCert) == cnf.x5t#S256 → allow
  GW->>RS: guards ok → upsert distributor
  RS-->>POS: 201 {distributorId}
  POS->>GW: POST /rs/v2/sync/retailer {...}
  GW->>RS: binding verified → retailer
  RS-->>POS: 201 {retailerId}
  POS->>GW: POST /rs/v2/sync/store {...}
  GW->>RS: binding verified → store
  RS-)MQ: store.created / updated → index
  RS-->>POS: 200 {storeId}
  Note over POS,LA: Stolen token + different certificate → thumbprint mismatch → 401. Stolen token + no certificate → handshake fails.

4Transactions

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor POS as Partner POS
  participant GW as API Gateway (mTLS) + authorizer
  participant TX as transactions-service
  participant MQ as RabbitMQ → customer / notification
  POS->>GW: mTLS handshake (client cert)
  POS->>GW: POST /v1/transactions {...}  [Bearer cert-bound token]
  GW->>GW: JWT ok, SHA-256(cert) == cnf.x5t#S256
  GW->>TX: guards ok → validate → persist
  TX-->>POS: 201 {transactionId, status, points}
  TX-)MQ: transaction.completed

5Revocation

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#E1F1F2","primaryBorderColor":"#0E7C86","actorBkg":"#E1F1F2","actorBorder":"#0E7C86","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  actor Admin as WHEEL Admin
  participant Auth as auth-service
  participant Trust as CA (CRL) or S3 truststore
  participant GW as API Gateway
  participant DDB as DynamoDB m2m_clients
  participant MQ as RabbitMQ fanout → services
  actor POS as Partner POS
  Admin->>Auth: PATCH /v1/m2m-clients/{clientId} {status disabled}  or  rotate certificate
  Auth->>Trust: revoke serial (CRL) — or — remove leaf from bundle
  Auth->>GW: truststore version (self-signed path)
  Auth->>DDB: status DISABLED / new cert_fingerprint
  Auth-)MQ: auth.m2m.client.revoked → blocklist
  POS->>GW: handshake with revoked cert
  GW-->>POS: TLS failure
  Note over Auth,POS: Rotation: register new fingerprint, partner re-mints — old tokens are bound to the old cert and die with it. Grant changes still use the blocklist + re-mint path.

6Data out (proposed)

Push: B-6A — WHEEL's outbound client certificate over mTLS to the partner endpoint; the certificate alone proves origin, so no signed JWT or webhook secret is needed. Pull: A-6B over mTLS with the cert-bound token; the authorizer applies the same thumbprint check.

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#F7EBDA","primaryBorderColor":"#A8681C","actorBkg":"#F7EBDA","actorBorder":"#A8681C","fontFamily":"IBM Plex Sans, Segoe UI, sans-serif","fontSize":"13px","primaryTextColor":"#17202B","actorTextColor":"#17202B","signalColor":"#17202B","signalTextColor":"#17202B","noteBkgColor":"#FFF6E5","noteBorderColor":"#A8681C","noteTextColor":"#17202B","activationBkgColor":"#EAF0F3","activationBorderColor":"#4E5A66","sequenceNumberColor":"#FFFFFF","labelBoxBkgColor":"#EAF0F3","labelBoxBorderColor":"#C9D2DB","loopTextColor":"#17202B"}}}%%
sequenceDiagram
  autonumber
  participant MQ as RabbitMQ
  participant Out as outbound-sync worker (new)
  actor POS as Partner mTLS endpoint
  MQ-)Out: transaction.completed / store.updated
  Out->>POS: TLS handshake — WHEEL client certificate
  POS->>POS: chain to WHEEL CA or pinned fingerprint
  Out->>POS: POST webhookUrl {eventId, event, payload, ts}
  POS-->>Out: 2xx

§Stage-by-stage comparison

What each stage looks like under each approach. "—" means unchanged from A.

StageA · Cognito secretB · mTLS + CognitoC · Self-signed mTLS + CognitoD · private_key_jwtE · DPoPF · Cert-bound token
Partner receivesclientId + clientSecretcert + clientId + clientSecretclientId + clientSecret (sends own cert)clientId only (sends public JWK)clientId onlyclientId (+ cert on CA path)
Shared secretyesyesyesnonono
Token issued byCognito via auth-service——WHEEL M2M Auth (KMS)WHEEL M2M Auth (KMS)WHEEL M2M Auth (KMS)
Token request provesknows secretholds cert key + knows secretholds cert key + knows secretholds client key (assertion)holds client key + DPoP keyholds cert key
Per-call headersBearer + X-HMAC-Code— (+ TLS cert)— (+ TLS cert)—Authorization: DPoP + DPoP proofBearer (+ TLS cert)
Authorizer adds—cert fingerprint ↔ registrycert fingerprint ↔ registryWHEEL issuer / JWKSWHEEL JWKS + proof verification + jti cacheWHEEL JWKS + x5t#S256 ↔ presented cert
Stolen token usable?yes, until expiryno (needs cert key)no (needs cert key)yes, until expiry (15 min)no (needs DPoP key)no (needs cert key)
Sync / transaction services change—nonenonetrust new issuertrust new issuer + DPoP guardtrust new issuer
Revocation leverblocklist + delete clientCRL + blocklisttruststore rebuild + blocklistswap JWK + blocklistswap JWK + blocklist (theft needs none)CRL / truststore + blocklist
Data out — origin proofwebhookSecret HMACWHEEL client cert (mTLS)WHEEL self-signed cert (pinned)WHEEL-signed JWT (JWKS)WHEEL-signed JWTWHEEL client cert (mTLS)
New infranonePrivate CA, truststore, mTLS domaintruststore, mTLS domaintoken endpoint, JWKS, KMS key, jti cacheD + proof verification + proof cacheD + B/C
Questions for the review
  • Is the requirement to protect the connection (B/C suffice) or to remove the shared secret entirely (D/E/F)?
  • Must Cognito remain the M2M issuer? D/E/F require WHEEL to mint partner tokens.
  • CA-issued or self-signed certificates for B/C/F? The self-signed path avoids running a CA but makes auth-service own the truststore lifecycle.
  • Do the partner's POS devices have secure key storage? All of B–F assume the private key never leaves the device.
  • Data out: push or pull first?
Source
  • api-auth-service/src/modules/m2m, modules/m2m-clients, modules/m2m-enforcement
  • api-retailer-service/src/modules/sync/controllers/sync.controller.ts
  • api-transactions-service/.../transactions.controller.ts
  • docs/m2m-role-based-auth-setup-guide.md §E
  • RFC 7523 (JWT client auth), RFC 9449 (DPoP), RFC 8705 (cert-bound tokens)