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}"
}