# Kenni Documentation — Full Corpus
The complete contents of https://developers.kenni.is/docs, concatenated for use in LLM context windows. Each section below is the raw markdown of one page; section dividers mark page boundaries.
Last updated: 2026-05-07
---
# Overview
Kenni is an Iceland-first identity provider built on **OpenID Connect** and **OAuth 2.0**. Drop it into your application to get specification-compliant authentication, passkey-based sign-in, and access to Iceland's national identification flows — without writing your own auth.
These docs cover everything from your first integration to advanced features like delegations and single sign-on.
## Start here
If you're new to Kenni, the [Get started](https://developers.kenni.is/docs/get-started) guide walks you through creating a team and your first application in a few minutes.
## Using these docs with AI
Append `.md` to any docs URL for the raw markdown source — e.g. `https://developers.kenni.is/docs/applications.md`. The full corpus is also published in two AI-friendly forms:
- [`/llms.txt`](https://developers.kenni.is/llms.txt) — [llmstxt.org](https://llmstxt.org)-format index pointing to every page's raw markdown with a one-line description.
- [`/llms-full.txt`](https://developers.kenni.is/llms-full.txt) — the entire docs corpus concatenated into a single file for "drop into the context window" use.
Most agents don't auto-discover these yet, so point yours at the URL explicitly. Tools that read HTML `` tags will pick up `/llms.txt` automatically.
---
# Get started
Setting up Kenni takes three quick steps in the [developer portal](https://developers.kenni.is).
1. **Sign in** — open [developers.kenni.is](https://developers.kenni.is) and choose **Continue with Kenni** to create or open your account.
2. **Create a team** — pick a friendly name, a stable domain like `my-amazing-app.is` (it prefixes your client IDs and API scopes), and your invoice details.
3. **Create your first application** — choose a type (web, native, or machine-to-machine) and your redirect URIs. The portal generates a `client_id` and, for confidential clients, a `client_secret`.
> **Note.** New teams start on the **developer plan**: no costs, but only team members can sign in. Upgrade from team settings when you're ready to launch.
---
# Applications
An application (client) is the integration point between your software and Kenni. Each application has its own credentials, redirect URIs, branding, and feature configuration. You manage applications from the **Applications** section of the [developer portal](https://developers.kenni.is).
When you create an application, Kenni issues a `client_id` (prefixed with your team domain, e.g. `@my-app.is/web`) and — for confidential clients — a `client_secret`. These identify your application to the OIDC endpoints under `https://idp.kenni.is/`.
## Application types
Pick the type that matches how your software talks to Kenni. The type controls which grants are available, whether a `client_secret` is issued, and which fields you can configure.
| Type | Use for | Client secret | Grants |
| ---------------------- | --------------------------------------------------------- | ------------- | --------------------------------------------------------------- |
| **Web** | Server-rendered apps and APIs that can keep a secret | Yes | `authorization_code`, `refresh_token` |
| **Web (SPA)** | Single-page apps running entirely in the browser | No | `authorization_code` (PKCE), `refresh_token` |
| **Native** | iOS, Android, and desktop apps | No | `authorization_code` (PKCE), `refresh_token` |
| **Machine to Machine** | Backend-to-backend integrations with no end user | Yes | `client_credentials` |
| **Device** | Smart TVs, CLI tools, and other input-constrained devices | No | `urn:ietf:params:oauth:grant-type:device_code`, `refresh_token` |
> **Note.** The **Device** application type runs OAuth 2.0's [Device Authorization Grant](https://datatracker.ietf.org/doc/html/rfc8628) and is currently behind a plan-tier flag. [Get in touch](mailto:hello@kenni.is) and we'll enable it for your team. See [Device code flow](https://developers.kenni.is/docs/features/device-code) for the full request/response walkthrough.
## Redirect URIs
Web, SPA, and Native applications must declare every URI Kenni is allowed to redirect the user back to after authentication. Anything not on the list is rejected. Add one URI per line in the application settings.
Machine-to-Machine and Device applications don't use redirect URIs — M2M obtains tokens directly from the token endpoint, and Device flows verify the user out-of-band via the activation page.
## Client credentials
For confidential clients (Web and M2M), Kenni stores a hashed `client_secret`. The full secret is shown once when the application is created and is then masked. You can copy the masked value, but the only way to recover a lost secret is to **rotate** it from the application's Settings tab — which invalidates the old secret immediately.
Public clients (SPA, Native, Device) don't get a secret. They authenticate the user via PKCE (for code-flow clients) or the device verification step instead.
## Endpoints
Each team has its own OIDC issuer at `https://idp.kenni.is/`. The standard endpoints are derived from it:
- Discovery — `/.well-known/openid-configuration`
- Authorization — `/oidc/auth`
- Token — `/oidc/token`
- Token introspection — `/oidc/token/introspection`
- User info — `/oidc/me`
- JWKS — `/oidc/jwks`
Device applications also expose:
- Device authorization — `/oidc/device/auth`
- Device verification — `/activate`
The exact URLs for an application are available on its **Overview** tab in the portal.
---
# Settings
The **Settings** tab on an application controls everything from its identifiers and redirect URIs to token lifetimes and feature toggles. Changes take effect immediately — there's no separate publish step.
## Core fields
These fields are required and shown to every application type.
- **Name** — The display name shown to users in the login flow. Pick something they'll recognize.
- **Client ID** — The stable identifier your code uses. Auto-generated from the name on create, but you can edit it. Allowed characters: alphanumerics, underscores, and dashes. Kenni prefixes it with your team domain at runtime, e.g. `@my-app.is/web`.
- **Application type** — Web, Web (SPA), Native, Machine to Machine, or Device. See [Applications overview](https://developers.kenni.is/docs/applications) for what each one means.
- **Application URI** _(Web / SPA / Native)_ — The absolute URL of your app. If something goes wrong mid-login, Kenni offers users a button to return here.
- **Redirect URIs** _(Web / SPA / Native)_ — One URI per line. Kenni only redirects to URIs on this list. Wildcards are not allowed; list every callback explicitly.
## Client secret
Only Web and Machine-to-Machine applications get a `client_secret`. The full value is shown once when the application is created. After that, the Settings tab displays a masked value you can copy.
If you suspect the secret has leaked, use **Rotate client secret** at the bottom of the Settings tab. Rotation is immediate and irreversible — every integration using the old secret will start failing until updated.
> **Warning.** Never embed a `client_secret` in a SPA, native app, or anything that ships to end users. If you're unsure, use the SPA, Native, or Device application type instead — they authenticate without a secret.
## Token lifetimes
Hidden inside the **Token lifetimes (TTLs)** collapsible section. Values are in seconds.
- **Access token TTL** — How long an access token is valid. Default `7200` (2 hours).
- **ID token TTL** — How long an ID token is valid. Default `7200` (2 hours).
- **Refresh token TTL** — How long a refresh token is valid. Default `86400` (1 day). Not shown for M2M (no refresh tokens).
Shorter TTLs limit the blast radius of a leaked token but mean more refresh round-trips. The defaults are a reasonable starting point; tune only if you have a specific reason.
## Advanced settings
Hidden inside the **Advanced settings** collapsible section. Not available for Machine-to-Machine or Device clients.
- **Initiate login URI** — A URL on your side that can start the login flow. Used by Kenni for recoverable errors.
- **Post logout redirect URIs** — Allowed return URLs after RP-initiated logout. One per line. Particularly relevant when [single sign-on](https://developers.kenni.is/docs/features/single-sign-on) is enabled.
- **Require PKCE** _(Web only)_ — On by default. SPA and Native clients always require PKCE; this toggle exists only because confidential Web clients can disable it for legacy integrations. Leave it on unless you know you need to.
- **Enable test user access** — Lets [test users](https://developers.kenni.is/docs/features/test-users) (configured under **Settings → Test users**) authenticate to this application. Only shown if your plan supports test users.
- **Skip passkey prompt** — Hides the passkey registration prompt for this application. See [Skipping passkeys](https://developers.kenni.is/docs/features/skipping-passkeys). Only shown if your plan supports it.
## Branding, consent, scopes, delegation
These live on their own tabs alongside Settings:
- **Branding** — Logo, accent color, border radius, and light/dark appearance. See [Theming](https://developers.kenni.is/docs/applications/theming).
- **Consent** — Identity scopes the application can request and which ones the user must agree to. See [Consent](https://developers.kenni.is/docs/features/consent). Plan-gated.
- **API Scopes** — Authorize this application to request your team's API scopes.
- **Delegation** — Self-delegation toggle plus [company](https://developers.kenni.is/docs/features/company-delegations) and [custom](https://developers.kenni.is/docs/features/custom-delegations) delegation configuration. Plan-gated.
## Deleting an application
The **Delete your application** action at the bottom of the Settings tab is permanent and irreversible. Make sure no production traffic still depends on the `client_id` — once deleted, every authentication and token request against it will fail.
---
# Theming
Theming controls how the Kenni-hosted login screen looks when users authenticate to your application. You can match your brand's colors, set the corner radius, force a light or dark appearance, and upload a logo. Theming is configured per application from the **Branding** tab in the [developer portal](https://developers.kenni.is).
Machine-to-Machine clients have no user-facing surface, so they don't have a Branding tab. Every other application type — Web, SPA, Native, and Device — does.
> **Note.** Themed branding only applies to pages where Kenni knows which client is in play. For pages without a client context (the device activation entry page, error pages, and the post-logout screen) Kenni falls back to your team's global branding. Configure that under **Settings → Branding** in the portal.
## Logo
Upload separate logos for light and dark mode under **Branding → Logo**. The light-mode logo is used as the default if you don't supply a dark-mode variant.
- Square crop, sized to fit the login screen header.
- PNG or JPG.
- Use a transparent background so the logo blends with both light and dark themes.
The image is cropped in-browser before upload, so you can pick a larger source and trim it to a square in the portal.
## Theme
The **Theme** section under **Branding** maps directly onto the Radix Themes design tokens. Pick:
- **Accent color** — the primary color used for buttons, links, focus rings, and the Kenni accent. Choose something that pairs with your logo. The picker shows every available swatch.
- **Border radius** — `none`, `small`, `medium`, `large`, or `full`. Applied to inputs, buttons, and cards on the login screen.
- **Appearance** — `light`, `dark`, or `inherit`. `inherit` uses the user's system preference; `light` and `dark` force a specific mode regardless of the user's setting.
Changes save when you click **Save theme** and take effect on the next authentication.
## Tips
- Preview your theme by starting an authentication flow against the application. There's no in-portal preview yet.
- If your logo looks dim against a dark background, supply a dedicated dark-mode logo rather than relying on the light one.
- Picking an unfamiliar accent isn't free — `inherit` plus a strong logo is often a cleaner look than a custom accent that clashes with your brand.
---
# API scopes
Scopes are how OAuth 2.0 and OpenID Connect express what an access token is allowed to do. Your application asks for scopes during authentication, the user (or the consent configuration) approves them, and Kenni issues tokens that carry only those scopes.
Kenni separates scopes into two categories:
- **Identity scopes** — Built-in scopes that release information about the authenticated user (or, for delegated sessions, the actor). They map to claims on the ID token and the user-info endpoint. The full list lives on [Standard scopes](https://developers.kenni.is/docs/api-scopes/standard).
- **Custom API scopes** — Scopes you define for your team's own APIs. They protect your backend resources, not user data. They're prefixed with your team domain (e.g. `@my-app.is/orders.read`) and managed from **API scopes** in the [developer portal](https://developers.kenni.is).
## Requesting scopes
Scopes are sent as a space-separated `scope` parameter on the authorization request:
```
scope=openid profile email @my-app.is/orders.read
```
The token endpoint then issues an access token that carries only the scopes the client is allowed to request and the user has consented to. Anything you didn't ask for is silently dropped.
`openid` is mandatory for any OpenID Connect flow — without it Kenni won't issue an ID token. `offline_access` is required for a refresh token to be issued.
## Access token format
The access token Kenni issues comes in one of two shapes, depending on whether a custom API scope was requested:
- **JWT access token** — issued when the request includes at least one custom API scope. Self-contained, signed, and verifiable by your resource server using Kenni's JWKS endpoint without an extra round-trip.
- **Opaque access token** — issued when no custom API scope was requested. Not a JWT — it's a reference to a session held inside Kenni. You can still call `/oidc/me` (user-info) and `/oidc/token/introspection` with it, but it carries no claims your resource server can read directly.
If your application needs to validate access tokens at the edge of your own API, request at least one custom API scope so you get a JWT. If you only need user info, an opaque token is fine.
## Custom API scopes
Custom API scopes let you split your backend into independently authorizable surfaces. A typical pattern is one scope per resource and verb (`orders.read`, `orders.write`), but the granularity is yours to decide.
Create a scope from **API scopes** in the portal:
- **Name** — alphanumerics, underscores, and dashes. Kenni prefixes it with your team domain at runtime, so `orders.read` becomes `@my-app.is/orders.read` in the request.
- **Description** — internal note shown in the portal. Not visible to end users.
A new scope is inert until at least one application is authorized to request it. Open the application's **API Scopes** tab and toggle the scope on. From then on, that application can include the scope on its authorization or token request.
> **Note.** Scopes are case-sensitive. The name you create in the portal is the exact string your code must send.
## Machine-to-machine clients
M2M clients use the `client_credentials` grant and don't have an end user to consent to anything. The same opaque-vs-JWT rule applies: authorize at least one custom API scope so the resulting access token is a JWT your services can validate locally. An M2M client with no API scopes can still call user-info and introspection, but most M2M use cases want a verifiable token.
## Renaming and deleting scopes
Renaming a scope or deleting it is immediate and breaking. Every application requesting the old name will start failing on the next token request. Coordinate with downstream consumers before changing anything in production.
---
# Standard scopes
Kenni ships with a fixed set of identity scopes for the authenticated user, the company on whose behalf the user is acting (for delegated sessions), and the actor (the human in a delegation). Each scope releases one or more claims on the ID token and the user-info endpoint.
Scopes marked **auto-consent** are released without prompting the user. The remaining scopes require your application to have the consent feature enabled — they only show up in the **Consent** tab of an application once consent is on.
> **Note.** The exact scopes a given application can request — filtered by its plan tier and feature configuration — are listed under **Requestable identity scopes** on the application's **Overview** tab in the [developer portal](https://developers.kenni.is).
## Session
| Scope | Claims | Notes |
| ---------------- | ---------------- | -------------------------------------------------------- |
| `openid` | `sub`, `actor` | Required for any OIDC flow. Auto-consent. |
| `offline_access` | `offline_access` | Required for a refresh token to be issued. Auto-consent. |
## User identity
Released about the authenticated end user.
| Scope | Claims | Notes |
| ----------------------- | --------------------------------------- | --------------------------------------------------- |
| `display_name` | `display_name`, `name` | The user's editable display name. Requires consent. |
| `picture` | `picture` | Profile picture. Requires consent. |
| `national_id` | `national_id` | National identification number. Auto-consent. |
| `audkenni_name` | `audkenni_name`, `name` | Name as reported by Audkenni. Auto-consent. |
| `audkenni_phone_number` | `audkenni_phone_number` | Phone number as reported by Audkenni. Auto-consent. |
| `phone_number` | `phone_number`, `phone_number_verified` | Editable phone number. Requires consent. |
| `email` | `email`, `email_verified` | Email address. Requires consent. |
## Company
Released for delegated sessions where the user is acting on behalf of a company. Requires the company delegation feature.
| Scope | Claims | Notes |
| ---------------------- | ------------------------------ | --------------------------------------------------- |
| `company_display_name` | `company_display_name`, `name` | Company's editable display name. Requires consent. |
| `company_name` | `company_name`, `name` | Legal name from the company registry. Auto-consent. |
| `company_email` | `company_email` | Company email. Requires consent. |
| `company_phone_number` | `company_phone_number` | Company phone number. Requires consent. |
| `company_logo` | `company_logo` | Company logo. Requires consent. |
## Actor
In a delegated session the actor is the human acting on behalf of the subject. Released under the `actor.*` namespace. Requires the delegation feature.
| Scope | Claims | Notes |
| ----------------------------- | ----------------------------------- | ------------------------------------------------ |
| `actor_display_name` | `actor.display_name`, `actor.name` | Actor's editable display name. Requires consent. |
| `actor_audkenni_name` | `actor.audkenni_name`, `actor.name` | Actor's name from Audkenni. Auto-consent. |
| `actor_audkenni_phone_number` | `actor.audkenni_phone_number` | Actor's phone from Audkenni. Auto-consent. |
| `actor_picture` | `actor.picture` | Actor's profile picture. Requires consent. |
| `actor_national_id` | `actor.national_id` | Actor's national ID. Auto-consent. |
| `actor_phone_number` | `actor.phone_number` | Actor's editable phone number. Requires consent. |
| `actor_email` | `actor.email` | Actor's email. Requires consent. |
| `delegation_type` | `delegation_type` | The type(s) of delegation in use. Auto-consent. |
## Deprecated
These scopes are kept for backwards compatibility. Use the replacement instead.
| Scope | Replacement | Claims |
| --------------- | -------------------- | ------------ |
| `profile` | `display_name` | `name` |
| `actor_profile` | `actor_display_name` | `actor.name` |
---
# No framework (curl)
> **Note.** Use this guide to learn the protocol, debug other integrations, or build for a language or runtime not covered elsewhere. For production apps, prefer one of the framework guides — they handle the dance below for you.
This guide walks through a full Authorization Code + PKCE flow against Kenni using nothing but `curl` and a browser.
You'll need a Web or SPA application registered in the [developer portal](https://developers.kenni.is) with one redirect URI on the list (we'll use `http://localhost:8080/callback` below).
Replace `` with your team's domain throughout. Your `client_id` is shown on the application's Overview tab — it looks like `@my-app.is/web`.
## Prerequisites
The snippets below assume `bash`, `curl`, [`jq`](https://jqlang.org/), `openssl`, and `xxd`. On macOS, `jq` is not installed by default — `brew install jq`. `openssl` and `xxd` ship with the OS. The JWT-verification recipe at the end uses `openssl` exclusively (no `jq` substitutes for the cryptography).
## Discovery
Every Kenni team exposes an OIDC discovery document. It's the only URL your code needs to hard-code; everything else is derived from it.
```bash
curl -s https://idp.kenni.is//.well-known/openid-configuration | jq . > discovery.json
```
The response lists `authorization_endpoint`, `token_endpoint`, `userinfo_endpoint`, `jwks_uri`, `end_session_endpoint`, supported scopes, and supported response/grant types. Cache it for the rest of the session — re-fetch only when the JWKS rotates.
```bash
AUTH_ENDPOINT=$(jq -r .authorization_endpoint discovery.json)
TOKEN_ENDPOINT=$(jq -r .token_endpoint discovery.json)
USERINFO_ENDPOINT=$(jq -r .userinfo_endpoint discovery.json)
JWKS_URI=$(jq -r .jwks_uri discovery.json)
END_SESSION_ENDPOINT=$(jq -r .end_session_endpoint discovery.json)
```
## Generate PKCE values
PKCE binds the authorization request to the token exchange so an intercepted code is useless on its own. Generate a verifier (random) and a challenge (its SHA-256, base64url-encoded):
```bash
CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=\n' | tr '/+' '_-')
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr -d '=' | tr '/+' '_-')
```
The verifier is a 43-character base64url string drawn from the full RFC 7636 alphabet (`[A-Z][a-z][0-9]-._~`). Don't try to "simplify" the alphabet by stripping `-_` — every character of entropy counts.
`state` and `nonce` are also worth setting — both are echoed back on the callback and used to detect tampering and replay:
```bash
STATE=$(openssl rand -hex 16)
NONCE=$(openssl rand -hex 16)
```
## Build the authorization URL
Open this URL in a browser. Don't `curl` it — the response is a redirect into the Kenni login UI, which the user has to interact with.
```bash
URL="$AUTH_ENDPOINT?client_id=@/web"
URL+="&response_type=code"
URL+="&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback"
URL+="&scope=openid+profile+national_id+offline_access"
URL+="&state=$STATE"
URL+="&nonce=$NONCE"
URL+="&code_challenge=$CODE_CHALLENGE"
URL+="&code_challenge_method=S256"
# macOS: open "$URL" Linux: xdg-open "$URL" Windows: start "$URL"
echo "$URL"
```
`offline_access` is what gets you a refresh token — drop it if you don't need refresh.
## Receive the redirect
The browser redirects to `http://localhost:8080/callback?code=...&state=...` (or `?error=...&error_description=...` if the user declined). You need _something_ listening on that port to display or capture the URL. The simplest portable approach is a tiny static server:
```bash
mkdir -p callback
cat > callback/index.html <<'HTML'
Kenni callback
HTML
python3 -m http.server 8080
```
Open the auth URL, sign in, and copy the query string from the page that loads.
> **Note.** If nothing is listening on 8080 the browser shows "connection refused" — the URL still appears in the address bar, so copying from there works as a fallback.
Pull the values out of the redirect URL:
```bash
URL='http://localhost:8080/callback?code=...&state=...' # paste the URL
QUERY="${URL#*\?}"
get_param() {
printf '%s' "$QUERY" | tr '&' '\n' | awk -F= -v k="$1" '$1==k{print substr($0, length(k)+2); exit}'
}
urldecode() {
local s="${1//+/ }"
printf '%b' "${s//%/\\x}"
}
CODE=$(urldecode "$(get_param code)")
RETURNED_STATE=$(urldecode "$(get_param state)")
ERR=$(urldecode "$(get_param error)")
ERR_DESC=$(urldecode "$(get_param error_description)")
[[ -z "$ERR" ]] || { echo "auth failed: $ERR — $ERR_DESC" >&2; exit 1; }
[[ "$RETURNED_STATE" == "$STATE" ]] || { echo "state mismatch" >&2; exit 1; }
```
The `state` check is the CSRF defence; the `error` check covers the consent-decline path.
## Exchange the code for tokens
Confidential (Web) clients authenticate the token request with their `client_secret`. Public clients (SPA, Native) omit it — PKCE is the proof.
```bash
RESPONSE=$(curl -s -X POST "$TOKEN_ENDPOINT" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=$CODE" \
--data-urlencode "redirect_uri=http://localhost:8080/callback" \
--data-urlencode "client_id=@/web" \
--data-urlencode "client_secret=" \
--data-urlencode "code_verifier=$CODE_VERIFIER")
ACCESS_TOKEN=$(printf '%s' "$RESPONSE" | jq -r .access_token)
ID_TOKEN=$(printf '%s' "$RESPONSE" | jq -r .id_token)
REFRESH_TOKEN=$(printf '%s' "$RESPONSE" | jq -r .refresh_token)
```
`--data-urlencode` correctly escapes client IDs that contain `@` or `/`. `jq -r` strips the surrounding quotes; `printf '%s'` strips `jq`'s trailing newline so the values can be reused without contaminating the next request.
> **Warning.** **Do not trust the `id_token` payload before verifying its signature.** A naive `cut -d. -f2 | base64 -d | jq .` decode of an `id_token` tells you what someone claimed — you don't know it was Kenni. See [Verifying a JWT in shell](#verifying-a-jwt-in-shell) below — apply the same recipe to `$ID_TOKEN`, then check that `nonce` in the payload equals the `$NONCE` you sent.
## Call the user-info endpoint
The access token works against `/oidc/me`. The response contains the same identity claims released by the scopes the user consented to.
```bash
curl -s "$USERINFO_ENDPOINT" -H "Authorization: Bearer $ACCESS_TOKEN" | jq .
```
If you also requested a custom API scope, the access token is itself a JWT carrying many of the same claims — reading it directly skips a network round-trip when you only need to confirm identity. **Verify the signature first** (next section). If you didn't request a custom API scope, the access token is an opaque reference token — see [API scopes](https://developers.kenni.is/docs/api-scopes) for the difference.
## Verifying a JWT in shell
The whole point of a "no framework" guide is to make the protocol concrete, and Kenni's tokens are signed JWTs. The recipe below verifies an RS256 JWT (id_token or access token) using only `openssl` and `jq`.
```bash
verify_jwt_rs256() {
local TOKEN="$1" JWKS="$2"
local HEADER_B64="${TOKEN%%.*}"
local REST="${TOKEN#*.}"
local PAYLOAD_B64="${REST%%.*}"
local SIG_B64="${REST#*.}"
local SIGNED_INPUT="$HEADER_B64.$PAYLOAD_B64"
# base64url -> base64 with padding, then decode
b64url_decode() {
local s="$1" pad
s="${s//-/+}"; s="${s//_/\/}"
pad=$(( (4 - ${#s} % 4) % 4 ))
printf '%s%*s' "$s" "$pad" '' | tr ' ' '=' | openssl base64 -d -A
}
# 1. Read kid + alg from the JOSE header
local HEADER ALG KID
HEADER=$(b64url_decode "$HEADER_B64")
ALG=$(printf '%s' "$HEADER" | jq -r .alg)
KID=$(printf '%s' "$HEADER" | jq -r .kid)
[[ "$ALG" == "RS256" ]] || { echo "alg=$ALG, expected RS256" >&2; return 1; }
# 2. Find the matching JWK by kid, pull its n and e
local JWK N_B64URL E_B64URL
JWK=$(printf '%s' "$JWKS" | jq -c --arg kid "$KID" '.keys[] | select(.kid==$kid)')
[[ -n "$JWK" ]] || { echo "no JWK for kid=$KID" >&2; return 1; }
N_B64URL=$(printf '%s' "$JWK" | jq -r .n)
E_B64URL=$(printf '%s' "$JWK" | jq -r .e)
# 3. Convert n,e -> PEM. ASN.1 INTEGER is signed; if the RSA modulus's
# high bit is set (which it is for any real RSA key), prepend 00.
local TMP; TMP=$(mktemp -d)
b64url_decode "$N_B64URL" > "$TMP/n.bin"
b64url_decode "$E_B64URL" > "$TMP/e.bin"
prepend_zero_if_signed() {
local f="$1" first
first=$(xxd -p -l 1 "$f")
if [[ "$((16#$first))" -ge 128 ]]; then
{ printf '\x00'; cat "$f"; } > "$f.tmp" && mv "$f.tmp" "$f"
fi
}
prepend_zero_if_signed "$TMP/n.bin"
prepend_zero_if_signed "$TMP/e.bin"
# Build an ASN.1 RSAPublicKey SEQUENCE { n INTEGER, e INTEGER } via openssl asn1parse -genconf
local N_HEX E_HEX
N_HEX=$(xxd -p -c 9999 "$TMP/n.bin")
E_HEX=$(xxd -p -c 9999 "$TMP/e.bin")
cat > "$TMP/asn1.cnf" </dev/null
# 4. Verify signature
b64url_decode "$SIG_B64" > "$TMP/sig.bin"
if ! printf '%s' "$SIGNED_INPUT" \
| openssl dgst -sha256 -verify "$TMP/pub.pem" -signature "$TMP/sig.bin" >/dev/null 2>&1; then
echo "signature INVALID" >&2
rm -rf "$TMP"
return 1
fi
# 5. Emit the (now-trusted) payload for further claim checks
b64url_decode "$PAYLOAD_B64"
rm -rf "$TMP"
}
```
Use it:
```bash
JWKS=$(curl -s "$JWKS_URI")
PAYLOAD=$(verify_jwt_rs256 "$ID_TOKEN" "$JWKS") || exit 1
# Claim checks
NOW=$(date +%s)
EXP=$(printf '%s' "$PAYLOAD" | jq -r .exp)
ISS=$(printf '%s' "$PAYLOAD" | jq -r .iss)
AUD=$(printf '%s' "$PAYLOAD" | jq -r .aud)
TOK_NONCE=$(printf '%s' "$PAYLOAD" | jq -r .nonce)
[[ "$NOW" -lt "$EXP" ]] || { echo "token expired" >&2; exit 1; }
[[ "$ISS" == "https://idp.kenni.is/" ]] || { echo "wrong issuer: $ISS" >&2; exit 1; }
[[ "$AUD" == "@/web" ]] || { echo "wrong audience: $AUD" >&2; exit 1; }
[[ "$TOK_NONCE" == "$NONCE" ]] || { echo "nonce mismatch" >&2; exit 1; } # id_token only
printf '%s' "$PAYLOAD" | jq .
```
The same function verifies API access tokens (omit the `nonce` check, validate the API scope claim instead).
> **Note.** The leading `\x00` byte on the modulus is an ASN.1 quirk — `INTEGER` is signed two's-complement, so any value with the high bit set must be sign-extended to stay positive. Forget this and `openssl rsa` rejects the DER as "non-positive integer."
## Refresh
When the access token expires, swap the refresh token for a fresh pair:
```bash
curl -s -X POST "$TOKEN_ENDPOINT" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=refresh_token" \
--data-urlencode "refresh_token=$REFRESH_TOKEN" \
--data-urlencode "client_id=@/web" \
--data-urlencode "client_secret=" | jq .
```
## Client credentials (M2M)
For server-to-server calls there's no user, no PKCE, no browser — just a confidential client exchanging its credentials directly for an access token. Use the **Machine** application type in the developer portal.
```bash
# RFC 6749 §2.3.1 says client_id and client_secret in a Basic header must each be
# form-urlencoded *before* the Base64. Kenni client IDs contain '@' and '/', so the
# naive `client_id:secret` Basic header technically violates the spec.
ID_ENC=$(jq -rn --arg v "$CLIENT_ID" '$v|@uri')
SECRET_ENC=$(jq -rn --arg v "$CLIENT_SECRET" '$v|@uri')
BASIC=$(printf '%s:%s' "$ID_ENC" "$SECRET_ENC" | openssl base64 -A)
curl -s -X POST "$TOKEN_ENDPOINT" \
-H "Authorization: Basic $BASIC" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "scope=$M2M_SCOPE" | jq .
```
The response contains an `access_token` (a JWT, with the M2M scope as `scope`) and `expires_in`. No `refresh_token` and no `id_token` — there's no user to identify. Verify the JWT exactly the same way as the user-flow access token.
## Sign out
Clearing your local session is not enough. The user is still signed in to Kenni — the next time they hit your "Sign in" button, Kenni recognizes the existing session and logs them straight back in with no visible interaction. Users perceive this as "logout doesn't work."
The fix is **RP-initiated logout**: redirect the browser to Kenni's `end_session_endpoint` so Kenni clears its own session, then comes back to your app:
```bash
LOGOUT_URL="$END_SESSION_ENDPOINT?client_id=@/web"
LOGOUT_URL+="&id_token_hint=$ID_TOKEN"
LOGOUT_URL+="&post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Floggedout"
# macOS: open "$LOGOUT_URL"
echo "$LOGOUT_URL"
```
`id_token_hint` is the ID token you received at sign-in. `post_logout_redirect_uri` must be on the application's **Post logout redirect URIs** list in the [developer portal](https://developers.kenni.is) — register a separate path from the sign-in redirect URI so your app can tell sign-in completion apart from sign-out completion.
**When to clear the local session**: either right before redirecting to `end_session_endpoint`, or in your `post_logout_redirect_uri` handler. Both work; pick one and be consistent. Most apps clear it before the redirect so users see a logged-out UI even if the round-trip fails.
That's the full loop. Every framework guide that follows is essentially a way to delegate the work above to a library.
---
# Next.js / better-auth
[better-auth](https://www.better-auth.com) is the modern auth framework for the Next.js (and broader TypeScript) ecosystem. Its `genericOAuth` plugin lets you register Kenni as an OIDC provider with discovery, PKCE, and token storage handled for you.
This guide is tested against Next.js 16 and `better-auth` 1.x.
## Install
```bash
npm install better-auth jose
```
`better-auth` runs database-less by default — session and account state live in signed cookies. If you need persistence (e.g. to invalidate sessions across deploys), pass a `database:` option. For most "let users sign in with Kenni" integrations, you don't need one.
## Auth instance
Create the auth instance once and import it into your routes and middleware.
> **Warning.** Two non-obvious tweaks are required to get sign-in working against Kenni: a custom `getUserInfo` (so the `name` claim isn't dropped) and a `mapProfileToUser` (so better-auth's required-`email`/`name` checks pass). Both are explained below.
```ts
// lib/auth.ts
import { betterAuth } from 'better-auth';
import { genericOAuth } from 'better-auth/plugins';
import { nextCookies } from 'better-auth/next-js';
import { decodeJwt } from 'jose';
const issuer = `https://idp.kenni.is/`;
export const auth = betterAuth({
plugins: [
genericOAuth({
config: [
{
providerId: 'kenni',
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
clientId: process.env.KENNI_CLIENT_ID!,
clientSecret: process.env.KENNI_CLIENT_SECRET!,
// `offline_access` requests a refresh token so better-auth can
// refresh the access token without bouncing the user back to Kenni.
scopes: ['openid', 'profile', 'national_id', 'offline_access'],
pkce: true,
// Better-auth's default user-info parser only trusts id_token claims
// when both `sub` AND `email` are present, then falls through to the
// userinfo endpoint. Kenni puts `name` in the id_token but doesn't
// include `email` (it's consent-gated), so the gate fails and the
// userinfo response — which doesn't carry `name` for most clients —
// wins. The result is a silently-empty `user.name`. Decoding the
// id_token directly fixes it.
getUserInfo: async (tokens) => {
if (!tokens.idToken) return null;
const claims = decodeJwt(tokens.idToken) as Record;
return {
...claims,
id: String(claims.sub ?? ''),
email: claims.email as string | undefined,
emailVerified: Boolean(claims.email_verified),
name: claims.name as string | undefined,
image: claims.picture as string | undefined,
};
},
// Better-auth requires `email` and `name` on every user. Kenni
// guarantees neither: `email` is consent-gated, and `name` may be
// empty for some test accounts and delegation flows. Without this
// map, sign-in throws `email_is_missing` / `name_is_missing`.
mapProfileToUser: (profile) => ({
email: profile.email ?? `${profile.sub}@no-reply.users.kenni.is`,
emailVerified: Boolean(profile.email_verified),
name:
profile.name ||
[profile.given_name, profile.family_name].filter(Boolean).join(' ') ||
'Kenni user',
}),
},
],
}),
// `nextCookies` must be the last plugin. Without it, sign-in via a server
// action silently fails to set the session cookie.
nextCookies(),
],
});
```
## Mount the handler
```ts
// app/api/auth/[...all]/route.ts
import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';
export const { GET, POST } = toNextJsHandler(auth);
```
## Auth client
`authClient.signIn.oauth2(...)` is only available when you register the `genericOAuthClient()` plugin on the client.
```ts
// lib/auth-client.ts
import { createAuthClient } from 'better-auth/react';
import { genericOAuthClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({
plugins: [genericOAuthClient()],
});
```
## Environment
```
KENNI_CLIENT_ID=@/web
KENNI_CLIENT_SECRET=
NEXT_PUBLIC_KENNI_CLIENT_ID=@/web
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=http://localhost:3000
```
`BETTER_AUTH_URL` is the public origin better-auth derives the `redirect_uri` from. If it's wrong (e.g. `localhost:3000` set but the app is actually running on `localhost:3001`), sign-in fails with a `redirect_uri mismatch` and the cause is not obvious.
`NEXT_PUBLIC_KENNI_CLIENT_ID` is the same value, exposed to the browser for the sign-out snippet below. Next.js only inlines vars prefixed with `NEXT_PUBLIC_` into client bundles.
Register `http://localhost:3000/api/auth/oauth2/callback/kenni` as a redirect URI on your application in the [developer portal](https://developers.kenni.is). The path comes from the `genericOAuth` plugin mounting `/oauth2/callback/:providerId` under the `/api/auth/[...all]` handler. Adjust the host for production.
## Triggering sign-in
```tsx
'use client';
import { authClient } from '@/lib/auth-client';
export const SignInButton = () => (
);
```
`callbackURL` controls where the user lands after sign-in completes. Skip it and better-auth picks its own default, which is rarely what you want.
## Public clients
For SPA or Native applications, drop `clientSecret` and set `pkce: true` (it already is in the snippet above). PKCE is recommended for confidential clients too as defence in depth; for public clients PKCE alone replaces the missing `client_secret`.
## Reading the access and id tokens
Better-auth keeps the Kenni-issued tokens server-side. Retrieve them with `auth.api.getAccessToken`, which auto-refreshes the access token if it has expired:
```ts
// app/lib/kenni-tokens.ts
import { headers } from 'next/headers';
import { auth } from '@/lib/auth';
export async function getKenniTokens() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
return auth.api.getAccessToken({
body: { providerId: 'kenni', userId: session.user.id },
});
// -> { accessToken, accessTokenExpiresAt, scopes, idToken }
}
```
Use `accessToken` for outbound API calls and `idToken` for RP-initiated logout (below). See [API scopes](https://developers.kenni.is/docs/api-scopes) for what kind of access token Kenni issues based on your scope configuration.
## Sign out
`authClient.signOut()` clears the better-auth session but leaves the Kenni session untouched — users get silently re-authenticated on the next sign-in. After signing out locally, redirect the browser to Kenni's `end_session_endpoint` (RP-initiated logout) so Kenni also clears its session.
Clearing the local session _before_ the redirect means a failed RP-initiated logout still leaves the user signed out locally. Most apps prefer that over the alternative.
```tsx
'use client';
import { authClient } from '@/lib/auth-client';
const handleSignOut = async (idToken: string) => {
// Discover the end-session endpoint instead of hard-coding it.
const issuer = `https://idp.kenni.is/`;
const discovery = await fetch(`${issuer}/.well-known/openid-configuration`).then((r) => r.json());
await authClient.signOut();
const url = new URL(discovery.end_session_endpoint);
url.searchParams.set('id_token_hint', idToken);
url.searchParams.set('post_logout_redirect_uri', `${window.location.origin}/`);
url.searchParams.set('client_id', process.env.NEXT_PUBLIC_KENNI_CLIENT_ID!);
window.location.href = url.toString();
};
```
Pass `idToken` in from a server component that called `getKenniTokens()` above. Register your post-logout URL on the application's **Post logout redirect URIs** list in the developer portal. See the [No framework (curl) guide](https://developers.kenni.is/docs/guides/curl#sign-out) for the full reasoning.
## Per-call `prompt` (delegation, switch user)
`authClient.signIn.oauth2` doesn't accept a `prompt` parameter — better-auth reads it from static provider config only. To vary `prompt` per-click (e.g. a "Switch delegation" button), pass `authorizationUrlParams` as a function that reads from `additionalData`:
```ts
genericOAuth({
config: [
{
// ... discoveryUrl, clientId, etc.
authorizationUrlParams: (ctx) => {
const prompt = (ctx.body as { additionalData?: { prompt?: string } } | undefined)?.additionalData
?.prompt;
return prompt ? { prompt } : ({} as Record);
},
},
],
});
```
Then on the client:
```ts
authClient.signIn.oauth2({
providerId: 'kenni',
additionalData: { prompt: 'delegation' },
});
```
One provider, one redirect URI, no account-linking errors.
## Client credentials (M2M)
Better-auth doesn't help with the client-credentials grant — it's not user-bound. POST to the token endpoint directly:
```ts
const res = await fetch(`${issuer}/oidc/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.KENNI_CLIENT_ID!,
client_secret: process.env.KENNI_CLIENT_SECRET!,
scope: 'your_api:read',
}),
});
const { access_token } = await res.json();
```
You'll need a separate **Machine** application registered in the developer portal, with the relevant API scopes authorized. See the [No framework (curl) guide](https://developers.kenni.is/docs/guides/curl) for the full protocol view.
## Verifying access tokens
If your API runs as a separate service, validate Kenni-issued JWT access tokens locally against the JWKS from discovery — don't introspect on every request. See [API scopes](https://developers.kenni.is/docs/api-scopes) for when access tokens are JWTs vs. opaque references.
---
# React (SPA)
For a single-page React app, [`react-oidc-context`](https://github.com/authts/react-oidc-context) wraps the well-tested `oidc-client-ts` library and exposes a hook-based API. PKCE, silent refresh (with caveats — see below), and session storage are handled for you.
Use the **Web (SPA)** application type in the [developer portal](https://developers.kenni.is) — public client, no secret.
## Install
```bash
npm install react-oidc-context oidc-client-ts
```
## Provider
Wrap your app once at the root.
```tsx
// main.tsx
import { AuthProvider } from 'react-oidc-context';
import { WebStorageStateStore } from 'oidc-client-ts';
const oidcConfig = {
authority: import.meta.env.VITE_KENNI_AUTHORITY, // https://idp.kenni.is/
client_id: import.meta.env.VITE_KENNI_CLIENT_ID, // @/spa
redirect_uri: `${window.location.origin}/callback`,
post_logout_redirect_uri: window.location.origin,
response_type: 'code',
scope: 'openid profile national_id offline_access',
// Default is sessionStorage (clears on tab close). localStorage survives
// tab close and devtools refresh — pick whichever fits your app's session
// semantics. Both are fine for public clients.
userStore: new WebStorageStateStore({ store: window.localStorage }),
onSigninCallback: () => {
window.history.replaceState({}, document.title, window.location.pathname);
},
};
createRoot(document.getElementById('root')!).render(
);
```
This is a public client, so no `client_secret` — PKCE is the proof of possession, and `oidc-client-ts` enables it by default.
Vite only exposes env vars prefixed with `VITE_` to the client bundle. If you'd rather use unprefixed names, set `envPrefix: 'KENNI_'` in `vite.config.ts`. See [Vite's env-and-mode docs](https://vitejs.dev/guide/env-and-mode).
Register `http://localhost:5173/callback` (or whatever your dev origin is) as a redirect URI on your application. The library handles the code exchange when the user lands on `/callback`.
> **Warning.** **Pin your dev port.** Vite falls back to `5174`, `5175`, … if `5173` is busy, which silently shifts the runtime `redirect_uri` and produces a "redirect_uri mismatch" with no obvious cause. Either set `strictPort: true` in `vite.config.ts` (and register the one port), or read `redirect_uri` from an env var so the registered URI and runtime URI can't drift.
If you're using a router (React Router, TanStack Router, etc.), you don't need a `/callback` route component — the provider's `onSigninCallback` rewrites the URL via `history.replaceState` before any route renders. If your router is strict about unknown paths, add a placeholder route at `/callback` so it doesn't 404 during the brief code-exchange window.
## Using auth state
```tsx
import { useAuth } from 'react-oidc-context';
export const App = () => {
const auth = useAuth();
if (auth.isLoading) return
Loading…
;
if (auth.error) return
Error: {auth.error.message}
;
if (!auth.isAuthenticated) {
return ;
}
return (
<>
Hello, {auth.user?.profile.name}
>
);
};
```
`auth.user?.access_token` is the access token you'd send to your APIs.
> **Warning.** **Silent refresh requires explicit setup.** `oidc-client-ts` only renews the access token when one of these is configured:
>
> - `offline_access` is in `scope` AND Kenni issued a refresh token (the default config above does this — the refresh token sits in `localStorage`), or
> - `silent_redirect_uri` is set AND `automaticSilentRenew: true` enables the iframe-based silent-renew flow.
>
> If you drop `offline_access` (e.g. because storing a refresh token in `localStorage` is a trade-off you don't want) without configuring the iframe flow, the access token will expire mid-session and your API calls will start failing. Pick one of the two paths.
## Calling your API
```ts
const res = await fetch('https://api.example.com/orders', {
headers: { Authorization: `Bearer ${auth.user?.access_token}` },
});
```
If you authorized at least one custom API scope on this application, the access token is a JWT your API can validate locally. Otherwise it's an opaque token only Kenni can introspect — see [API scopes](https://developers.kenni.is/docs/api-scopes).
## Sign out
`auth.signoutRedirect()` redirects to Kenni's `end_session_endpoint` with `id_token_hint` and `post_logout_redirect_uri`, lets Kenni clear its session, and comes back to your `post_logout_redirect_uri`. The local user state is cleared automatically.
> **Warning.** **Pass `client_id` explicitly.** `oidc-client-ts` does not include `client_id` in the end-session URL by default, but Kenni's end-session endpoint requires it. Without it the user sees an error instead of the post-logout redirect.
```ts
auth.signoutRedirect({
extraQueryParams: { client_id: oidcConfig.client_id },
});
```
If you only call `auth.removeUser()` you'll clear the local session but the Kenni session stays live, and the next sign-in will silently re-authenticate. Always prefer `signoutRedirect()`. See the [No framework (curl) guide](https://developers.kenni.is/docs/guides/curl#sign-out) for the full reasoning.
Register your post-logout URL on the application's **Post logout redirect URIs** list in the [developer portal](https://developers.kenni.is). For `react-oidc-context` it defaults to `post_logout_redirect_uri` from your config (we set it to `window.location.origin` in the provider above).
## Verifying access tokens
If your API runs as a separate service, validate Kenni-issued JWT access tokens locally against the JWKS from discovery rather than introspecting on every request. See [API scopes](https://developers.kenni.is/docs/api-scopes) for the difference between JWT and opaque access tokens, and the [No framework (curl) guide](https://developers.kenni.is/docs/guides/curl) for the protocol view.
---
# Expo (React Native)
[`expo-auth-session`](https://docs.expo.dev/versions/latest/sdk/auth-session/) is the standard OAuth/OIDC client for Expo apps. It handles PKCE, deep-link redirects, and the in-app browser session.
Use the **Native** application type in the [developer portal](https://developers.kenni.is) — public client, no secret. Mobile apps cannot keep secrets safely.
> **Warning.** **This requires a development build (`expo run:ios` / `expo run:android`).** The AuthSession proxy was removed in SDK 48; Expo Go cannot route custom-scheme redirects back into your app, so the sign-in flow dead-ends in the browser. Don't waste an afternoon trying to make Expo Go work.
## Install
```bash
npx expo install expo-auth-session expo-crypto expo-web-browser expo-secure-store expo-constants
```
## Configuration
Drive everything from environment variables so the developer portal registration and the runtime values can't drift. Expo CLI auto-loads `.env` since SDK 49.
```bash
# .env
KENNI_ISSUER=https://idp.kenni.is/
KENNI_CLIENT_ID=@/native
KENNI_REDIRECT_URI=is.kenni.example://redirect
KENNI_POST_LOGOUT_REDIRECT_URI=is.kenni.example://post-logout
```
Use a reverse-DNS scheme (`is.kenni.example`) — `myapp` collides with every other tutorial app on the device. Register **both** `KENNI_REDIRECT_URI` (Redirect URIs) and `KENNI_POST_LOGOUT_REDIRECT_URI` (Post logout redirect URIs) in the developer portal — they're separate lists with separate lifecycles.
Expo's static `app.json` can't read env vars, so use a dynamic `app.config.ts`. Parse the URL scheme out of the redirect URI so there's a single source of truth — drift between `expo.scheme` and the redirect URI causes silent sign-in failures.
```ts
// app.config.ts
import type { ExpoConfig } from 'expo/config';
const required = (key: string): string => {
const value = process.env[key];
if (!value) throw new Error(`Missing env var: ${key}`);
return value;
};
const redirectUri = required('KENNI_REDIRECT_URI');
const scheme = new URL(redirectUri).protocol.replace(':', '');
const config: ExpoConfig = {
name: 'kenni-expo-example',
slug: 'kenni-expo-example',
scheme,
ios: { bundleIdentifier: 'is.kenni.example' },
android: { package: 'is.kenni.example' },
extra: {
issuer: required('KENNI_ISSUER'),
clientId: required('KENNI_CLIENT_ID'),
redirectUri,
postLogoutRedirectUri: required('KENNI_POST_LOGOUT_REDIRECT_URI'),
},
};
export default config;
```
Read the values at runtime through `expo-constants`:
```ts
// src/config.ts
import Constants from 'expo-constants';
const extra = Constants.expoConfig?.extra ?? {};
export const config = {
issuer: extra.issuer as string,
clientId: extra.clientId as string,
redirectUri: extra.redirectUri as string,
postLogoutRedirectUri: extra.postLogoutRedirectUri as string,
};
```
## Sign-in flow
Run the code exchange from the resolved value of `promptAsync()`, **not** from a render-time conditional. A render-time `if (response?.type === 'success') { exchangeCodeAsync(...) }` re-fires on every subsequent render and the second exchange returns `invalid_grant`.
```tsx
import { useEffect, useState } from 'react';
import { Button, Text, View } from 'react-native';
import * as AuthSession from 'expo-auth-session';
import * as WebBrowser from 'expo-web-browser';
import * as SecureStore from 'expo-secure-store';
import { config } from './config';
WebBrowser.maybeCompleteAuthSession();
const TOKENS_KEY = 'kenni.tokens.v1';
type Tokens = {
accessToken: string;
idToken: string;
refreshToken?: string;
expiresAt?: number;
};
const decodeIdToken = (idToken: string): Record => {
const [, payload] = idToken.split('.');
const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4);
const base64 = padded.replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(globalThis.atob(base64));
};
export const SignIn = () => {
const discovery = AuthSession.useAutoDiscovery(config.issuer);
const [tokens, setTokens] = useState(null);
const [ready, setReady] = useState(false);
// Round-trip stored tokens on launch so a relaunch doesn't bounce the user
// back through Kenni when their session is still valid.
useEffect(() => {
SecureStore.getItemAsync(TOKENS_KEY)
.then((value) => value && setTokens(JSON.parse(value)))
.finally(() => setReady(true));
}, []);
const [request, , promptAsync] = AuthSession.useAuthRequest(
{
clientId: config.clientId,
redirectUri: config.redirectUri,
scopes: ['openid', 'profile', 'national_id', 'offline_access'],
usePKCE: true,
},
discovery
);
const signIn = async () => {
if (!discovery || !request) return;
const result = await promptAsync();
if (result.type !== 'success') return;
if (!request.codeVerifier) {
throw new Error('PKCE code_verifier missing — usePKCE must be true');
}
const exchange = await AuthSession.exchangeCodeAsync(
{
clientId: config.clientId,
code: result.params.code,
redirectUri: config.redirectUri,
extraParams: { code_verifier: request.codeVerifier },
},
discovery
);
const next: Tokens = {
accessToken: exchange.accessToken,
idToken: exchange.idToken!,
refreshToken: exchange.refreshToken,
expiresAt: exchange.expiresIn ? Date.now() + exchange.expiresIn * 1000 : undefined,
};
await SecureStore.setItemAsync(TOKENS_KEY, JSON.stringify(next));
setTokens(next);
};
if (!ready) return null;
const profile = tokens ? decodeIdToken(tokens.idToken) : null;
return (
{profile ? (
Welcome {String(profile.name ?? profile.sub)}
) : (
)}
);
};
```
`maybeCompleteAuthSession()` must run at module scope — it lets `expo-auth-session` resolve when the OS bounces the user back into the app. Inside a component it gets called too late.
`useAutoDiscovery` reads `/.well-known/openid-configuration` once and caches the endpoints. `usePKCE: true` is the default for public clients but it's worth being explicit, and the runtime check on `request.codeVerifier` keeps the type-narrowing honest.
## Refresh
```ts
const refreshed = await AuthSession.refreshAsync(
{
clientId: config.clientId,
refreshToken,
},
discovery
);
```
Persist `refreshed.accessToken` and `refreshed.refreshToken` back into `SecureStore`.
## Sign out
> **Warning.** **RP-initiated logout has rough UX on iOS that Apple doesn't let you fix.** `WebBrowser.openAuthSessionAsync` opens an `ASWebAuthenticationSession` which shows a hardcoded _"<App> wants to use 'kenni.is' to sign in"_ consent prompt — even on a sign-out flow. The wording is not configurable. Opening Safari directly (`Linking.openURL`) trades it for the OS-level _"Open this page in '<App>'?"_ confirmation when Kenni redirects back to the custom scheme. The only clean fix is [Universal Links](https://developer.apple.com/documentation/xcode/supporting-associated-domains): a real HTTPS domain you control + an `apple-app-site-association` file + the `applinks:` entitlement.
>
> **What real iOS apps ship**: skip RP-initiated logout entirely on iOS. Clear local tokens and force `prompt=login` on the next sign-in. Android Chrome Custom Tabs have neither prompt and the full flow is clean.
```ts
import { Platform } from 'react-native';
import * as AuthSession from 'expo-auth-session';
import * as WebBrowser from 'expo-web-browser';
import * as SecureStore from 'expo-secure-store';
const signOut = async (discovery: AuthSession.DiscoveryDocument, tokens: Tokens) => {
// Clear local state first — a failed RP-initiated logout still leaves you signed out locally.
await SecureStore.deleteItemAsync(TOKENS_KEY);
if (tokens.refreshToken) {
await AuthSession.revokeAsync({ token: tokens.refreshToken, clientId: config.clientId }, discovery).catch(
() => {}
);
}
if (Platform.OS === 'ios') {
// Skip RP-initiated logout. Force `prompt=login` on the next authorize request instead.
return;
}
if (!discovery.endSessionEndpoint) return;
const url = new URL(discovery.endSessionEndpoint);
url.searchParams.set('id_token_hint', tokens.idToken);
url.searchParams.set('post_logout_redirect_uri', config.postLogoutRedirectUri);
url.searchParams.set('client_id', config.clientId);
await WebBrowser.openAuthSessionAsync(url.toString(), config.postLogoutRedirectUri);
};
```
The second argument to `openAuthSessionAsync` is the URI the WebBrowser detects to close itself — it must equal `post_logout_redirect_uri`, and it must be a different value from your sign-in `redirectUri` so the OS deep-link layer can tell sign-in completion apart from sign-out completion.
Then on the next sign-in, force re-authentication on iOS so the local sign-out doesn't feel fake:
```ts
const [request, , promptAsync] = AuthSession.useAuthRequest(
{
clientId: config.clientId,
redirectUri: config.redirectUri,
scopes: ['openid', 'profile', 'national_id', 'offline_access'],
usePKCE: true,
extraParams: forceReauth ? { prompt: 'login' } : undefined,
},
discovery
);
```
Set `forceReauth = true` after `signOut`, clear it after a successful sign-in.
`revokeAsync` invalidates the refresh token server-side. Skipping it leaves a valid refresh token in `SecureStore` after sign-out — if cleared local state is your only defence and the device is later compromised, the token still works.
See the [No framework (curl) guide](https://developers.kenni.is/docs/guides/curl#sign-out) for the protocol-level reasoning.
---
# Flutter
[`flutter_appauth`](https://pub.dev/packages/flutter_appauth) wraps Google's AppAuth SDKs — the canonical OAuth client for native iOS and Android. PKCE, browser tabs, deep-link handling, and silent refresh are all handled by the underlying native code.
Use the **Native** application type in the [developer portal](https://developers.kenni.is) — public client, no secret. Mobile apps cannot keep secrets safely.
## Install
```yaml
# pubspec.yaml
dependencies:
flutter_appauth: ^12.0.0
flutter_secure_storage: ^10.1.0
```
These are the current major versions on pub.dev as of writing — `flutter_appauth` 12 needs Flutter 3.38+ / Dart 3.10+, iOS 13 minimum, and Android `minSdkVersion 24`. The platform bumps are non-optional; see below.
## Platform configuration
### iOS
`flutter_appauth` 12 and `flutter_secure_storage` 10 both require iOS 13. The Podfile shipped by `flutter create` targets iOS 12, which builds but fails at link time with cryptic "Unsupported deployment target" errors.
```ruby
# ios/Podfile
platform :ios, '13.0'
```
### Android
```gradle
// android/app/build.gradle
android {
defaultConfig {
minSdkVersion 24
manifestPlaceholders = [appAuthRedirectScheme: 'is.kenni.example']
}
}
```
The AppAuth Android plugin reads `manifestPlaceholders[appAuthRedirectScheme]` at manifest-merge time — it's how Android registers your custom URL scheme. Use a reverse-DNS form (`is.kenni.example`) — `myapp` collides with every other tutorial app on the device.
### iOS URL scheme
```xml
CFBundleURLTypesCFBundleURLSchemesis.kenni.example
```
Register **two** URIs in the developer portal — `is.kenni.example://callback` as a Redirect URI and `is.kenni.example://post-logout` as a Post logout redirect URI. They're separate lists with separate lifecycles.
## Configuration
The idiomatic Flutter pattern is `--dart-define-from-file` (stable since Flutter 3.7), with values surfaced via `String.fromEnvironment`. No runtime `.env` parser, no extra dependency.
```json
// dart_defines.json (gitignored)
{
"KENNI_ISSUER": "https://idp.kenni.is/",
"KENNI_CLIENT_ID": "@/native",
"KENNI_REDIRECT_URI": "is.kenni.example://callback",
"KENNI_POST_LOGOUT_REDIRECT_URI": "is.kenni.example://post-logout"
}
```
```dart
// lib/src/config.dart
class KenniConfig {
static const issuer = String.fromEnvironment('KENNI_ISSUER');
static const clientId = String.fromEnvironment('KENNI_CLIENT_ID');
static const redirectUrl = String.fromEnvironment('KENNI_REDIRECT_URI');
static const postLogoutRedirectUrl =
String.fromEnvironment('KENNI_POST_LOGOUT_REDIRECT_URI');
static String get discoveryUrl => '$issuer/.well-known/openid-configuration';
static void assertConfigured() {
if (issuer.isEmpty || clientId.isEmpty || redirectUrl.isEmpty || postLogoutRedirectUrl.isEmpty) {
throw StateError(
'Missing KENNI_* dart-defines. Run with --dart-define-from-file=dart_defines.json',
);
}
}
}
```
Run with:
```bash
flutter run --dart-define-from-file=dart_defines.json
```
## Sign-in flow
```dart
import 'dart:convert';
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'src/config.dart';
final FlutterAppAuth _appAuth = const FlutterAppAuth();
final FlutterSecureStorage _storage = const FlutterSecureStorage();
Future signIn({bool forceReauth = false}) async {
try {
return await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
KenniConfig.clientId,
KenniConfig.redirectUrl,
discoveryUrl: KenniConfig.discoveryUrl,
scopes: const ['openid', 'profile', 'national_id', 'offline_access'],
promptValues: forceReauth ? const ['login'] : null,
),
);
} on FlutterAppAuthUserCancelledException {
// User dismissed the in-app browser — not an error to surface.
return null;
}
}
```
`const FlutterAppAuth()` is the right constructor — current versions are `const`-constructable, and the `prefer_const_constructors` lint (default-on with `flutter_lints`) flags the non-const form.
`authorizeAndExchangeCode` performs the authorization request, code exchange, and PKCE in a single call. The returned response contains `accessToken`, `idToken`, `refreshToken`, and `accessTokenExpirationDateTime`. `FlutterAppAuthUserCancelledException` fires when the user dismisses the in-app browser — catch it, don't let it crash to Crashlytics/Sentry as if it were a bug.
## Storing tokens
Persist tokens on sign-in and round-trip them on launch — without this, every relaunch bounces the user back through Kenni even when their Kenni session is still valid.
```dart
const _tokensKey = 'kenni.tokens.v1';
Future persist(AuthorizationTokenResponse res) async {
await _storage.write(
key: _tokensKey,
value: jsonEncode({
'accessToken': res.accessToken,
'idToken': res.idToken,
'refreshToken': res.refreshToken,
'expiresAt': res.accessTokenExpirationDateTime?.toIso8601String(),
}),
);
}
Future