Table of Contents

SAML identity provider to OpenID Connect client

Protocol bridging: a user whose organisation signs them in over SAML 2.0 signs in to an application that speaks only OpenID Connect. The application asks this server for tokens; this server is the SAML service provider of the organisation's identity provider, sends the user there with an AuthnRequest, receives the signed assertion, and issues its own tokens. The client is the demo host's web, naming the provider in acr_values; the identity provider is a second instance of the demo host at http://idp.partner.example, whose native SAML identity provider has this server registered as a service provider that sends unsigned requests. For this flow the test registers that provider on the first instance with AddExternalSaml2, trusting the second instance's signing certificate, with its correlation cookie following the request's scheme because both run over plain http in-process. One more change is a known defect's: the service provider's RelayState is its data-protected state, longer than the 80 bytes SAML Bindings §3.4.3 allows, and this server's own identity provider — like Duende's — refuses a longer one (relay_state_too_long), so the test raises the second instance's SamlOptions.MaxRelayStateLength. The federation gateway guide shows how a host keeps the state server-side under a short key instead.

Follows: SAML 2.0 Web Browser SSO Profile, SAML 2.0 Bindings, OpenID Connect Core 1.0 §3.1.

Recorded from the demo host (src/Fdyay.IdentityServer.Host) by the SamlBridgeFlow test on every CI run. A value in braces, such as {access_token_1}, is one that differs on every run; the same placeholder is the same value wherever it appears. See how these pages are made.

sequenceDiagram
    participant Browser
    participant Client
    participant AuthorizationServer as Authorization Server
    participant SAMLIdentityProvider as SAML Identity Provider
    Browser->>AuthorizationServer: GET /connect/authorize
    AuthorizationServer-->>Browser: 302 Found to /account/login
    Browser->>AuthorizationServer: GET /account/login
    AuthorizationServer-->>Browser: 302 Found to /account/externallogin
    Browser->>AuthorizationServer: GET /account/externallogin
    AuthorizationServer-->>Browser: 302 Found to http://idp.partner.example/saml/sso
    Browser->>SAMLIdentityProvider: GET http://idp.partner.example/saml/sso
    SAMLIdentityProvider-->>Browser: 302 Found to /account/login
    Note over Browser,SAMLIdentityProvider: The user signs in at the identity provider's<br/>sign-in page, as in the authorization code flow.
    Browser->>SAMLIdentityProvider: GET http://idp.partner.example/saml/sso/callback
    SAMLIdentityProvider-->>Browser: 200 OK
    Browser->>AuthorizationServer: POST /saml/partner/acs
    AuthorizationServer-->>Browser: 302 Found to /Account/ExternalLogin
    Browser->>AuthorizationServer: GET /Account/ExternalLogin
    AuthorizationServer-->>Browser: 302 Found to /connect/authorize
    Browser->>AuthorizationServer: GET /connect/authorize
    AuthorizationServer-->>Browser: 302 Found to https://client.example.com/callback
    Client->>AuthorizationServer: POST /connect/token
    AuthorizationServer-->>Client: 200 OK

1. The client sends the browser to the authorize endpoint

acr_values=idp:partner-saml names the user's identity provider. Nobody is signed in here, so the server sends the browser to its sign-in page.

Browser → Authorization Server

GET /connect/authorize?client_id=web
    &response_type=code
    &scope=openid%20profile
    &redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback
    &state=af0ifjsldkj
    &nonce=n-0S6_WzA2Mj
    &code_challenge=_VOp_w53jMkBWlSwOlY2MntCuqr-wFZf-TdML17xmn4
    &code_challenge_method=S256
    &acr_values=idp%3Apartner-saml HTTP/1.1

Authorization Server → Browser

HTTP/1.1 302 Found
Location: /account/login?returnUrl=%2Fconnect%2Fauthorize%3Fclient_id%3Dweb%26response_type%3Dcode%26scope%3Dopenid%2520profile%26redirect_uri%3Dhttps%253A%252F%252Fclient.example.com%252Fcallback%26state%3Daf0ifjsldkj%26nonce%3Dn-0S6_WzA2Mj%26code_challenge%3D_VOp_w53jMkBWlSwOlY2MntCuqr-wFZf-TdML17xmn4%26code_challenge_method%3DS256%26acr_values%3Didp%253Apartner-saml

2. The sign-in page sends the user straight to that provider

Browser → Authorization Server

GET /account/login?returnUrl=%2Fconnect%2Fauthorize%3Fclient_id%3Dweb%26response_type%3Dcode%26scope%3Dopenid%2520profile%26redirect_uri%3Dhttps%253A%252F%252Fclient.example.com%252Fcallback%26state%3Daf0ifjsldkj%26nonce%3Dn-0S6_WzA2Mj%26code_challenge%3D_VOp_w53jMkBWlSwOlY2MntCuqr-wFZf-TdML17xmn4%26code_challenge_method%3DS256%26acr_values%3Didp%253Apartner-saml HTTP/1.1

Authorization Server → Browser

HTTP/1.1 302 Found
Location: /account/externallogin?scheme=partner-saml
    &returnUrl=%2Fconnect%2Fauthorize%3Fclient_id%3Dweb%26response_type%3Dcode%26scope%3Dopenid%2520profile%26redirect_uri%3Dhttps%253A%252F%252Fclient.example.com%252Fcallback%26state%3Daf0ifjsldkj%26nonce%3Dn-0S6_WzA2Mj%26code_challenge%3D_VOp_w53jMkBWlSwOlY2MntCuqr-wFZf-TdML17xmn4%26code_challenge_method%3DS256%26acr_values%3Didp%253Apartner-saml

3. This server, as a service provider, sends the browser with an AuthnRequest

On the redirect binding. RelayState is this server's protected record of the sign-in, and the correlation cookie ties the response to this browser.

Browser → Authorization Server

GET /account/externallogin?scheme=partner-saml
    &returnUrl=%2Fconnect%2Fauthorize%3Fclient_id%3Dweb%26response_type%3Dcode%26scope%3Dopenid%2520profile%26redirect_uri%3Dhttps%253A%252F%252Fclient.example.com%252Fcallback%26state%3Daf0ifjsldkj%26nonce%3Dn-0S6_WzA2Mj%26code_challenge%3D_VOp_w53jMkBWlSwOlY2MntCuqr-wFZf-TdML17xmn4%26code_challenge_method%3DS256%26acr_values%3Didp%253Apartner-saml HTTP/1.1

Authorization Server → Browser

HTTP/1.1 302 Found
Location: http://idp.partner.example/saml/sso?SAMLRequest={saml_request_1}
    &RelayState={relay_state_1}
Set-Cookie: .AspNetCore.Correlation.{correlation_id_1}=N; expires={timestamp}; path=/saml/partner/acs; samesite=none; httponly

{saml_request_1} decoded (DEFLATE, then Base64, as the redirect binding sends it):

<samlp:AuthnRequest xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{saml_id_1}" Version="2.0" IssueInstant="{timestamp}" Destination="http://idp.partner.example/saml/sso" AssertionConsumerServiceURL="http://localhost/saml/partner/acs" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
  <saml:Issuer>http://localhost/saml/partner</saml:Issuer>
</samlp:AuthnRequest>

4. The identity provider asks the user to sign in

It validates the request — a registered service provider, its own assertion consumer service — keeps it, and sends the browser to its sign-in page with a reference to it.

Browser → SAML Identity Provider

GET http://idp.partner.example/saml/sso?SAMLRequest={saml_request_1}
    &RelayState={relay_state_1} HTTP/1.1

SAML Identity Provider → Browser

HTTP/1.1 302 Found
Location: /account/login?returnUrl=%2Fsaml%2Fsso%2Fcallback%3FsamlStateId%3D{saml_state_id_1}

The user signs in at the identity provider's sign-in page, as in the authorization code flow.

5. The identity provider answers with a signed assertion

A page that posts the SAMLResponse, and this server's RelayState, to this server's assertion consumer service (the POST binding). The attributes are named by the identity provider's own claim mappings.

Browser → SAML Identity Provider

GET http://idp.partner.example/saml/sso/callback?samlStateId={saml_state_id_1} HTTP/1.1
Cookie: .AspNetCore.Antiforgery.{suffix}={antiforgery_cookie_1}; idsrv={authentication_cookie_1}

SAML Identity Provider → Browser

HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Cache-Control: no-cache,no-store
Pragma: no-cache
Set-Cookie: idsrv={authentication_cookie_2}; path=/; samesite=none; httponly

(an HTML page, not shown, that posts this form to http://localhost/saml/partner/acs as it loads:)
SAMLResponse={saml_response_1}
RelayState={relay_state_1}

{saml_response_1} decoded (Base64, as the POST binding sends it):

<samlp:Response xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{saml_id_2}" Version="2.0" IssueInstant="{timestamp}" InResponseTo="{saml_id_1}" Destination="http://localhost/saml/partner/acs" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
  <saml:Issuer>https://localhost:5001/saml</saml:Issuer>
  <samlp:Status>
    <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
  </samlp:Status>
  <saml:Assertion ID="{saml_id_3}" Version="2.0" IssueInstant="{timestamp}">
    <saml:Issuer>https://localhost:5001/saml</saml:Issuer>
    <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
      <SignedInfo>
        <CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
        <SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" />
        <Reference URI="#{saml_id_3}">
          <Transforms>
            <Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
            <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
          </Transforms>
          <DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256" />
          <DigestValue>{digest_1}</DigestValue>
        </Reference>
      </SignedInfo>
      <SignatureValue>{signature_1}</SignatureValue>
      <KeyInfo>
        <X509Data>
          <X509Certificate>{certificate_1}</X509Certificate>
        </X509Data>
      </KeyInfo>
    </Signature>
    <saml:Subject>
      <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">alice</saml:NameID>
      <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
        <saml:SubjectConfirmationData InResponseTo="{saml_id_1}" Recipient="http://localhost/saml/partner/acs" NotOnOrAfter="{timestamp}" />
      </saml:SubjectConfirmation>
    </saml:Subject>
    <saml:Conditions NotBefore="{timestamp}" NotOnOrAfter="{timestamp}">
      <saml:AudienceRestriction>
        <saml:Audience>http://localhost/saml/partner</saml:Audience>
      </saml:AudienceRestriction>
    </saml:Conditions>
    <saml:AuthnStatement AuthnInstant="{timestamp}" SessionIndex="{saml_session_index_1}">
      <saml:AuthnContext>
        <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
      </saml:AuthnContext>
    </saml:AuthnStatement>
    <saml:AttributeStatement>
      <saml:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name">
        <saml:AttributeValue>Alice Smith</saml:AttributeValue>
      </saml:Attribute>
      <saml:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress">
        <saml:AttributeValue>alice@example.com</saml:AttributeValue>
      </saml:Attribute>
    </saml:AttributeStatement>
  </saml:Assertion>
</samlp:Response>

6. The browser posts the assertion to this server

The service provider checks the signature against the identity provider's certificate, the issuer, audience, recipient, InResponseTo and times, accepts the assertion once, and keeps the user in the external cookie. The NameID is the user's subject at the provider.

Browser → Authorization Server

POST /saml/partner/acs HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Cookie: .AspNetCore.Correlation.{correlation_id_1}=N

SAMLResponse={saml_response_1}
&RelayState={relay_state_1}

Authorization Server → Browser

HTTP/1.1 302 Found
Location: /Account/ExternalLogin?handler=Callback
Cache-Control: no-cache,no-store
Pragma: no-cache
Set-Cookie: .AspNetCore.Correlation.{correlation_id_1}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/saml/partner/acs; samesite=none; httponly
Set-Cookie: idsrv.external={cookie_1}; path=/; samesite=none; httponly

7. The external sign-in page signs the user in here

A local user linked to partner-saml and the NameID, provisioned the first time.

Browser → Authorization Server

GET /Account/ExternalLogin?handler=Callback HTTP/1.1
Cookie: idsrv.external={cookie_1}

Authorization Server → Browser

HTTP/1.1 302 Found
Location: /connect/authorize?client_id=web
    &response_type=code
    &scope=openid%20profile
    &redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback
    &state=af0ifjsldkj
    &nonce=n-0S6_WzA2Mj
    &code_challenge=_VOp_w53jMkBWlSwOlY2MntCuqr-wFZf-TdML17xmn4
    &code_challenge_method=S256
    &acr_values=idp%3Apartner-saml
Cache-Control: no-cache,no-store
Pragma: no-cache
Set-Cookie: idsrv={authentication_cookie_3}; path=/; samesite=none; httponly
Set-Cookie: idsrv.external=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=none; httponly

8. The authorize endpoint issues this server's code

Browser → Authorization Server

GET /connect/authorize?client_id=web
    &response_type=code
    &scope=openid%20profile
    &redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback
    &state=af0ifjsldkj
    &nonce=n-0S6_WzA2Mj
    &code_challenge=_VOp_w53jMkBWlSwOlY2MntCuqr-wFZf-TdML17xmn4
    &code_challenge_method=S256
    &acr_values=idp%3Apartner-saml HTTP/1.1
Cookie: idsrv={authentication_cookie_3}

Authorization Server → Browser

HTTP/1.1 302 Found
Location: https://client.example.com/callback?code={authorization_code_1}
    &state=af0ifjsldkj
    &session_state={session_state_1}
    &iss=http%3A%2F%2Flocalhost
Cache-Control: no-cache,no-store
Pragma: no-cache
Set-Cookie: idsrv.session={session_id_1}; path=/; secure; samesite=none
Set-Cookie: idsrv={authentication_cookie_4}; path=/; samesite=none; httponly

9. The client redeems the code for OpenID Connect tokens

The client never sees SAML: an identity token and an access token from the server it trusts, for a local user with idp=partner-saml. Its profile is what the demo host's test-user store kept — the attributes under the names the identity provider sent — so the identity token carries no name: putting the provider's claims into the application's vocabulary is the host's normalisation.

Client → Authorization Server

POST /connect/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code={authorization_code_1}
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback
&client_id=web
&code_verifier=documented-flow-code-verifier-0123456789-abcdefghij

Authorization Server → Client

HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Cache-Control: no-store, no-cache, max-age=0
Pragma: no-cache

{
  "access_token": "{access_token_1}",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid profile",
  "id_token": "{id_token_1}"
}

{access_token_1} decoded — the header, then the payload; the signature is not shown:

{
  "alg": "RS256",
  "kid": "{key_id_1}",
  "typ": "at+jwt"
}
{
  "iss": "http://localhost",
  "iat": {iat},
  "nbf": {iat},
  "exp": {iat + 3600},
  "jti": "{jti_1}",
  "client_id": "web",
  "scope": "openid profile",
  "sub": "{local_subject_1}",
  "auth_time": {auth_time},
  "idp": "partner-saml",
  "amr": [
    "external"
  ],
  "sid": "{session_id_1}"
}

{id_token_1} decoded — the header, then the payload; the signature is not shown:

{
  "alg": "RS256",
  "kid": "{key_id_1}",
  "typ": "JWT"
}
{
  "aud": [
    "web"
  ],
  "iss": "http://localhost",
  "iat": {iat},
  "nbf": {iat},
  "exp": {iat + 300},
  "jti": "{jti_2}",
  "azp": "web",
  "sub": "{local_subject_1}",
  "auth_time": {auth_time},
  "idp": "partner-saml",
  "amr": [
    "external"
  ],
  "nonce": "n-0S6_WzA2Mj",
  "sid": "{session_id_1}",
  "at_hash": "{at_hash_1}"
}