19. Federated identity provider selection
Date: 2026-03-19
Status
Proposed
Context
Cognito User Pools can federate with multiple external OIDC identity providers (e.g. CIAM partners). When a federated IdP is configured, the Cognito /oauth2/authorize endpoint accepts an identity_provider query parameter that bypasses the hosted UI and redirects the user directly to the external provider.
The current OIDCFlowService.initiate() builds the authorization URL with a fixed set of OIDC-standard parameters. There is no mechanism to inject provider-specific parameters like identity_provider.
Requirements
| # | Requirement |
|---|---|
| R1 | Support one configured IdP (e.g. ciam-dev) as default — GET /auth/login redirects directly |
| R2 | Support multiple IdPs — GET /auth/login?idp=ciam-dev selects at request time |
| R3 | Validate the requested IdP against an allow-list |
| R4 | When no IdP is configured, the flow falls through to the Cognito hosted UI (current behavior) |
| R5 | Token exchange and refresh are unaffected (Cognito handles federation server-side) |
| R6 | The service layer remains provider-agnostic — no Cognito-specific concepts leak into OIDCFlowService |
Authorize URL examples
# No federation (current behavior)
https://{domain}/oauth2/authorize?response_type=code&client_id=...&scope=...&state=...&code_challenge=...
# Single default IdP
https://{domain}/oauth2/authorize?response_type=code&client_id=...&identity_provider=ciam-dev&...
# Explicit selection
https://{domain}/oauth2/authorize?response_type=code&client_id=...&identity_provider=ciam-prod&...
Decision
D1: New port — AuthorizeParamsResolver Protocol
A new Protocol in ports/oidc.py that maps an optional idp_hint to extra authorize URL parameters:
class AuthorizeParamsResolver(Protocol):
def resolve(self, idp_hint: str | None = None) -> dict[str, str]: ...
The service calls resolver.resolve(idp_hint) and merges the result into the authorize URL query parameters. The service never sees identity_provider — it only sees opaque key-value pairs.
This keeps the service provider-agnostic (R6). A different provider could map the same idp_hint to login_hint, acr_values, or any other authorize parameter.
D2: Cognito adapter — CognitoAuthorizeParams
A new adapter in adapters/cognito/ implementing AuthorizeParamsResolver:
| Field | Source | Purpose |
|---|---|---|
default_provider |
COGNITO_IDENTITY_PROVIDER |
Used when no idp_hint in request (R1) |
allowed_providers |
COGNITO_ALLOWED_IDENTITY_PROVIDERS |
Comma-separated allow-list for validation (R3) |
Resolution logic:
idp_hint provided?
→ yes → in allowed_providers? → {"identity_provider": idp_hint}
→ → raise ValueError (unknown provider)
→ no → default_provider set? → {"identity_provider": default_provider}
→ → {} (hosted UI fallback — R4)
When default_provider is set but allowed_providers is empty, the default provider is implicitly allowed.
D3: Null adapter — NullAuthorizeParams
For non-Cognito providers or when federation is not configured:
class NullAuthorizeParams:
def resolve(self, idp_hint: str | None = None) -> dict[str, str]:
return {}
D4: Service change — OIDCFlowService.initiate() accepts idp_hint
async def initiate(self, callback_uri: str, idp_hint: str | None = None) -> OIDCAuthorizationRequest:
...
extra_params = self._authorize_params_resolver.resolve(idp_hint)
params = {**base_params, **extra_params}
...
The AuthorizeParamsResolver is injected via constructor, like all other dependencies.
D5: Route change — /auth/login reads ?idp= query param
The AuthRouteHandler.login() reads an optional idp query parameter from the request and passes it to initiate():
GET /auth/login → initiate(callback_uri, idp_hint=None)
GET /auth/login?idp=ciam-dev → initiate(callback_uri, idp_hint="ciam-dev")
D6: Settings
Two new fields on CognitoSettings:
identity_provider: str = "" # COGNITO_IDENTITY_PROVIDER
allowed_identity_providers: str = "" # COGNITO_ALLOWED_IDENTITY_PROVIDERS (comma-separated)
D7: Wiring
wire_oidc_flow() selects the adapter:
cognito.identity_providerorcognito.allowed_identity_providersset →CognitoAuthorizeParams- otherwise →
NullAuthorizeParams
Alternatives considered
A. Single identity_provider field in settings (static, no request-time selection)
Add COGNITO_IDENTITY_PROVIDER and always inject it in the authorize URL.
Rejected because: - Doesn't support multiple IdPs on the same User Pool - Would require redeployment to switch provider
B. Conditional logic in OIDCFlowService with identity_provider parameter
Have the service know about identity_provider directly.
Rejected because:
- identity_provider is a Cognito-specific parameter name
- Other providers use login_hint, idp_hint, kc_idp_hint (Keycloak), acr_values
- Violates R6 (service must remain provider-agnostic)
C. Override get_authorization_endpoint() to include query params
Have CognitoSettings.get_authorization_endpoint() return the base URL with identity_provider baked in.
Rejected because: - Mixes static configuration with request-time selection - The endpoint URL is a fixed property of the provider, not request-dependent
Consequences
OIDCFlowServicegains one new constructor dependency (AuthorizeParamsResolver) and one new parameter oninitiate()(idp_hint)- The
provider_settingsparameter onOIDCFlowServiceshould be formalized into a Protocol (eliminating the current# type: ignore) - Token exchange and refresh are completely unaffected (R5)
- The pattern is reusable: any provider with request-time authorize params can implement
AuthorizeParamsResolver - Existing deployments with no federation configured are unaffected (
NullAuthorizeParamsreturns{})
References
- AWS Cognito Identity Provider Parameter —
identity_providerin authorize endpoint - ADR-0015 PKCE S256 and Session Cookie — current OIDC flow design
- ADR-0017 Cognito Adapter Specialization — same pattern: provider-specific adapter
- ADR-0013 Provider-agnostic JWT Validation — Protocol-based provider abstraction