Python

Authlib is the de facto OIDC client library for Python — works with Flask, FastAPI, Django, and standalone scripts. The example below uses Flask, but the moving parts are the same in any framework.

For Django specifically, mozilla-django-oidc is more idiomatic and integrates with django.contrib.auth directly.

Install

python3 -m venv .venv
.venv/bin/pip install authlib flask requests

Use .venv/bin/python app.py (and .venv/bin/pip ...) rather than source .venv/bin/activate + bare python — that way shell aliases or pyenv shims can't silently route to a different interpreter.

On macOS, port 5000 is taken by AirPlay Receiver (System Settings → General → AirDrop & Handoff). The rest of this guide assumes port 5001 to avoid the collision. Either turn AirPlay Receiver off, or run with flask run --port 5001 and register http://localhost:5001/callback.

Register the provider

import os
from authlib.integrations.flask_client import OAuth
from flask import Flask, redirect, session, url_for

app = Flask(__name__)
# 32+ random chars, stable across restarts (otherwise existing sessions get invalidated).
app.secret_key = os.environ["FLASK_SECRET"]

oauth = OAuth(app)
oauth.register(
    name="kenni",
    client_id=os.environ["KENNI_CLIENT_ID"],
    client_secret=os.environ["KENNI_CLIENT_SECRET"],
    server_metadata_url="https://idp.kenni.is/<team-domain>/.well-known/openid-configuration",
    client_kwargs={
        "scope": "openid profile national_id offline_access",
        "code_challenge_method": "S256",
    },
)

server_metadata_url triggers discovery — Authlib reads the issuer's well-known document and configures every endpoint. code_challenge_method=S256 enables PKCE for both public and confidential clients (defence in depth).

Login route

@app.route("/login")
def login():
    return oauth.kenni.authorize_redirect(url_for("callback", _external=True))

_external=True is required — Authlib needs an absolute redirect URI.

Callback route

@app.route("/callback")
def callback():
    token = oauth.kenni.authorize_access_token()
    session["userinfo"] = dict(token.get("userinfo") or {})
    session["id_token"] = token["id_token"]
    session["access_token"] = token["access_token"]
    session["refresh_token"] = token.get("refresh_token")
    return redirect("/")

token["userinfo"] is parsed from the verified ID token. authorize_access_token() validates the signature, iss, aud, and nonce for you. Casting to a plain dict keeps the value portable across session backends.

Saving id_token here makes it available later for RP-initiated logout. Register http://localhost:5001/callback as a redirect URI on your application in the developer portal. To derive it dynamically, use url_for("callback", _external=True).

Flask's default session is a signed-but-not-encrypted cookie capped at ~4KB. id_token + access_token + refresh_token + userinfo is in the 2–3KB range — close enough that any extra payload tips you over. Switch to server-side sessions (e.g. flask-session backed by Redis or filesystem) for anything beyond a demo. Storing tokens in an unencrypted cookie also means anyone with the cookie reads the tokens.

Calling your API

import requests

res = requests.get(
    "https://api.example.com/orders",
    headers={"Authorization": f"Bearer {session['access_token']}"},
)

Verifying access tokens

When your Python service receives a Kenni access token (because you requested a custom API scope), validate it locally against the JWKS from discovery — issuer, audience, expiry, signature, plus the scope check:

import requests
from authlib.jose import JsonWebKey, jwt

ISSUER = "https://idp.kenni.is/<team-domain>"
API_AUDIENCE = "<your-api-audience>"
API_SCOPE = "<your-api-scope>"

# Cache this — don't refetch the JWKS per request.
metadata = oauth.kenni.load_server_metadata()
keyset = JsonWebKey.import_key_set(requests.get(metadata["jwks_uri"]).json())

def verify_bearer(raw_bearer: str) -> dict:
    claims = jwt.decode(
        raw_bearer,
        keyset,
        claims_options={
            "iss": {"essential": True, "value": ISSUER},
            "aud": {"essential": True, "value": API_AUDIENCE},
            "exp": {"essential": True},
        },
    )
    claims.validate()
    if API_SCOPE not in claims.get("scope", "").split():
        raise PermissionError("missing required scope")
    return claims

authlib.jose emits an AuthlibDeprecationWarning recommending joserfc instead. The API stays compatible until Authlib 2.0; pin Authlib if the warning is noisy, or migrate to joserfc for new code.

Client credentials (M2M)

For machine-to-machine calls, hit the token endpoint directly:

import requests

metadata = oauth.kenni.load_server_metadata()
res = requests.post(
    metadata["token_endpoint"],
    data={"grant_type": "client_credentials", "scope": API_SCOPE},
    auth=(os.environ["KENNI_CLIENT_ID"], os.environ["KENNI_CLIENT_SECRET"]),
)
access_token = res.json()["access_token"]

For credentials with special characters, the form-urlencoded Basic auth from RFC 6749 §2.3.1 requires base64(quote(id) + ":" + quote(secret)) rather than requests' default auth= tuple. Build the Authorization: Basic … header by hand if you suspect special characters in your secret.

Refresh

new_token = oauth.kenni.fetch_access_token(
    grant_type="refresh_token",
    refresh_token=session["refresh_token"],
)
session["access_token"] = new_token["access_token"]
session["refresh_token"] = new_token.get("refresh_token", session["refresh_token"])

fetch_access_token does not mutate the session — write the new token back yourself.

FastAPI / Starlette

Authlib has a Starlette integration that drops straight into FastAPI:

from authlib.integrations.starlette_client import OAuth
from fastapi import FastAPI, Request
from starlette.responses import RedirectResponse

app = FastAPI()
oauth = OAuth()
oauth.register(name="kenni", server_metadata_url="...", client_kwargs={...})

@app.get("/login")
async def login(request: Request):
    return await oauth.kenni.authorize_redirect(request, request.url_for("callback"))

@app.get("/callback")
async def callback(request: Request):
    token = await oauth.kenni.authorize_access_token(request)
    request.session["userinfo"] = dict(token.get("userinfo") or {})
    request.session["id_token"] = token["id_token"]
    return RedirectResponse("/")

The route bodies are otherwise identical — just async, with request.session instead of Flask's global session, and RedirectResponse instead of redirect().

Public clients

For SPA or Native flows, omit client_secret. Authlib uses PKCE (already enabled via code_challenge_method) as the proof on the token request. PKCE is also recommended for confidential clients — defence in depth against authorization-code interception.

Sign out

session.clear() kills your tokens but leaves the Kenni session alive — the next sign-in silently re-authenticates. Read end_session_endpoint from the cached discovery metadata and redirect through it. The id_token was saved in the callback above.

from urllib.parse import urlencode

@app.route("/logout")
def logout():
    id_token = session.get("id_token")
    metadata = oauth.kenni.load_server_metadata()
    end_session = metadata["end_session_endpoint"]
    session.clear()
    params = urlencode({
        "id_token_hint": id_token,
        "post_logout_redirect_uri": url_for("index", _external=True),
        "client_id": os.environ["KENNI_CLIENT_ID"],
    })
    return redirect(f"{end_session}?{params}")

Register the resolved post-logout URL on the application's Post logout redirect URIs list in the developer portal. See the No framework (curl) guide for the full reasoning.

oauth.kenni.load_server_metadata() returns the cached discovery doc — use it to read any field (token_endpoint, jwks_uri, end_session_endpoint, etc.) without a re-fetch.

Next steps