Loyalty platform · M2M integration · architecture review
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.
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.
| Actor | Role |
|---|---|
| Partner POS | M2M client (Samsung, Apple, POS vendor). Holds clientId + clientSecret. |
| WHEEL Admin | Operator with a wheel-admin token. Onboards and manages clients. |
| API Gateway | AWS API Gateway + Lambda authorizer (JWT signature / issuer / audience / expiry). |
| auth-service | /v1/m2m-clients CRUD and /v1/m2m-token/refresh-token. |
| Cognito + pre-token lambda | M2M user pool; lambda embeds allowed_apis, role=m2m-client. |
| DynamoDB m2m_clients | Registry: client, allowed APIs, secret hash, status. |
| retailer-service · transactions-service | Host /rs/v2/sync/* and /v1/transactions. |
| RabbitMQ · search-service · customer-service | Event fan-out targets. |
%%{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.
%%{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.
Authorization: Bearer <accessToken> · X-HMAC-Code: <hmacCode>
%%{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])
%%{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
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.%%{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.
Two shapes. Both authorise the partner the same way steps 1–2 do; only the direction changes.
%%{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
%%{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
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.
clientCert| Actor | What changes vs A |
|---|---|
| Partner POS | Additionally holds a private key + CA-issued client certificate. Private key never leaves the POS. |
| Private CA | New. ACM Private CA (WHEEL-owned) or the partner's corporate CA whose root WHEEL trusts. |
| S3 truststore | New. PEM bundle of trusted CA certs referenced by the API Gateway custom domain. |
| API Gateway | Custom domain with mTLS enabled; rejects handshakes whose cert does not chain to the truststore; forwards cert to the authorizer. |
| Lambda authorizer | Also reads requestContext.identity.clientCert and checks fingerprint ↔ registry. |
| DynamoDB m2m_clients | Adds cert_fingerprint, cert_serial, cert_not_after. |
%%{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.
%%{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.
TLS: client certificate + private key · HTTP: Authorization: Bearer <accessToken> · X-HMAC-Code
%%{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.
%%{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
%%{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.
%%{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.
Identical to A-6B, over the mTLS connection: GET /v1/export/… with Bearer + X-HMAC-Code; the authorizer additionally matches the cert fingerprint.
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.
| Actor | What changes vs B |
|---|---|
| Partner POS | Generates its own key pair and self-signed certificate. Sends the certificate (public) to WHEEL. |
| Private CA | Not needed. |
| auth-service | Owns the truststore bundle: appends / removes leaf certs and bumps the API Gateway truststore version. |
| S3 truststore | Contains the partners' leaf certificates directly (API Gateway supports self-signed certs in the bundle). |
| Lambda authorizer | Looks the client up by fingerprint — the fingerprint is the identity, since there is no CA-issued subject to trust. |
%%{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.
%%{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}
%%{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}
%%{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
%%{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).
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
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.
auth.config.ts| Actor | What changes vs A |
|---|---|
| Partner POS | Holds an RSA/EC private key. Builds and signs a client_assertion for each token request. |
| WHEEL M2M Auth | New module in auth-service: POST /v1/m2m-token (RFC 7523 client auth) and GET /.well-known/jwks.json. |
| KMS signing key | New. WHEEL's asymmetric key (e.g. RSA-2048 / ES256). Private half never leaves KMS; public half is published in JWKS. |
| DynamoDB m2m_clients | Stores public_jwk (or jwks_uri) and key_id; no secret fields. |
| jti replay cache | New. DynamoDB TTL table or in-memory set of seen assertion ids. |
| Cognito | Not involved for partner tokens (still used for internal service-to-service and user pools). |
| Lambda authorizer | Verifies WHEEL-issued tokens against WHEEL JWKS (new issuer entry). |
%%{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.
%%{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.
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…
200 OK
{
"accessToken": "eyJraWQiOiJ3aGVlbC0yMDI2LTA5In0…",
"expiresIn": 900,
"tokenType": "Bearer",
"hmacCode": "…"
}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.
%%{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}
%%{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
%%{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.
%%{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.
A-6B unchanged — the export endpoint sits behind the same authorizer and guards, using the WHEEL-issued token.
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.
token_type: DPoPDPoP header and verify the proof| Actor | What changes vs D |
|---|---|
| Partner POS | Holds 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 Auth | Also verifies the DPoP proof on the token request and embeds cnf.jkt (thumbprint of the DPoP public key) in the token. |
| Lambda authorizer / DpopGuard | New 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 cache | New. Short-TTL store (seconds to minutes) shared across instances — ElastiCache or DynamoDB TTL. |
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
%%{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.
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}"cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" }
"token_type": "DPoP"Authorization: DPoP <accessToken> · DPoP: <fresh proof with htm, htu of this request, ath = base64url(SHA-256(accessToken))>
%%{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.
%%{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
%%{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.
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
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.
cnf.x5t#S256| Actor | What changes vs B / D |
|---|---|
| Partner POS | Holds private key + client certificate (CA-issued or self-signed). No client secret, no assertion signing. |
| WHEEL M2M Auth | Token endpoint accepts tls_client_auth / self_signed_tls_client_auth: identity = certificate presented on the connection. Embeds cnf.x5t#S256. |
| API Gateway + authorizer | mTLS 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_clients | Stores cert_fingerprint (as B/C); no keys, no secrets. |
%%{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.
%%{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.
POST /v1/m2m-token (over mTLS) Content-Type: application/x-www-form-urlencoded grant_type=client_credentials&client_id=samsung-pos-001
"cnf": { "x5t#S256": "bwcK0esc3ACC3DB2Y5_lESsXE8o9ltc05O89jdN-dg2" }TLS: same client certificate as at token time · HTTP: Authorization: Bearer <cert-bound 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)
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.
%%{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
%%{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.
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
What each stage looks like under each approach. "—" means unchanged from A.
| Stage | A · Cognito secret | B · mTLS + Cognito | C · Self-signed mTLS + Cognito | D · private_key_jwt | E · DPoP | F · Cert-bound token |
|---|---|---|---|---|---|---|
| Partner receives | clientId + clientSecret | cert + clientId + clientSecret | clientId + clientSecret (sends own cert) | clientId only (sends public JWK) | clientId only | clientId (+ cert on CA path) |
| Shared secret | yes | yes | yes | no | no | no |
| Token issued by | Cognito via auth-service | — | — | WHEEL M2M Auth (KMS) | WHEEL M2M Auth (KMS) | WHEEL M2M Auth (KMS) |
| Token request proves | knows secret | holds cert key + knows secret | holds cert key + knows secret | holds client key (assertion) | holds client key + DPoP key | holds cert key |
| Per-call headers | Bearer + X-HMAC-Code | — (+ TLS cert) | — (+ TLS cert) | — | Authorization: DPoP + DPoP proof | Bearer (+ TLS cert) |
| Authorizer adds | — | cert fingerprint ↔ registry | cert fingerprint ↔ registry | WHEEL issuer / JWKS | WHEEL JWKS + proof verification + jti cache | WHEEL JWKS + x5t#S256 ↔ presented cert |
| Stolen token usable? | yes, until expiry | no (needs cert key) | no (needs cert key) | yes, until expiry (15 min) | no (needs DPoP key) | no (needs cert key) |
| Sync / transaction services change | — | none | none | trust new issuer | trust new issuer + DPoP guard | trust new issuer |
| Revocation lever | blocklist + delete client | CRL + blocklist | truststore rebuild + blocklist | swap JWK + blocklist | swap JWK + blocklist (theft needs none) | CRL / truststore + blocklist |
| Data out — origin proof | webhookSecret HMAC | WHEEL client cert (mTLS) | WHEEL self-signed cert (pinned) | WHEEL-signed JWT (JWKS) | WHEEL-signed JWT | WHEEL client cert (mTLS) |
| New infra | none | Private CA, truststore, mTLS domain | truststore, mTLS domain | token endpoint, JWKS, KMS key, jti cache | D + proof verification + proof cache | D + B/C |
api-auth-service/src/modules/m2m, modules/m2m-clients, modules/m2m-enforcementapi-retailer-service/src/modules/sync/controllers/sync.controller.tsapi-transactions-service/.../transactions.controller.tsdocs/m2m-role-based-auth-setup-guide.md §E