# Connect an Application

> Connect an application to nauthera-server step by step: the client type, registration, authorization code with PKCE, refresh, token validation and sign-out.

This guide connects an application to Nauthera end to end. It uses the
[Quick Start](https://www.nauthera.io/docs/v1/quick-start) stack, whose issuer is `https://localhost:8443`,
and plain `curl` so every request is visible. In a real application, an OpenID Connect
library does these steps for you. Point it at the issuer and it reads everything else
from the discovery document.

```bash
ISSUER=https://localhost:8443
```

The `curl` commands use `-k` because the local certificate comes from a CA the chart
created, which your machine does not trust; pass `--cacert nauthera-local-ca.crt` from
the Quick Start instead if you saved it. Drop both against a real deployment.

## 1. Pick a client type

| Your application | Client | Grant | Authentication at `/token` |
|---|---|---|---|
| Web app with a server-side backend | Confidential | `authorization_code` (+ `refresh_token`) | `client_secret_basic`, `client_secret_post` or `private_key_jwt` |
| Single-page app, mobile or desktop app | Public (`tokenEndpointAuthMethod: none`) | `authorization_code` with PKCE | `none` |
| Service calling an API, with no user | Confidential | `client_credentials` | As for a web app |
| CLI, TV or device without a browser | Public or confidential | `urn:ietf:params:oauth:grant-type:device_code` | `none` or a client credential |

PKCE with `S256` is required by default for every client, confidential ones included.
Implicit and password grants are refused.

## 2. Register the client

Register it in the organization your users belong to. For the main organization on the
Quick Start stack, declare an `OIDCClient` in the `nauthera` namespace, as
[Quick Start](https://www.nauthera.io/docs/v1/quick-start#4-register-your-own-client) shows:

```yaml
apiVersion: nauthera.io/v1alpha1
kind: OIDCClient
metadata:
  name: my-app
  namespace: nauthera
spec:
  clientId: my-app
  displayName: My App
  redirectUris:
    - http://localhost:5173/callback
  postLogoutRedirectUris:
    - http://localhost:5173/
  grantTypes:
    - authorization_code
    - refresh_token
  responseTypes:
    - code
  scopes:
    - openid
    - profile
    - email
    - offline_access
  tokenEndpointAuthMethod: client_secret_basic
  pkcePolicy: required_s256
  allowRefresh: true
  secret:
    generate: true
```

```bash
kubectl apply -f my-app.yaml
CLIENT_SECRET=$(kubectl -n nauthera get secret my-app -o jsonpath='{.data.client-secret}' | base64 -d)
```

The operator writes the generated secret into the Secret `my-app` once. For other ways
to register a client, including dynamic registration, see [Clients](https://www.nauthera.io/docs/v1/clients).

To use another organization, change the issuer, not the client: an organization `acme`
is the issuer `https://localhost:8443/orgs/acme`, and its endpoints live under that path.
A client exists in exactly one organization. See
[Organizations](https://www.nauthera.io/docs/v1/organizations).

## 3. Read the discovery document

```bash
curl -sk $ISSUER/.well-known/openid-configuration \
  | jq '{issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, end_session_endpoint}'
```

Configure your library with the issuer alone wherever it supports discovery. The
document lists only endpoints and features the server actually serves.

## 4. Send the user to sign in

Create a PKCE verifier and its S256 challenge, plus a `state` and a `nonce`:

```bash
VERIFIER=$(openssl rand -base64 48 | tr -d '=+/\n' | cut -c1-64)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
STATE=$(openssl rand -hex 16)
NONCE=$(openssl rand -hex 16)

echo "$ISSUER/authorize?response_type=code&client_id=my-app\
&redirect_uri=http%3A%2F%2Flocalhost%3A5173%2Fcallback\
&scope=openid%20profile%20email%20offline_access\
&code_challenge=$CHALLENGE&code_challenge_method=S256\
&state=$STATE&nonce=$NONCE"
```

Open the printed URL in a browser. The hosted UI signs the user in, runs MFA if they have
it, and asks for consent when consent is needed (see below). The browser then returns to
your redirect URI:

```text
http://localhost:5173/callback?code=…&state=…&iss=https%3A%2F%2Flocalhost%3A8443
```

Always start a sign-in this way. The hosted sign-in screen serves only a request that
names a registered client: opened directly, `/login` answers `404`.

Before you use the code, check that `state` is the one you sent and that `iss` is the
issuer you started with (RFC 9207). Both checks are what stop a code from another flow,
or another server, being slipped into yours.

**Useful parameters.** `prompt=login` forces a fresh sign-in, `prompt=none` fails instead
of showing any screen, `max_age` limits how old the sign-in may be, `login_hint`
prefills the email field, and `ui_locales` picks the language (`en`, `de` or `fr`).

**Consent.** A client registered through the gRPC AdminService, for example by the
operator from an `OIDCClient`, counts as approved by an administrator, so users are not asked (see
[Sessions, Consent & Logout](https://www.nauthera.io/docs/v1/sessions#consent)). Two cases are
asked anyway: a dynamically registered client, and a public client whose redirect URI is
not `https` (a native app's loopback redirect, which another app on the device could
claim). `prompt=consent` always asks.

**Redirect URIs** must match a registered one exactly. Loopback redirects on any port and
private-use URI schemes for native apps are not supported yet.

## 5. Exchange the code for tokens

The authorization code is single use and expires after 60 seconds by default.

```bash
CODE=…   # from the callback
curl -sk $ISSUER/token \
  -u "my-app:$CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  -d redirect_uri=http://localhost:5173/callback \
  -d code_verifier="$VERIFIER" | jq
```

A public client sends `client_id=my-app` in the body instead of `-u`. The response
carries `access_token`, `id_token`, `token_type`, `expires_in`, `scope` and, because
`offline_access` was granted, `refresh_token`.

Redeeming the same code twice fails, and revokes the grant the first redemption started:
its refresh token stops working, and its access tokens are refused at the server's own
endpoints, such as UserInfo. An API that validates them locally accepts them until they
expire.

## 6. Validate the ID token

The ID token is a JWT signed with ES256 or RS256 by a key in the issuer's JWKS
(`/.well-known/jwks.json`). Your library should check:

- the signature, against the JWKS key named by the token's `kid`,
- `iss` equals the issuer exactly,
- `aud` contains your `client_id`,
- `exp` is in the future, and `iat` is recent,
- `nonce` equals the one you sent.

The ID token also carries `sub`, `auth_time`, `amr`, `sid` and `at_hash`. After an MFA
sign-in it carries `acr` too, and when their scope is granted it carries the profile and
email claims from step 7. `sid` identifies the sign-in session, which logout notifications
refer to.

## 7. Read the user's claims

```bash
curl -sk $ISSUER/userinfo -H "Authorization: Bearer $ACCESS_TOKEN" | jq
```

| Scope | Claims |
|---|---|
| `openid` | `sub` |
| `profile` | `preferred_username` (the username) and `name` (the user's `name` attribute) |
| `email` | `email`, and `email_verified`, which is `true` once the address is verified: by the user following the link the server emailed, or by an administrator who vouches for it. A change of address clears it. |

The same profile and email claims are stamped into the ID token and into user access
tokens when their scope is granted.

## 8. Refresh

A refresh token is issued only when the client may use `refresh_token` **and** the user
granted `offline_access`.

```bash
curl -sk $ISSUER/token \
  -u "my-app:$CLIENT_SECRET" \
  -d grant_type=refresh_token \
  -d refresh_token="$REFRESH_TOKEN" | jq
```

Refresh tokens **rotate on every use**. Store the new refresh token from each response
and discard the old one. Presenting a refresh token that has already been used revokes
the whole family descended from it, which signs the user out of your application. Two
concurrent requests racing with the same refresh token cause exactly this, and so does
retrying a refresh whose response was lost, so serialize refreshes per user. Telling a
retry apart from a stolen token's replay is planned.

When the scope includes `openid`, the refresh response also carries a new ID token. It
keeps `sid` but carries no `auth_time`, `amr`, `acr` or `nonce`, because no new sign-in
happened.

A refresh token also stops working, before it expires, when the session it came from is
ended: the user signs out, or an administrator ends the session. A password reset, by the
user or an administrator, a change of the user's second factor, an administrator ending
all of the user's sessions, and deleting the user retire every refresh token the account
holds, whatever session it came from. A suspended or blocked account cannot refresh
either. Your
application then gets `invalid_grant` and has to send the user through `/authorize`
again. Every refusal carries the same description, so treat any `invalid_grant` on
refresh that way.

## 9. Validate access tokens in your API

Access tokens are JWTs with the header `typ: at+jwt` (RFC 9068). A resource server should
validate them **locally** against the JWKS:

- the signature, and `typ` is `at+jwt`,
- `iss` is the issuer,
- `aud` is what your API expects: the client's `client_id`, or the `resource` the client
  asked for (RFC 8707),
- `exp` is in the future,
- `scope` contains what the endpoint needs.

Do not plan on `/introspect` for this. Introspection is caller-bound: only the
confidential client a token was issued to can introspect it, so a separate API sees every
other client's tokens as inactive.

If the token carries `cnf.jkt`, it is DPoP-bound. Accept it only with a valid `DPoP`
proof for that key. See [Tokens & Sender Constraint](https://www.nauthera.io/docs/v1/tokens#dpop-sender-constrained-tokens).

Revoking a token does not reach access tokens yet: a revoked or logged-out access
token stays valid until it expires, 10 minutes by default. Keep access token lifetimes
short.

## 10. Sign the user out

Send the browser to the end-session endpoint, preferably as a form `POST` so the ID token
stays out of browser history and logs:

```text
GET /end_session?id_token_hint=…&client_id=my-app&post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A5173%2F&state=…
```

| Parameter | |
|---|---|
| `id_token_hint` | An ID token you received. It names the user who is signing out. |
| `client_id` | Send it on every logout. Without it, `post_logout_redirect_uri` is honoured only when a valid `id_token_hint` is sent and the user is not asked to confirm. |
| `post_logout_redirect_uri` | Must exactly match a post-logout redirect URI registered for the client. Otherwise the browser goes to the server's own `/logout` page instead. |
| `state` | Returned on the redirect. |
| `ui_locales` | Language of the confirmation screen. |

When the `id_token_hint` verifies and names the user who is signed in, the session ends
at once. Without a valid hint, or with one for someone else, the hosted UI asks the user
to confirm first. If nobody is signed in, the browser goes straight to
`post_logout_redirect_uri`. An expired `id_token_hint` counts as no hint, so a signed-in
user is asked to confirm. Accepting it, as the spec allows, is planned.

Ending the session retires the refresh tokens tied to it and tells the applications the
user signed in to during that session, through the logout URIs they registered. Clients
with a back-channel logout URI get a signed logout token on every logout path. Clients
with a front-channel logout URI get it loaded in a page of iframes only when
`/end_session` ends the session at once, not after the confirmation screen. Back-channel
logout URIs can be registered only through dynamic registration. See
[Sessions, Consent & Logout](https://www.nauthera.io/docs/v1/sessions#logout).

## Machine-to-machine: client credentials

A service with no user gets a token for itself. Register it for the
`client_credentials` grant, with the scopes your API checks:

```yaml
apiVersion: nauthera.io/v1alpha1
kind: OIDCClient
metadata:
  name: my-service
  namespace: nauthera
spec:
  clientId: my-service
  grantTypes:
    - client_credentials
  scopes:
    - orders:read
  tokenEndpointAuthMethod: client_secret_basic
  secret:
    generate: true
```

Then request a token with the secret the operator writes into the Secret `my-service`:

```bash
curl -sk $ISSUER/token \
  -u "my-service:$CLIENT_SECRET" \
  -d grant_type=client_credentials \
  -d scope="orders:read" | jq
```

There is no refresh token and no ID token. Request a new access token when the old one
expires. To bind it to a key, send a DPoP proof with the request. Every proof must carry
a nonce from this server, so a first proof without one is refused with
`400 use_dpop_nonce` and a `DPoP-Nonce` header. Send the request again with a new proof
that carries that nonce. See
[Tokens & Sender Constraint](https://www.nauthera.io/docs/v1/tokens#dpop-sender-constrained-tokens).

## Devices and CLIs: the device authorization grant

A device without a usable browser asks for a code, shows it to the user, and polls:

```bash
curl -sk $ISSUER/device_authorization \
  -d client_id=my-cli \
  -d scope="openid offline_access" | jq
```

The response has:

| Field | Value |
|---|---|
| `user_code` | Eight letters shown as `XXXX-XXXX`, drawn from consonants only so they are easy to read out and type |
| `verification_uri` | The issuer followed by `/device` |
| `verification_uri_complete` | The same URL with the code filled in, for a QR code. The user still confirms the code they were shown. |
| `device_code` | What the device polls with. Keep it to the device. |
| `expires_in` | 900 seconds: both codes live for 15 minutes |
| `interval` | 5 seconds between polls |

Show the user the code and the URL. They sign in on another device, enter the code on
the hosted `/device` screen and approve or deny. Meanwhile, poll the token endpoint no
faster than `interval`:

```bash
curl -sk $ISSUER/token \
  -d grant_type=urn:ietf:params:oauth:grant-type:device_code \
  -d device_code="$DEVICE_CODE" \
  -d client_id=my-cli
```

Keep polling while the answer is `authorization_pending`. After `slow_down`, add five
seconds to the interval and keep it. Stop on `access_denied` or `expired_token`.

## Checklist

- [ ] The client lives in the organization whose users sign in, and your library uses
      that organization's issuer.
- [ ] Redirect and post-logout redirect URIs are registered exactly as sent.
- [ ] PKCE `S256` on every authorization request; `state` and `iss` checked on return.
- [ ] Each refresh token is used once, and the new one is stored.
- [ ] APIs validate access tokens locally against the JWKS, including `aud` and `typ`.
- [ ] Access tokens are short-lived, since revocation does not reach them yet.
