Connect an Application
This guide connects an application to Nauthera end to end. It uses the
Quick Start stack, whose issuer is https://localhost:8080,
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.
ISSUER=https://localhost:8080The curl commands use -k because the local certificate is self-signed unless you ran
mkcert -install. Drop it 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 (--public) | 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 a local stack:
go run ./cmd/nauthera-cli create client my-app \
--display-name "My App" \
--redirect-uri http://localhost:5173/callback \
--post-logout-redirect-uri http://localhost:5173/ \
--scopes openid,profile,email,offline_access \
--grant-types authorization_code,refresh_tokenThe command prints the client secret once. Keep it as CLIENT_SECRET. For other ways to
register a client, including dynamic registration, see Clients.
To use another organization, change the issuer, not the client: an organization acme
is the issuer https://localhost:8080/orgs/acme, and its endpoints live under that path.
A client exists in exactly one organization. See
Organizations.
3. Read the discovery document
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:
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:
http://localhost:5173/callback?code=…&state=…&iss=https%3A%2F%2Flocalhost%3A8080Before 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 username, and ui_locales picks the language (en, de or fr).
Consent. A client registered through the gRPC AdminService, by nauthera-cli or the
operator, counts as approved by an administrator, so users are not asked (see
Sessions, Consent & Logout). Two cases are
asked anyway: a dynamically registered client, and a public client whose redirect URI is
not https (a native app's loopback or custom-scheme redirect, which another app 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 (#84, #87).
5. Exchange the code for tokens
The authorization code is single use and expires after 60 seconds by default.
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" | jqA 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 the tokens already issued from it are revoked.
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, issequals the issuer exactly,audcontains yourclient_id,expis in the future, andiatis recent,nonceequals the one you sent.
The ID token also carries sub, auth_time, amr, acr and sid. sid identifies
the sign-in session, which logout notifications refer to.
7. Read the user's claims
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 always false because there is no email verification yet |
The same profile and email claims are stamped 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.
curl -sk $ISSUER/token \
-u "my-app:$CLIENT_SECRET" \
-d grant_type=refresh_token \
-d refresh_token="$REFRESH_TOKEN" | jqRefresh 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, so serialize refreshes per user.
A refresh token also stops working, before it expires, when the session it came from is
ended: the user signs out, or an administrator sets their password or deletes them. 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
typisat+jwt, issis the issuer,audis what your API expects: the client'sclient_id, or theresourcethe client asked for (RFC 8707),expis in the future,scopecontains 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.
Revoking a token does not reach access tokens yet (#91): 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:
GET /end_session?id_token_hint=…&post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A5173%2F&state=…| Parameter | |
|---|---|
id_token_hint | An ID token you received. It names the session to end. |
client_id | Needed when you send no id_token_hint. |
post_logout_redirect_uri | Must exactly match a registered post-logout redirect URI. Otherwise the user sees a logged-out 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 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 is refused (#312).
Ending the session retires the refresh tokens tied to it and notifies the other applications the user signed in to during that session: through a page of iframes for clients with a front-channel logout URI, and with a signed logout token for clients with a back-channel logout URI. Back-channel logout URIs can be registered only through dynamic registration. See Sessions, Consent & 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:
go run ./cmd/nauthera-cli create client my-service \
--grant-types client_credentials \
--scopes orders:read \
--redirect-uri https://my-service.example.com/unusednauthera-cli asks every confidential client for a redirect URI. This grant never uses
it.
Then request a token with its secret:
curl -sk $ISSUER/token \
-u "my-service:$CLIENT_SECRET" \
-d grant_type=client_credentials \
-d scope="orders:read" | jqThere 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.
Devices and CLIs: the device authorization grant
A device without a usable browser asks for a code, shows it to the user, and polls:
curl -sk $ISSUER/device_authorization \
-d client_id=my-cli \
-d scope="openid offline_access" | jqThe 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:
curl -sk $ISSUER/token \
-d grant_type=urn:ietf:params:oauth:grant-type:device_code \
-d device_code="$DEVICE_CODE" \
-d client_id=my-cliKeep 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
S256on every authorization request;stateandisschecked on return. - Each refresh token is used once, and the new one is stored.
- APIs validate access tokens locally against the JWKS, including
audandtyp. - Access tokens are short-lived, since revocation does not reach them yet.