Skip to content

Acting on behalf of a clinician

The default integration grant (client_credentials) issues a token that represents your service account — every audit entry on the platform attributes the action to your client_id, not to the human who triggered it.

When the user who triggered the action is a clinician who has signed in to your product through AWS Cognito (the platform's identity provider for clinicians), you can swap their Cognito ID token for a delegated platform token that records the human identity in audit logs and limits scopes to what that clinician is allowed to do.

This is an implementation of RFC 8693 — OAuth 2.0 Token Exchange.

When to use it

Use this Use the default client_credentials grant
A real clinician is signed in to your product right now Your service is running a background job
You want audit entries to record the human You want audit entries to record the service account
You want fine-grained scopes (intersection of your scopes + the user's role permissions) You're happy with the full scope set your client was granted

You can mix the two — call the auth endpoint twice from the same product depending on the context.

Prerequisites

  1. The clinician has a Cognito user account in the platform's user pool and has signed in to your product, giving you a valid ID token (token_use=id, signed by the platform's Cognito JWKS).
  2. The clinician has been provisioned as a platform user (someone has called POST /v1/user-management/admin/users for them) — Cognito sub → platform user mapping is required.
  3. The clinician has an org membership in your organisation, with role permissions that overlap the scopes your consumer client was granted.

The exchange

POST https://platform.example.com/v1/auth/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&client_id=<your-client-id>
&client_secret=<your-client-secret>
&subject_token=<cognito-id-token>
&subject_token_type=urn:ietf:params:oauth:token-type:id_token
&scope=cases:read patients:read           # optional — downscope further

Successful response:

{
  "access_token": "eyJhbGc...",
  "token_type": "Bearer",
  "expires_in": 900,
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "scope": "cases:read patients:read"
}

The returned token carries:

  • sub — the platform user_id of the clinician
  • org_id + product_id — same as your consumer client's binding
  • scopes — the intersection of (your client's scopes, the user's permissions, the optionally-requested scope set)
  • act.sub — your client_id. Records the delegation chain per RFC 8693.
  • actor_context — a copy of the clinician's identity payload, so downstream services can attribute audit entries without a separate user-management lookup.

Failure modes

Response Meaning
400 invalid_request Missing subject_token or subject_token_type
400 unsupported_token_type subject_token_type was not urn:ietf:params:oauth:token-type:id_token
400 invalid_grant Cognito token failed verification, the clinician has no platform user, or the clinician is not a member of your org
400 invalid_scope scope parameter requested a scope your client lacks or the user lacks
401 invalid_client client_id / client_secret rejected
500 Auth service couldn't reach Cognito JWKS or user-management

invalid_grant is deliberately opaque about which check failed — it prevents a misconfigured consumer (or a malicious one) from probing which Cognito subs exist on the platform.

Token lifetime + refresh

Delegated tokens are short-lived — 15 minutes by default vs. 1 hour for client_credentials. Re-exchange whenever you have a fresh Cognito ID token; Cognito's own refresh-token flow handles end-user session continuity, so you should never need a long-lived delegated token.

A typical request pipeline in your product:

1. End-user makes a request to your product
2. Your product validates the user's Cognito session (refreshes ID token
   if needed)
3. Your product exchanges that ID token for a delegated platform token
4. Your product calls platform APIs with the delegated token
5. Your product caches the delegated token for the duration of the
   request (or up to ~5 minutes) keyed by user

Using the TypeScript SDK

import { SkinAnalyticsClient } from '@sa-platform/client-sdk';

const client = new SkinAnalyticsClient({
  baseUrl: 'https://platform.example.com',
  clientId: process.env.SA_CLIENT_ID!,
  clientSecret: process.env.SA_CLIENT_SECRET!,
  scopes: ['cases:read', 'patients:read'],
});

// Per-request, with the clinician's freshly-validated Cognito ID token:
const { accessToken } = await client.exchangeForUser(cognitoIdToken, {
  scope: ['cases:read'],
});

const response = await fetch(`${process.env.SA_BASE_URL}/v1/clinical-api/cases/${caseId}`, {
  headers: { Authorization: `Bearer ${accessToken}` },
});

Operator setup

Token exchange requires the auth service to know the platform's Cognito user pool so it can verify ID-token signatures.

Set on services/auth in deployed environments:

  • COGNITO_USER_POOL_ID (e.g. eu-west-2_abc123)
  • COGNITO_REGION (e.g. eu-west-2)
  • COGNITO_APP_CLIENT_ID (optional — restricts which Cognito app clients are trusted to mint ID tokens)
  • DELEGATED_TOKEN_TTL_SECONDS (optional — defaults to 900)

If COGNITO_USER_POOL_ID is unset, the exchange grant returns invalid_grant for every request.