Aller au contenu
Cette page n'a pas encore été traduite.

Signing In from a Mobile or Desktop App with PKCE

Introduction

A phone app or a desktop app signs its user in through the provider's own login page, opened in the system browser, and gets tokens back. It cannot keep a client secret: anything shipped inside an app can be pulled out of the package. So it registers as a public client, with no secret at all, and protects the authorization code with PKCE instead. The pattern is described in OAuth 2.0 for Native Apps, RFC 8252.

This guide registers such a client in OpenIDProviderApp.MinimalApi from the Getting Started repository, walks through the requests the app makes, and shows how to try the sign-in, the code exchange and the refresh from a browser and a terminal before any app exists.

In a real app, let a client library make these requests: AppAuth for iOS and AppAuth for Android, which RFC 8252 points to, or IdentityModel.OidcClient in .NET. They generate the verifier, keep it across the browser round trip, check the response and exchange the code. The requests below are what such a library sends, and what to compare when something fails.

Register the app as a public client

Add the client to the Oidc section of OpenIDProviderApp.MinimalApi/appsettings.json, next to the clients the sample already has:

JSON
{
  "ClientId": "mobile_app",
  "TokenEndpointAuthMethod": "none",
  "AllowedGrantTypes": [ "authorization_code", "refresh_token" ],
  "PkceRequired": true,
  "OfflineAccessAllowed": true,
  "RedirectUris": [ "com.example.app:/oauth2redirect" ],
  "PostLogoutRedirectUris": [ "com.example.app:/signedout" ],
  "RefreshToken": { "SlidingExpiresIn": "7.00:00:00", "AbsoluteExpiresIn": "30.00:00:00" }
}

What each setting controls:

  • TokenEndpointAuthMethod set to none makes this a public client: it presents no secret at the token endpoint, and the registration has no ClientSecrets.
  • PkceRequired is true by default and is spelled out here because PKCE is what makes an intercepted code useless: without the verifier nobody can exchange it. An authorization request without a code challenge is refused.
  • OfflineAccessAllowed and the refresh_token grant let the app ask for the offline_access scope and keep the user signed in with a refresh token instead of sending them back to the login page each time the access token expires.
  • RedirectUris is where the browser hands the result back, and PostLogoutRedirectUris is where it lands after sign-out. A registered address must match the one in the request exactly: scheme, host, port and path.
  • RefreshToken sets how long the user stays signed in. By default a refresh token lasts an hour from its last use and at most eight hours from the sign-in, which suits a web session but logs a phone user out almost every time they open the app. The values above, in the days.hours:minutes:seconds format, keep the session for a week of inactivity and a month at most.

The redirect address takes one of two forms on a phone. Where the platform supports it, prefer an HTTPS address the operating system has verified the app owns (Universal Links on iOS, App Links on Android), such as https://app.example.com/oauth2redirect: no other app can receive it. Otherwise use a private-use URI scheme, which RFC 8252, section 7.1 requires to be a domain the publisher controls in reverse order. Nothing stops another app from registering the same scheme, which is why the code must be useless without the verifier.

A desktop app usually receives the result on the loopback interface instead, at an address like http://127.0.0.1:51004/oauth2redirect. RFC 8252 lets such an app pick a free port each time it starts, but this provider compares the whole address, port included. Put that exact address in RedirectUris, have the app listen on that fixed port, and send it as redirect_uri in both the authorization request and the code exchange below.

A public client cannot prove who it is, so PKCE does not stop another app from starting its own sign-in as mobile_app. If the user already has a session with the provider in the browser, that sign-in can complete without the user doing anything. Before going live, have the provider ask for the user's consent for such clients, as RFC 8252, section 8.6 advises; the Production Hardening Checklist shows how.

Start the sign-in

The app generates a random code_verifier, derives the code_challenge from it as the base64url-encoded SHA-256 hash, and opens the authorization endpoint in the system browser:

Code
https://localhost:5006/connect/authorize
  ?response_type=code
  &client_id=mobile_app
  &redirect_uri=com.example.app:/oauth2redirect
  &scope=openid%20offline_access
  &state=<random value>
  &code_challenge=<code_challenge>
  &code_challenge_method=S256

The line breaks are only for reading; the app sends one URL. Use the system browser or the platform's authentication session (ASWebAuthenticationSession on iOS, Custom Tabs on Android, WebAuthenticator in .NET MAUI), never a web view embedded in the app: in an embedded view the app can read what the user types into the login form, which is exactly what sending the user to the provider's page is meant to prevent.

Store the verifier and the state with the pending sign-in where they survive the app being stopped while the browser is open, for example in app-private storage, and delete them once the exchange is done. On Android the system can end the app's process while the browser is in front, and a verifier kept only in memory is gone when the result arrives.

The provider shows its login page, the user signs in, and the provider redirects the browser to the redirect address with the result:

Code
com.example.app:/oauth2redirect?state=<the same value>&code=<authorization code>&iss=https%3A%2F%2Flocalhost%3A5006

The operating system delivers that address to the app registered for it. Before using the code, the app checks two things. state must be the value it sent, which ties the response to a sign-in this app started. iss must be the provider's issuer, https://localhost:5006 here, which tells the app the response came from the provider it asked rather than another one it also talks to (RFC 9207).

Exchange the code

The app posts the code to the token endpoint together with the code_verifier it stored. There is no secret to send:

Bash
curl -X POST https://localhost:5006/connect/token \
  -d grant_type=authorization_code \
  -d client_id=mobile_app \
  -d code=<authorization code> \
  -d redirect_uri=com.example.app:/oauth2redirect \
  -d code_verifier=<code_verifier>

The response carries an ID token for the signed-in user, an access token, and a refresh token, because the request asked for offline_access. Before trusting the ID token, check that its iss is the provider's issuer, its aud contains mobile_app, and it has not expired; a client library does this for you. The access token is valid for ten minutes by default (AccessTokenExpiresIn on the client), and the app sends it to its API in the Authorization: Bearer header.

A code works once and is valid for one minute by default (AuthorizationCodeExpiresIn on the client). A second attempt with the same code is rejected with invalid_grant and "The authorization code was already used", so an app whose exchange failed starts the sign-in again rather than resending the code.

Keep the user signed in

When the access token expires, the app asks for a new one with the refresh token, again without a secret:

Bash
curl -X POST https://localhost:5006/connect/token \
  -d grant_type=refresh_token \
  -d client_id=mobile_app \
  -d refresh_token=<refresh token>

The response carries a new refresh token as well, and the old one stops working: the provider rotates refresh tokens by default, for every client. Presenting a refresh token that has already been replaced is treated as theft. The request is refused with "Token was already used", and from then on the newest token of the same sign-in is refused too, with "Refresh token family was revoked", so the user has to sign in again.

Two ordinary situations trigger that, so design for them:

  • Two parts of the app refresh at the same moment, for example two requests that both got a 401. Allow one refresh at a time, and let everything else that needs a token wait for the refresh already running.
  • A refresh reached the provider but the response was lost on the network. Retrying with the old token ends the session. Save the new refresh token as soon as it arrives, before using the new access token, and if a refresh response is lost, send the user to sign in again.

If a refresh is refused with invalid_grant for any reason, the session is over: the token expired, was revoked, or its sign-in was revoked after a reuse. Delete the stored tokens and start the sign-in again; do not retry with the same token.

The refresh token is the app's longest-lived credential, so keep it in the platform's protected storage: Keychain on iOS and macOS, Keystore-backed storage on Android, the Data Protection API or Credential Manager on Windows, libsecret on Linux.

Sign out

Deleting the tokens from the app is not enough. The refresh token stays valid on the provider, and the provider's session in the system browser stays too, so the next sign-in completes without a login page, as the same user. Signing out takes two requests:

  • Revoke the refresh token at the revocation endpoint. The provider serves it once its host calls services.AddRevocation() (namespace Abblix.Oidc.Server.Features); the sample does not. A public client revokes with its client_id alone, and after that the refresh token is refused with "Token was revoked":
Bash
curl -X POST https://localhost:5006/connect/revoke \
  -d client_id=mobile_app \
  -d token=<refresh token> \
  -d token_type_hint=refresh_token
  • End the provider's session by opening its end-session endpoint in the system browser, with the ID token and the registered post-logout address. The provider ends its session and redirects to com.example.app:/signedout?state=<value>, and the next sign-in shows the login page again:
Code
https://localhost:5006/connect/endsession
  ?id_token_hint=<ID token>
  &post_logout_redirect_uri=com.example.app:/signedout
  &state=<random value>

Then delete the stored tokens.

Try it without an app

You can walk through the sign-in, the exchange and the refresh before writing the app. With the mobile_app registration in appsettings.json, trust the development certificate once with dotnet dev-certs https --trust, and start the provider from the repository root:

Bash
dotnet run --project OpenIDProviderApp.MinimalApi

Make a verifier and its challenge in a terminal:

Bash
VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')
CHALLENGE=$(printf %s "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
echo "$CHALLENGE"

Join the authorization URL from Start the sign-in into one line, put $CHALLENGE in code_challenge and any random string in state. Open the developer tools' network tab with "Preserve log" turned on, open the URL in the browser, and sign in as the sample's test user (its email and password are in the provider's Program.cs). The browser then tries to open com.example.app:/oauth2redirect?... and fails, because no app owns the scheme. The address is in the Location header of the last /connect/authorize response.

The code is valid for one minute, so have the exchange command from Exchange the code typed in the same terminal, with code_verifier=$VERIFIER, before you sign in, and paste the code into it. Then take the refresh_token from the response and run the request from Keep the user signed in: it returns a new refresh token, and sending the old one again is refused.

Troubleshooting

The app receives invalid_request: "The client requires PKCE code challenge"

The authorization request carried no code_challenge. The provider sends this error back to the app's redirect address, so it arrives where the code would have. Add code_challenge and code_challenge_method=S256 to the request.

The app receives invalid_scope: "This client is not allowed to request for offline access"

The request asked for offline_access, but the registration does not set OfflineAccessAllowed to true. Like the PKCE error, it comes back to the app's redirect address.

The provider answers 400 instead of redirecting

The redirect_uri in the request does not match a registered address exactly. The provider does not redirect to an address it cannot trust, so the error stays on its own page. Compare the scheme, host, port and path of the two addresses.

invalid_grant from the token endpoint

The error_description tells you which case it is. "The authorization code was already used" means the code was presented before. "Code verifier is not valid" means the code_verifier does not match the challenge sent with the authorization request, usually because the app generated a new verifier instead of using the one it stored when the sign-in started. A code older than one minute is refused as well, so exchange it as soon as the app receives it. For refresh tokens, see Keep the user signed in.

Next steps

  • PKCE in OpenID Connect explains what the verifier and the challenge protect against.
  • Production Hardening Checklist covers refresh token reuse, consent and the rest of what to settle before real users arrive.
  • Machine-to-Machine (Client Credentials) shows how an API validates the access tokens it receives. An API that checks its audience and scope, like the one there, accepts this app's token only when the authorization request also names the API's resource and scope.