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¶
- 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). - The clinician has been provisioned as a platform user (someone has
called
POST /v1/user-management/admin/usersfor them) — Cognito sub → platform user mapping is required. - 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 platformuser_idof the clinicianorg_id+product_id— same as your consumer client's bindingscopes— the intersection of (your client's scopes, the user's permissions, the optionally-requestedscopeset)act.sub— yourclient_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.