Table of Contents

Authorization code with PKCE

A user signs in to a web application. The application sends the browser to the authorize endpoint with a PKCE challenge; the user signs in there; the server sends the browser back with a one-time code; and the application redeems the code, with the verifier only it knows, for an identity token and an access token. The client is the demo host's web: a public client (no secret), PKCE required.

Follows: RFC 6749 §4.1, RFC 7636 (PKCE), OpenID Connect Core 1.0 §3.1, RFC 9207 (iss).

Recorded from the demo host (src/Fdyay.IdentityServer.Host) by the AuthorizationCodeFlow 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 AuthorizationServer as Authorization Server
    participant Client
    Browser->>AuthorizationServer: GET /connect/authorize
    AuthorizationServer-->>Browser: 302 Found to /account/login
    Browser->>AuthorizationServer: GET /account/login
    AuthorizationServer-->>Browser: 200 OK
    Browser->>AuthorizationServer: POST /account/login
    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
    Client->>AuthorizationServer: GET /connect/userinfo
    AuthorizationServer-->>Client: 200 OK

1. The client sends the browser to the authorize endpoint

code_challenge is the S256 hash of a verifier the client keeps. Nobody is signed in yet, so the server sends the browser to its sign-in page and carries the request along in returnUrl.

Browser → Authorization Server

GET /connect/authorize?client_id=web
    &response_type=code
    &scope=openid%20profile%20api
    &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 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%2520api%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

2. The user signs in

The sign-in page belongs to the host (here the Fdyay.IdentityServer.UI pages). Signing in sets the session cookies and sends the browser back to the authorize request.

Browser → Authorization Server

GET /account/login?returnUrl=%2Fconnect%2Fauthorize%3Fclient_id%3Dweb%26response_type%3Dcode%26scope%3Dopenid%2520profile%2520api%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 HTTP/1.1

Authorization Server → Browser

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: no-cache, no-store
Pragma: no-cache
Set-Cookie: .AspNetCore.Antiforgery.{suffix}={antiforgery_cookie_1}; path=/; samesite=strict; httponly

(an HTML page, not shown)

Browser → Authorization Server

POST /account/login HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Cookie: .AspNetCore.Antiforgery.{suffix}={antiforgery_cookie_1}

Input.Username=alice
&Input.Password=password
&Input.ReturnUrl=%2Fconnect%2Fauthorize%3Fclient_id%3Dweb%26response_type%3Dcode%26scope%3Dopenid%2520profile%2520api%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
&__RequestVerificationToken={antiforgery_token_1}

Authorization Server → Browser

HTTP/1.1 302 Found
Location: /connect/authorize?client_id=web
    &response_type=code
    &scope=openid%20profile%20api
    &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
Cache-Control: no-cache,no-store
Pragma: no-cache
Set-Cookie: idsrv={authentication_cookie_1}; path=/; samesite=none; httponly

3. The authorize endpoint issues a code

The code comes back on the redirect URI with the client's state, the session's session_state and the issuer (iss, RFC 9207), which tells the client which server the code is from.

Browser → Authorization Server

GET /connect/authorize?client_id=web
    &response_type=code
    &scope=openid%20profile%20api
    &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 HTTP/1.1
Cookie: .AspNetCore.Antiforgery.{suffix}={antiforgery_cookie_1}; idsrv={authentication_cookie_1}

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_2}; path=/; samesite=none; httponly

4. The client redeems the code

With the verifier: a code intercepted on its way back through the browser is useless without it. The access token is a JWT for the api scope's resource; the identity token names the user and carries the client's nonce back.

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 api",
  "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"
}
{
  "aud": [
    "api-resource"
  ],
  "iss": "http://localhost",
  "iat": {iat},
  "nbf": {iat},
  "exp": {iat + 3600},
  "jti": "{jti_1}",
  "client_id": "web",
  "scope": "api openid profile",
  "sub": "alice",
  "auth_time": {auth_time},
  "idp": "local",
  "amr": [
    "pwd"
  ],
  "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": "alice",
  "auth_time": {auth_time},
  "idp": "local",
  "amr": [
    "pwd"
  ],
  "name": "Alice Smith",
  "nonce": "n-0S6_WzA2Mj",
  "sid": "{session_id_1}",
  "at_hash": "{at_hash_1}"
}

5. The client asks userinfo for the user's claims

With the access token as a bearer token.

Client → Authorization Server

GET /connect/userinfo HTTP/1.1
Authorization: Bearer {access_token_1}

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

{
  "sub": "alice",
  "name": "Alice Smith"
}