Connect an Application
Docs version v1 (main)Nauthera is pre-1.0, in a design-partner programUpdated
This guide connects an application to Nauthera end to end. It uses the
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.
ISSUER=https://localhost:8443The 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. 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:) | 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: | 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 shows:
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: truekubectl 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.
To use another organization, change the issuer, not the client: an organization acme
is the issuer https:, 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%3A8443Always 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). 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.
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 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, 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, 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
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.
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, 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
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: 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=…&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.
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:
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: trueThen request a token with the secret the operator writes into the Secret my-service:
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. 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.
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_ | 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.