# 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](https://www.abblix.com/en/docs/glossary-overview#client-secret): anything shipped inside an app can be pulled out of the package. So it registers as a [public client](https://www.abblix.com/en/docs/glossary-overview#public-client), with no secret at all, and protects the [authorization code](https://www.abblix.com/en/docs/glossary-overview#authorization-codes) with [PKCE](https://www.abblix.com/en/docs/pkce-in-openid-connect) instead. The pattern is described in OAuth 2.0 for Native Apps, [RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252).

This guide registers such a client in `OpenIDProviderApp.MinimalApi` from the [Getting Started repository](https://github.com/Abblix/Oidc.Server.GettingStarted), 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](https://www.abblix.com/en/docs/glossary-overview#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](https://datatracker.ietf.org/doc/html/rfc8252#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](https://datatracker.ietf.org/doc/html/rfc8252#section-8.6) advises; the [Production Hardening Checklist](https://www.abblix.com/en/docs/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](https://www.abblix.com/en/docs/glossary-overview#authorization-endpoint) in the system browser:

```text
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:

```text
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](https://datatracker.ietf.org/doc/html/rfc9207)).

## 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](https://www.abblix.com/en/docs/glossary-overview#id-token-identity-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:

```text
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](#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](#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](#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](#keep-the-user-signed-in).

## Next steps

- [PKCE in OpenID Connect](https://www.abblix.com/en/docs/pkce-in-openid-connect) explains what the verifier and the challenge protect against.
- [Production Hardening Checklist](https://www.abblix.com/en/docs/production-hardening-checklist) covers refresh token reuse, consent and the rest of what to settle before real users arrive.
- [Machine-to-Machine (Client Credentials)](https://www.abblix.com/en/docs/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.
