Module 4
August 19, 2026 · View on GitHub
Module 4's headline is that your users keep their corporate logins. This is how you actually wire that up, verified end to end against a real Microsoft Entra ID tenant.
The stack supports Entra ID, Okta, and Ping as OIDC providers into
Cognito (stacks/auth_stack.py). Cognito stays in the path — it is the token
issuer every agent and the gateway validate against — and your IdP becomes the
place where humans actually authenticate.
Browser → Cognito Hosted UI → your IdP (login, MFA, conditional access)
← id_token
← Cognito code → tokens minted by COGNITO (what agents verify)
Two consequences worth understanding before you start:
- Agents never see your IdP's tokens. They verify Cognito-issued JWTs
(
docs/IDENTITY.md), so nothing downstream changes when you federate. - The client secret lives in Secrets Manager, never in CDK context or
platform.yaml. Only the secret's name is configuration.
Entra ID, end to end
Everything below was run against a live tenant. Substitute your own tenant id and app name.
1. Get the redirect URI Cognito will use
It is derived from the project, environment, and account, so you can compute it before the IdP exists:
DOMAIN=$(aws cloudformation describe-stacks \
--stack-name <prefix>-auth \
--query "Stacks[0].Outputs[?OutputKey=='DomainUrl'].OutputValue" --output text)
echo "$DOMAIN/oauth2/idpresponse"
# https://<prefix>-<account>.auth.<region>.amazoncognito.com/oauth2/idpresponse
If the auth stack does not exist yet, deploy it once with IDP_TYPE=cognito
(module 3) and read the value, then come back.
2. Register the application in Entra ID
az ad app create \
--display-name "AgentCore Accelerator" \
--sign-in-audience AzureADMyOrg \
--web-redirect-uris "https://<prefix>-<account>.auth.<region>.amazoncognito.com/oauth2/idpresponse" \
--enable-id-token-issuance true \
--query "{appId:appId,id:id}"
--enable-id-token-issuance matters: Cognito's OIDC provider expects an
id_token from the implicit/hybrid response, and without it the login fails
after the user has already authenticated — the most confusing possible place.
Note the returned appId; that is your IDP_CLIENT_ID.
3. Put the client secret in Secrets Manager
az ad app credential reset --id <appId> \
--display-name agentcore --years 1 \
--query password -o tsv \
| tr -d '\n\r' \
| aws secretsmanager create-secret \
--name agentcore/entra-client-secret \
--secret-string file:///dev/stdin \
--query Name --output text
Do not omit the tr -d '\n\r'. A trailing newline is stored as part of the
secret, Cognito passes it verbatim to Entra's token endpoint, and the exchange
fails with an invalid-client error that gives no hint about whitespace. Verify:
aws secretsmanager get-secret-value --secret-id agentcore/entra-client-secret \
--output json | python3 -c "import json,sys; s=json.load(sys.stdin)['SecretString']; print(len(s), s==s.strip())"
# expect: 40 True (length varies; `True` is the part that matters)
Do not use --output text for this check — the CLI appends its own newline and
you will chase a bug that is not there.
Already have the secret in Secrets Manager? Name it and the deploy will use it as-is — no prompt, no copy:
identity:
client_secret_name: my-corp/entra-client-secret # any name you already own
./scripts/deploy.sh checks that secret exists and passes the name through. If
you also supply IDP_CLIENT_SECRET (rotating the value), it writes into that
same secret rather than creating one of its own.
4. Deploy with federation on
IDP_TYPE=entra_id \
IDP_TENANT_ID=<tenant-id> \
IDP_CLIENT_ID=<appId> \
IDP_CLIENT_SECRET_NAME=agentcore/entra-client-secret \
./scripts/deploy.sh deploy --module 4
Or declare it in platform.yaml (see presets/migration.yaml):
identity:
idp: entra_id
tenant_id: "<tenant-id>"
client_id: "<appId>"
client_secret_name: agentcore/entra-client-secret
The stack builds the issuer URL as
https://login.microsoftonline.com/<tenant-id>/v2.0, requests
openid email profile, and maps Entra's email and name claims onto the
Cognito user. The secret reaches CloudFormation as a
{{resolve:secretsmanager:...}} dynamic reference, so its value never appears
in the synthesized template or in process arguments.
5. Verify without a browser
Four checks, in the order that isolates failures:
# a) the provider exists and points at your tenant
aws cognito-idp describe-identity-provider \
--user-pool-id <pool-id> --provider-name EntraID \
--query 'IdentityProvider.{issuer:ProviderDetails.oidc_issuer,scopes:ProviderDetails.authorize_scopes,attrs:AttributeMapping}'
# b) the app clients actually offer it
aws cognito-idp describe-user-pool-client \
--user-pool-id <pool-id> --client-id <app-client-id> \
--query 'UserPoolClient.SupportedIdentityProviders'
# expect: ["COGNITO", "EntraID"]
# c) Entra accepts the client_id + redirect_uri (a sign-in page means both are registered;
# it does NOT prove the app is fully sign-in ready — that only shows up in step 6)
curl -s -o /dev/null -D - \
"$DOMAIN/oauth2/authorize?identity_provider=EntraID&client_id=<app-client-id>&response_type=code&scope=openid+email+profile&redirect_uri=<url-encoded-callback>" \
| grep -i '^location:'
# then fetch that Microsoft URL: HTTP 200 with a sign-in page, no AADSTS code
# d) the secret Cognito holds is genuinely valid at Entra
curl -s -X POST "https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token" \
-d grant_type=client_credentials -d client_id=<appId> \
--data-urlencode "client_secret=<the secret>" \
-d 'scope=https://graph.microsoft.com/.default'
# expect an access_token; "invalid_client" means the secret is wrong or has whitespace
Check (d) is the one worth knowing about: it proves the credential works without completing a login, which separates "my secret is wrong" from "my user cannot sign in".
6. Sign in for real
The token exchange and the attribute mapping only run when a human actually signs in, so finish in a browser:
open "$DOMAIN/oauth2/authorize?identity_provider=EntraID&client_id=<app-client-id>&response_type=code&scope=openid+email+profile&redirect_uri=<url-encoded-callback>"
Sign in with a tenant user. The browser lands on your callback URL with
?code=… — a connection error there is expected if nothing is listening on
localhost, the code in the URL is the success signal.
Confirm Cognito created the federated user:
aws cognito-idp list-users --user-pool-id <pool-id> \
--query 'Users[].{u:Username,status:UserStatus,email:Attributes[?Name==`email`].Value|[0]}'
# [{"u": "EntraID_<opaque-id>", "status": "EXTERNAL_PROVIDER", "email": "you@corp.example"}]
EXTERNAL_PROVIDER with a populated email is the proof: the exchange
succeeded and the attribute mapping ran. Note the username carries the provider
name exactly as Cognito registered it — EntraID_…, not lowercased.
To see what the agent will receive, exchange the code for tokens (within five minutes, single use) and read the claims:
curl -s -X POST "$DOMAIN/oauth2/token" \
-u "<app-client-id>:<app-client-secret>" \
-d grant_type=authorization_code -d "code=<the code>" \
--data-urlencode "redirect_uri=<the same callback>"
The id_token is issued by Cognito, not by Entra:
{
"iss": "https://cognito-idp.<region>.amazonaws.com/<pool-id>",
"identities": [{ "providerName": "EntraID", "providerType": "OIDC", "primary": "true" }],
"cognito:username": "EntraID_<opaque-id>",
"email": "you@corp.example",
"name": "Your Name"
}
That is the whole point of keeping Cognito in the path: the agent verifies a token it already knows how to verify, and the federation is invisible to it.
7. Invoke an agent as that user
The runtime's JWT authorizer trusts the Cognito pool and a list of client ids
(allowedClients), which includes the app client — so the token you just
obtained works against the data plane with no further wiring:
curl -s -X POST \
"https://bedrock-agentcore.<region>.amazonaws.com/runtimes/<url-encoded-runtime-arn>/invocations?qualifier=DEFAULT" \
-H "Authorization: Bearer <access_token from step 6>" \
-H 'Content-Type: application/json' \
-d '{"prompt":"who am I to you?","runtimeSessionId":"<33+ chars>"}'
Worth knowing what this does and does not check: the authorizer validates the
signature, the issuer, and the client id — not the scopes. A user token
carrying only openid profile email (no agentcore/invoke) is accepted. If you
need scope-level authorization, enforce it in the agent, not here.
Turning it back off
Redeploying with IDP_TYPE=cognito removes the identity provider and drops it
from the app clients. It does not remove the users it created — they stay in
the pool as EXTERNAL_PROVIDER accounts with no provider behind them, and
nobody can sign in as them. Clean up if you care:
aws cognito-idp admin-delete-user --user-pool-id <pool-id> --username EntraID_<id>
Okta and Ping
Same shape, one difference: they take a full issuer_url instead of a tenant
id, because the stack cannot derive it.
IDP_TYPE=okta \
IDP_ISSUER_URL=https://<your-org>.okta.com \
IDP_CLIENT_ID=<client-id> \
IDP_CLIENT_SECRET_NAME=agentcore/okta-client-secret \
./scripts/deploy.sh deploy --module 4
Register the same …/oauth2/idpresponse redirect URI on the Okta/Ping
application side, and grant the openid email profile scopes. The provider
appears in Cognito as Okta or PingIdentity respectively; use that name for
identity_provider= in the authorize URL.
Verified
Against a live Entra ID tenant, 2026-08-19:
| Step | Result |
|---|---|
| App registration with the Cognito redirect URI | created, AzureADMyOrg, id-token issuance on |
| Secret stored in Secrets Manager and consumed via dynamic reference | value absent from the synthesized template |
| Cognito OIDC provider | created — issuer …/<tenant>/v2.0, scopes openid email profile, email/name mapped |
| App + web clients | list EntraID alongside COGNITO |
| Cognito → Entra handoff | redirects with our client_id; Entra serves the sign-in page, no AADSTS error |
| The secret Cognito holds | Entra issued a token for it (client_credentials) |
Switching IDP_TYPE back and forth (cognito → entra_id) | provider removed and recreated cleanly |
| Interactive sign-in in a browser | completed; Cognito returned an authorization code |
| Federated user in Cognito | EntraID_<id>, EXTERNAL_PROVIDER, email and name mapped from Entra |
| Code → token exchange | Cognito minted id_token / access_token / refresh_token, iss = the Cognito pool |
| Agent invoke with that user's token | 200, the orchestrator answered |
| Service principal | not needed as a separate step — Entra created it during the first sign-in (checked by deleting it and signing in again) |
When it does not work
| Symptom | Cause |
|---|---|
redirect_mismatch from Cognito | the redirect_uri you passed is not in the app client's callback URLs (default: http://localhost:3000/api/auth/callback/cognito) |
AADSTS50011 from Microsoft | the …/oauth2/idpresponse URI is not registered on the Entra app |
AADSTS700016 | wrong IDP_CLIENT_ID, or the app is in a different tenant |
invalid_client at token exchange | the secret is wrong, expired, or has trailing whitespace (see step 3) |
invalid_client_secret from Cognito's /oauth2/token | you sent another client's secret — the app client and the M2M client each have their own; read it with describe-user-pool-client for the client id you are authenticating as |
| Login succeeds, then fails returning to Cognito | --enable-id-token-issuance was not set on the Entra app |
No EntraID button in the hosted UI | IDP_TYPE was not entra_id at deploy time — check ./scripts/deploy.sh config |
The client secret is readable from Cognito by anyone with
cognito-idp:DescribeIdentityProvider (it is returned in ProviderDetails).
That is AWS behaviour, not something this stack can hide — treat that permission
as sensitive and prefer scoping it away from broad read roles.