Skip to content

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_provider or cognito.allowed_identity_providers set → 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

  • OIDCFlowService gains one new constructor dependency (AuthorizeParamsResolver) and one new parameter on initiate() (idp_hint)
  • The provider_settings parameter on OIDCFlowService should 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 (NullAuthorizeParams returns {})

References