October 2, 2026
Login Is Not Permission: Building a Working Authorizer Demo for New Engineers
One identity provider, a FastAPI backend, a Next.js web app and a Flet client, the six walls I hit on the way, and every line of code.

By Suraj Kumar
39 min read
There is a moment in almost every authentication lesson that is worth watching for. A learner types a password, the screen says "Welcome, alice," and they lean back with a small, satisfied smile. "So⦠we're done?"
I love that smile, and I hate having to answer it. Because the honest answer is: no. Not even close.
Logging in answers exactly one question: who are you? It says nothing about what you are allowed to do. And the most common security mistake I see in early-career projects is not a broken password hash or a leaked key. It is a system where anyone with a valid login can read anything, because nobody wrote the second half of the sentence.
So I set out to build a demo that makes that gap visible, something you can click. You sign in as Alice from Finance. You ask the API for the finance dataset and get a green 200. You ask for the research dataset and get a red 403. Same person, same token, different answers, and now the difference between identity and permission is something learners have seen rather than something they were told.
This article is the whole thing: the architecture, every file, and the six walls I ran into while getting it running on a Windows machine through WSL.
The one idea
Keep this sentence on a slide:
Authentication proves who is calling. Authorization decides what they may touch. Your API has to do the second part on every request.
Two HTTP status codes carry that whole idea:
- 401 Unauthorized really means unauthenticated: "I don't know who you are." No token, a tampered token, an expired token.
- 403 Forbidden means "I know exactly who you are, and the answer is no."
A login page can fix a 401. Nothing a login page does can fix a 403.
If you build AI applications, a retrieval-augmented (RAG) system or a tool-calling agent, the same rule applies to them, and it is easier to forget there. A vector store does not know who is asking. A tool server does not know who is asking. Unless you filter and check on every call, your assistant will happily summarize a document the user should never have seen. The demo includes a small RAG endpoint and a tool endpoint precisely so this is not abstract.
What we are building
Next.js web ββ ββ PostgreSQL (users)
Flet app βββββΌββΊ Authorizer (OIDC, RS256) ββββββ€
β β² JWKS (public keys)ββ Redis (sessions)
βββΊ FastAPI βββ verifies every token, then applies
role + division rules (datasets, RAG, tools) Next.js web ββ ββ PostgreSQL (users)
Flet app βββββΌββΊ Authorizer (OIDC, RS256) ββββββ€
β β² JWKS (public keys)ββ Redis (sessions)
βββΊ FastAPI βββ verifies every token, then applies
role + division rules (datasets, RAG, tools)Authorizer is an open-source, self-hosted authentication server. It handles sign-up, login, sessions and token issuing, and it speaks standard OAuth2 and OpenID Connect. That means the web app, the Flet app and the API all trust the same identity provider, and none of them has to store a password.
The flow is short:
- A user signs in to Authorizer from a client (browser or Flet app).
- Authorizer returns an access token, a signed JWT.
- The client sends that token to our FastAPI backend as
Authorization: Bearer .... - The backend fetches Authorizer's public keys (the JWKS), verifies the signature, checks issuer, audience and expiry, reads the user's roles, and only then decides what to return.
The signing uses RS256: Authorizer holds the private key, everyone else only ever sees the public one. Our API cannot forge a token even if it wanted to. That asymmetry is another good whiteboard moment.
The people in our fictional company:
- alice: roles
vieweranddiv-finance - bob: roles
vieweranddiv-research - carol: role
vieweronly (no division) - admin: roles
adminandviewer
"Division" is just a role with a div- prefix. That keeps the demo small while still showing real resource-level authorization.
Here is the layout of the repository:
authorizer-demo/
βββ docker-compose.yml Authorizer + PostgreSQL + Redis + API
βββ .env.example template; init.sh writes the real .env
βββ Makefile
βββ scripts/ init.sh, seed_users.py, e2e_check.py, _common.py
βββ backend/ FastAPI: app/ and tests/
βββ frontend/ Next.js "Access ledger"
βββ flet_app/ Python clientauthorizer-demo/
βββ docker-compose.yml Authorizer + PostgreSQL + Redis + API
βββ .env.example template; init.sh writes the real .env
βββ Makefile
βββ scripts/ init.sh, seed_users.py, e2e_check.py, _common.py
βββ backend/ FastAPI: app/ and tests/
βββ frontend/ Next.js "Access ledger"
βββ flet_app/ Python clientStep 1: run the identity provider
Everything starts with docker-compose.yml. Two things about Authorizer v2 shape it, and both surprised me.
First, v2 is configured only through command-line flags. It does not read a .env file or environment variables. That is a deliberate twelve-factor choice, but it means the usual habit of dropping settings into .env silently does nothing. A workaround: Compose passes values into the container as environment variables, and a small shell wrapper turns them into flags. The doubled $$ stops Compose from expanding the variables before the container does.
Second, signing keys must survive restarts. If Authorizer generates keys at startup, every restart invalidates every token in circulation. So scripts/init.sh creates an RSA key pair once, and the container reads it from a mounted folder.
A few flags deserve a sentence each:
--roleslists the roles that exist.--default-roles=vieweris what a new user gets.--protected-roles=admin,div-finance,div-researchis the quiet hero. Protected roles cannot be self-assigned at sign-up. Removediv-financefrom that flag, sign up a new user asking for it, and watch your access model dissolve. That is a wonderful live demo.--allowed-originsdecides which web origins may call Authorizer.--disable-mfais there so scripted logins can get tokens. Keep multi-factor authentication on in production; more on that in the walls section.
docker-compose.yml
# Authorizer v2 is configured ONLY through CLI flags (it ignores .env / OS env).
# We keep values in .env for Docker Compose, pass them into the container as
# environment variables, and let a small shell wrapper turn them into flags.
name: authorizer-demo
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: authorizer
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: authorizer
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U authorizer -d authorizer"]
interval: 5s
timeout: 3s
retries: 20
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
authorizer:
# Pin a tested tag in .env (AUTHORIZER_VERSION) before production.
image: quay.io/authorizer/authorizer:${AUTHORIZER_VERSION:-latest}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
ports:
- "8080:8080"
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
AUTHORIZER_URL: ${AUTHORIZER_URL}
CLIENT_ID: ${CLIENT_ID}
CLIENT_SECRET: ${CLIENT_SECRET}
ADMIN_SECRET: ${ADMIN_SECRET}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
volumes:
# RSA key pair created by scripts/init.sh. Persisting it means tokens
# stay valid across restarts and the JWKS endpoint stays stable.
- ./keys:/keys:ro
# The image ENTRYPOINT is ./authorizer; we swap in a shell so $$VARS expand
# inside the container (the double $$ stops Compose expanding them first).
entrypoint: ["/bin/sh", "-c"]
command:
- >-
exec ./authorizer
--database-type=postgres
--database-url="postgres://authorizer:$${POSTGRES_PASSWORD}@postgres:5432/authorizer?sslmode=disable"
--redis-url=redis://redis:6379
--url="$${AUTHORIZER_URL}"
--client-id="$${CLIENT_ID}"
--client-secret="$${CLIENT_SECRET}"
--admin-secret="$${ADMIN_SECRET}"
--jwt-type=RS256
--jwt-private-key="$$(cat /keys/jwt_private.pem)"
--jwt-public-key="$$(cat /keys/jwt_public.pem)"
--encryption-key="$${ENCRYPTION_KEY}"
--jwt-role-claim=roles
--roles=admin,viewer,div-finance,div-research
--default-roles=viewer
--protected-roles=admin,div-finance,div-research
--allowed-origins=http://localhost:3000,http://localhost:8000,http://127.0.0.1:8765,http://localhost:8080,http://127.0.0.1:8080
--enable-email-verification=false
--disable-mfa
--app-cookie-secure=false
--admin-cookie-secure=false
--organization-name="Authorizer Demo"
backend:
build: ./backend
depends_on:
- authorizer
ports:
- "8000:8000"
environment:
AUTHORIZER_URL: ${AUTHORIZER_URL} # must equal the token's `iss`
AUTHORIZER_INTERNAL_URL: http://authorizer:8080 # how the container reaches it
CLIENT_ID: ${CLIENT_ID} # must equal the token's `aud`
ROLE_CLAIM: allowed_roles # all roles assigned to the user
ALLOWED_ORIGINS: http://localhost:3000
volumes:
pg_data:
redis_data:# Authorizer v2 is configured ONLY through CLI flags (it ignores .env / OS env).
# We keep values in .env for Docker Compose, pass them into the container as
# environment variables, and let a small shell wrapper turn them into flags.
name: authorizer-demo
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: authorizer
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: authorizer
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U authorizer -d authorizer"]
interval: 5s
timeout: 3s
retries: 20
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
authorizer:
# Pin a tested tag in .env (AUTHORIZER_VERSION) before production.
image: quay.io/authorizer/authorizer:${AUTHORIZER_VERSION:-latest}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
ports:
- "8080:8080"
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
AUTHORIZER_URL: ${AUTHORIZER_URL}
CLIENT_ID: ${CLIENT_ID}
CLIENT_SECRET: ${CLIENT_SECRET}
ADMIN_SECRET: ${ADMIN_SECRET}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
volumes:
# RSA key pair created by scripts/init.sh. Persisting it means tokens
# stay valid across restarts and the JWKS endpoint stays stable.
- ./keys:/keys:ro
# The image ENTRYPOINT is ./authorizer; we swap in a shell so $$VARS expand
# inside the container (the double $$ stops Compose expanding them first).
entrypoint: ["/bin/sh", "-c"]
command:
- >-
exec ./authorizer
--database-type=postgres
--database-url="postgres://authorizer:$${POSTGRES_PASSWORD}@postgres:5432/authorizer?sslmode=disable"
--redis-url=redis://redis:6379
--url="$${AUTHORIZER_URL}"
--client-id="$${CLIENT_ID}"
--client-secret="$${CLIENT_SECRET}"
--admin-secret="$${ADMIN_SECRET}"
--jwt-type=RS256
--jwt-private-key="$$(cat /keys/jwt_private.pem)"
--jwt-public-key="$$(cat /keys/jwt_public.pem)"
--encryption-key="$${ENCRYPTION_KEY}"
--jwt-role-claim=roles
--roles=admin,viewer,div-finance,div-research
--default-roles=viewer
--protected-roles=admin,div-finance,div-research
--allowed-origins=http://localhost:3000,http://localhost:8000,http://127.0.0.1:8765,http://localhost:8080,http://127.0.0.1:8080
--enable-email-verification=false
--disable-mfa
--app-cookie-secure=false
--admin-cookie-secure=false
--organization-name="Authorizer Demo"
backend:
build: ./backend
depends_on:
- authorizer
ports:
- "8000:8000"
environment:
AUTHORIZER_URL: ${AUTHORIZER_URL} # must equal the token's `iss`
AUTHORIZER_INTERNAL_URL: http://authorizer:8080 # how the container reaches it
CLIENT_ID: ${CLIENT_ID} # must equal the token's `aud`
ROLE_CLAIM: allowed_roles # all roles assigned to the user
ALLOWED_ORIGINS: http://localhost:3000
volumes:
pg_data:
redis_data:The .env template, the setup script that fills it with random secrets and generates the keys, and a Makefile for convenience:
.env.example
# Copied to .env (with random secrets) by scripts/init.sh
AUTHORIZER_VERSION=latest
AUTHORIZER_URL=http://localhost:8080
CLIENT_ID=change-me
CLIENT_SECRET=change-me
ADMIN_SECRET=change-me
ENCRYPTION_KEY=change-me
POSTGRES_PASSWORD=change-me# Copied to .env (with random secrets) by scripts/init.sh
AUTHORIZER_VERSION=latest
AUTHORIZER_URL=http://localhost:8080
CLIENT_ID=change-me
CLIENT_SECRET=change-me
ADMIN_SECRET=change-me
ENCRYPTION_KEY=change-me
POSTGRES_PASSWORD=change-mescripts/init.sh
#!/usr/bin/env bash
# One-time setup: random secrets, RSA signing keys, frontend env file.
set -euo pipefail
cd "$(dirname "$0")/.."
rand() { openssl rand -hex 24; }
if [ ! -f .env ]; then
cp .env.example .env
sed -i.bak \
-e "s/^CLIENT_ID=.*/CLIENT_ID=$(openssl rand -hex 12)/" \
-e "s/^CLIENT_SECRET=.*/CLIENT_SECRET=$(rand)/" \
-e "s/^ADMIN_SECRET=.*/ADMIN_SECRET=$(rand)/" \
-e "s/^ENCRYPTION_KEY=.*/ENCRYPTION_KEY=$(rand)/" \
-e "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$(rand)/" .env
rm -f .env.bak
echo "created .env"
fi
mkdir -p keys
if [ ! -f keys/jwt_private.pem ]; then
openssl genrsa -out keys/jwt_private.pem 2048 2>/dev/null
openssl rsa -in keys/jwt_private.pem -pubout -out keys/jwt_public.pem 2>/dev/null
# Demo only: the container runs as a non-root user and must read the key.
chmod 644 keys/*.pem
echo "created RSA signing keys in ./keys"
fi
set -a; . ./.env; set +a
cat > frontend/.env.local <<EOT
NEXT_PUBLIC_AUTHORIZER_URL=${AUTHORIZER_URL}
NEXT_PUBLIC_API_URL=http://localhost:8000
EOT
echo "created frontend/.env.local"
echo "Next: docker compose up -d --build && python3 scripts/seed_users.py"#!/usr/bin/env bash
# One-time setup: random secrets, RSA signing keys, frontend env file.
set -euo pipefail
cd "$(dirname "$0")/.."
rand() { openssl rand -hex 24; }
if [ ! -f .env ]; then
cp .env.example .env
sed -i.bak \
-e "s/^CLIENT_ID=.*/CLIENT_ID=$(openssl rand -hex 12)/" \
-e "s/^CLIENT_SECRET=.*/CLIENT_SECRET=$(rand)/" \
-e "s/^ADMIN_SECRET=.*/ADMIN_SECRET=$(rand)/" \
-e "s/^ENCRYPTION_KEY=.*/ENCRYPTION_KEY=$(rand)/" \
-e "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$(rand)/" .env
rm -f .env.bak
echo "created .env"
fi
mkdir -p keys
if [ ! -f keys/jwt_private.pem ]; then
openssl genrsa -out keys/jwt_private.pem 2048 2>/dev/null
openssl rsa -in keys/jwt_private.pem -pubout -out keys/jwt_public.pem 2>/dev/null
# Demo only: the container runs as a non-root user and must read the key.
chmod 644 keys/*.pem
echo "created RSA signing keys in ./keys"
fi
set -a; . ./.env; set +a
cat > frontend/.env.local <<EOT
NEXT_PUBLIC_AUTHORIZER_URL=${AUTHORIZER_URL}
NEXT_PUBLIC_API_URL=http://localhost:8000
EOT
echo "created frontend/.env.local"
echo "Next: docker compose up -d --build && python3 scripts/seed_users.py"Makefile
.PHONY: init up down logs seed e2e api web flet test reset
init: ; ./scripts/init.sh
up: ; docker compose up -d --build
down: ; docker compose down
logs: ; docker compose logs -f authorizer backend
seed: ; python3 scripts/seed_users.py
e2e: ; python3 scripts/e2e_check.py
test: ; cd backend && python3 -m pytest -q
api: ; set -a && . ./.env && set +a && cd backend && uvicorn app.main:app --reload --port 8000
web: ; cd frontend && npm install && npm run dev
reset: ; docker compose down -v && rm -f .env keys/*.pem
flet: ; cd flet_app && pip install -r requirements.txt && flet run main.py.PHONY: init up down logs seed e2e api web flet test reset
init: ; ./scripts/init.sh
up: ; docker compose up -d --build
down: ; docker compose down
logs: ; docker compose logs -f authorizer backend
seed: ; python3 scripts/seed_users.py
e2e: ; python3 scripts/e2e_check.py
test: ; cd backend && python3 -m pytest -q
api: ; set -a && . ./.env && set +a && cd backend && uvicorn app.main:app --reload --port 8000
web: ; cd frontend && npm install && npm run dev
reset: ; docker compose down -v && rm -f .env keys/*.pem
flet: ; cd flet_app && pip install -r requirements.txt && flet run main.pyBring it up like this:
./scripts/init.sh
docker compose up -d --build./scripts/init.sh
docker compose up -d --buildStep 2: the API that does the deciding
This is the heart of the lesson. The backend has four small files.
Configuration
Two settings matter more than they look. authorizer_url must equal the token's iss (issuer) claim, and client_id must equal its aud (audience). Inside Docker the API reaches Authorizer at http://authorizer:8080, but tokens still carry the public http://localhost:8080 as issuer, so there are two URLs, on purpose.
backend/app/config.py
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# Reads real environment variables first, then a local/root .env file.
model_config = SettingsConfigDict(env_file=(".env", "../.env"), extra="ignore")
# Public Authorizer URL. Must equal the `iss` claim in the tokens.
authorizer_url: str = "http://localhost:8080"
# How THIS process reaches Authorizer (differs from the public URL in Docker).
authorizer_internal_url: str | None = None
# Must equal the `aud` claim (Authorizer uses its --client-id as audience).
client_id: str = "demo-client-id"
allowed_origins: str = "http://localhost:3000"
jwks_cache_seconds: int = 300
# Which claim carries the user's roles. Authorizer puts the roles ACTIVE in this session
# in `roles` (just the default role unless the client asked for more at login) and ALL
# roles assigned to the user in `allowed_roles`. The demo authorizes on `allowed_roles`.
role_claim: str = "allowed_roles"
@property
def issuer(self) -> str:
return self.authorizer_url.rstrip("/")
@property
def jwks_url(self) -> str:
base = (self.authorizer_internal_url or self.authorizer_url).rstrip("/")
return f"{base}/.well-known/jwks.json"
@property
def origins(self) -> list[str]:
return [o.strip() for o in self.allowed_origins.split(",") if o.strip()]
@lru_cache
def get_settings() -> Settings:
return Settings()from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# Reads real environment variables first, then a local/root .env file.
model_config = SettingsConfigDict(env_file=(".env", "../.env"), extra="ignore")
# Public Authorizer URL. Must equal the `iss` claim in the tokens.
authorizer_url: str = "http://localhost:8080"
# How THIS process reaches Authorizer (differs from the public URL in Docker).
authorizer_internal_url: str | None = None
# Must equal the `aud` claim (Authorizer uses its --client-id as audience).
client_id: str = "demo-client-id"
allowed_origins: str = "http://localhost:3000"
jwks_cache_seconds: int = 300
# Which claim carries the user's roles. Authorizer puts the roles ACTIVE in this session
# in `roles` (just the default role unless the client asked for more at login) and ALL
# roles assigned to the user in `allowed_roles`. The demo authorizes on `allowed_roles`.
role_claim: str = "allowed_roles"
@property
def issuer(self) -> str:
return self.authorizer_url.rstrip("/")
@property
def jwks_url(self) -> str:
base = (self.authorizer_internal_url or self.authorizer_url).rstrip("/")
return f"{base}/.well-known/jwks.json"
@property
def origins(self) -> list[str]:
return [o.strip() for o in self.allowed_origins.split(",") if o.strip()]
@lru_cache
def get_settings() -> Settings:
return Settings()Verifying tokens and enforcing roles
security.py is the file I would ask to read line by line. A few decisions in it are worth pointing at:
- Only RS256 is accepted. Never let the token tell you which algorithm to use; that is how "alg: none" attacks work.
- Audience and issuer are checked, and
exp,iat,subare required. A token from a different application, even one signed by the same server, is rejected. - ID tokens are refused. An ID token is signed by the same key, but it is meant for the client, not the API. The
token_typecheck stops it being used as a credential. - A missing token and a bad token both return 401. If the identity provider itself is unreachable, we return 503, because that is a different kind of problem.
require_rolesreturns 403, not 401. The user is known; the answer is no.
And notice _roles. It reads the allowed_roles claim by default. I will tell you why in the walls section, because it cost me an evening.
backend/app/security.py
"""Token verification and authorization dependencies.Authentication = "is this a valid token issued by Authorizer?" -> current_user
Authorization = "may THIS user do THIS thing to THIS resource?" -> require_roles
and the per-resource checks in main.py / data.py
Authorizer proves who the caller is. Every route still has to decide what that
caller may touch. That second half is never automatic.
"""
from dataclasses import dataclass
from functools import lru_cache
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt import PyJWKClient
from jwt.exceptions import PyJWKClientConnectionError, PyJWKClientError, PyJWTError
from .config import get_settings
bearer = HTTPBearer(auto_error=False)
DIVISION_PREFIX = "div-"
@dataclass(frozen=True)
class User:
sub: str
email: str | None
roles: frozenset[str]
claims: dict
@property
def is_admin(self) -> bool:
return "admin" in self.roles
@property
def divisions(self) -> frozenset[str]:
return frozenset(r.removeprefix(DIVISION_PREFIX) for r in self.roles if r.startswith(DIVISION_PREFIX))
def can_access_division(self, division: str) -> bool:
return self.is_admin or division in self.divisions
@lru_cache
def jwks_client() -> PyJWKClient:
s = get_settings()
# Keys are cached, so a request does not hit Authorizer every time.
return PyJWKClient(s.jwks_url, cache_keys=True, lifespan=s.jwks_cache_seconds)
def _signing_key(token: str):
client = jwks_client()
kid = jwt.get_unverified_header(token).get("kid")
if kid:
return client.get_signing_key(kid).key
keys = client.get_signing_keys()
if len(keys) == 1:
return keys[0].key
raise PyJWTError("token has no kid and JWKS has several keys")
def _unauthorized(detail: str) -> HTTPException:
return HTTPException(status.HTTP_401_UNAUTHORIZED, detail, headers={"WWW-Authenticate": "Bearer"})
def decode_token(token: str) -> dict:
s = get_settings()
try:
claims = jwt.decode(
token,
_signing_key(token),
algorithms=["RS256"], # never accept "none" or HS256 here
audience=s.client_id,
issuer=s.issuer,
options={"require": ["exp", "iat", "iss", "aud", "sub"]},
)
except PyJWKClientConnectionError:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "identity provider unreachable")
except (PyJWKClientError, PyJWTError) as exc:
raise _unauthorized(f"invalid token: {exc.__class__.__name__}")
# An ID token is signed by the same key. It must not work as an API credential.
if claims.get("token_type", "access_token") != "access_token":
raise _unauthorized("not an access token")
return claims
def _roles(claims: dict) -> frozenset[str]:
"""Read roles from the configured claim (then `roles`, `role`).
Authorizer's `roles` claim is only what the session activated (usually the default role);
`allowed_roles` is everything an admin assigned. Reading `roles` alone silently drops
division roles, so the demo defaults to `allowed_roles`. For least-privilege sessions,
have clients request specific roles at login and set ROLE_CLAIM=roles instead. Accepts a list, or a
comma/space separated string, because identity providers differ."""
for name in (get_settings().role_claim, "roles", "role"):
raw = claims.get(name)
if not raw:
continue
items = raw.replace(",", " ").split() if isinstance(raw, str) else [str(r) for r in raw]
return frozenset(i.strip() for i in items if i.strip())
return frozenset()
def current_user(creds: HTTPAuthorizationCredentials | None = Depends(bearer)) -> User:
if creds is None or creds.scheme.lower() != "bearer":
raise _unauthorized("missing bearer token")
claims = decode_token(creds.credentials)
return User(sub=claims["sub"], email=claims.get("email"), roles=_roles(claims), claims=claims)
def require_roles(*needed: str):
"""Allow the request if the user holds at least one of the roles."""
def dependency(user: User = Depends(current_user)) -> User:
if not user.roles.intersection(needed):
raise HTTPException(status.HTTP_403_FORBIDDEN, f"requires one of: {', '.join(needed)}")
return user
return dependency
"""Token verification and authorization dependencies.Authentication = "is this a valid token issued by Authorizer?" -> current_user
Authorization = "may THIS user do THIS thing to THIS resource?" -> require_roles
and the per-resource checks in main.py / data.py
Authorizer proves who the caller is. Every route still has to decide what that
caller may touch. That second half is never automatic.
"""
from dataclasses import dataclass
from functools import lru_cache
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt import PyJWKClient
from jwt.exceptions import PyJWKClientConnectionError, PyJWKClientError, PyJWTError
from .config import get_settings
bearer = HTTPBearer(auto_error=False)
DIVISION_PREFIX = "div-"
@dataclass(frozen=True)
class User:
sub: str
email: str | None
roles: frozenset[str]
claims: dict
@property
def is_admin(self) -> bool:
return "admin" in self.roles
@property
def divisions(self) -> frozenset[str]:
return frozenset(r.removeprefix(DIVISION_PREFIX) for r in self.roles if r.startswith(DIVISION_PREFIX))
def can_access_division(self, division: str) -> bool:
return self.is_admin or division in self.divisions
@lru_cache
def jwks_client() -> PyJWKClient:
s = get_settings()
# Keys are cached, so a request does not hit Authorizer every time.
return PyJWKClient(s.jwks_url, cache_keys=True, lifespan=s.jwks_cache_seconds)
def _signing_key(token: str):
client = jwks_client()
kid = jwt.get_unverified_header(token).get("kid")
if kid:
return client.get_signing_key(kid).key
keys = client.get_signing_keys()
if len(keys) == 1:
return keys[0].key
raise PyJWTError("token has no kid and JWKS has several keys")
def _unauthorized(detail: str) -> HTTPException:
return HTTPException(status.HTTP_401_UNAUTHORIZED, detail, headers={"WWW-Authenticate": "Bearer"})
def decode_token(token: str) -> dict:
s = get_settings()
try:
claims = jwt.decode(
token,
_signing_key(token),
algorithms=["RS256"], # never accept "none" or HS256 here
audience=s.client_id,
issuer=s.issuer,
options={"require": ["exp", "iat", "iss", "aud", "sub"]},
)
except PyJWKClientConnectionError:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "identity provider unreachable")
except (PyJWKClientError, PyJWTError) as exc:
raise _unauthorized(f"invalid token: {exc.__class__.__name__}")
# An ID token is signed by the same key. It must not work as an API credential.
if claims.get("token_type", "access_token") != "access_token":
raise _unauthorized("not an access token")
return claims
def _roles(claims: dict) -> frozenset[str]:
"""Read roles from the configured claim (then `roles`, `role`).
Authorizer's `roles` claim is only what the session activated (usually the default role);
`allowed_roles` is everything an admin assigned. Reading `roles` alone silently drops
division roles, so the demo defaults to `allowed_roles`. For least-privilege sessions,
have clients request specific roles at login and set ROLE_CLAIM=roles instead. Accepts a list, or a
comma/space separated string, because identity providers differ."""
for name in (get_settings().role_claim, "roles", "role"):
raw = claims.get(name)
if not raw:
continue
items = raw.replace(",", " ").split() if isinstance(raw, str) else [str(r) for r in raw]
return frozenset(i.strip() for i in items if i.strip())
return frozenset()
def current_user(creds: HTTPAuthorizationCredentials | None = Depends(bearer)) -> User:
if creds is None or creds.scheme.lower() != "bearer":
raise _unauthorized("missing bearer token")
claims = decode_token(creds.credentials)
return User(sub=claims["sub"], email=claims.get("email"), roles=_roles(claims), claims=claims)
def require_roles(*needed: str):
"""Allow the request if the user holds at least one of the roles."""
def dependency(user: User = Depends(current_user)) -> User:
if not user.roles.intersection(needed):
raise HTTPException(status.HTTP_403_FORBIDDEN, f"requires one of: {', '.join(needed)}")
return user
return dependency
The data, with ownership on every record
data.py stands in for a database, a vector store and a tool registry. The rule to notice: every record carries its owning division, and every read path filters before returning anything. The RAG search filters documents by the caller's divisions first and ranks second. If you rank first and filter after, the ranking itself can leak. So can the count of hidden results, which is why the code never reports how many documents were filtered out.
backend/app/data.py
"""In-memory stand-ins for a database, a vector store, and an MCP tool registry.
The point for trainees: every record carries its owning division, and every read
path filters by the caller's divisions BEFORE anything is returned.
"""
from .security import User
DATASETS = [
{"id": "fin-q3-revenue", "division": "finance", "title": "Q3 revenue ledger", "rows": 1240},
{"id": "fin-forecast", "division": "finance", "title": "FY forecast model", "rows": 312},
{"id": "res-trials-2026", "division": "research", "title": "Trial results 2026", "rows": 8850},
{"id": "res-model-evals", "division": "research", "title": "Model evaluation runs", "rows": 4120},
]
DOCUMENTS = [
{"id": "d1", "division": "finance", "title": "Revenue policy", "text": "Revenue is recognised when a signed order ships. Q3 revenue grew 12 percent."},
{"id": "d2", "division": "finance", "title": "Budget memo", "text": "The forecast assumes flat headcount and a 4 percent cost increase."},
{"id": "d3", "division": "research", "title": "Trial protocol", "text": "Trial 7 enrols 400 participants. Results are embargoed until publication."},
{"id": "d4", "division": "research", "title": "Eval notes", "text": "Model evaluation shows a 3 point gain on the held-out benchmark."},
]
# Tool -> roles allowed to call it. Same idea an MCP server needs per tool call.
TOOLS = {
"summarize_dataset": {"viewer", "admin"},
"export_dataset": {"admin"},
}
AUDIT: list[dict] = []
def log(user: User, action: str, target: str, allowed: bool) -> None:
AUDIT.append({"user": user.email or user.sub, "action": action, "target": target, "allowed": allowed})
del AUDIT[:-200]
def visible_datasets(user: User) -> list[dict]:
return [d for d in DATASETS if user.can_access_division(d["division"])]
def find_dataset(dataset_id: str) -> dict | None:
return next((d for d in DATASETS if d["id"] == dataset_id), None)
def search_documents(user: User, query: str, limit: int = 3) -> list[dict]:
allowed = [d for d in DOCUMENTS if user.can_access_division(d["division"])] # filter first
terms = {t for t in query.lower().split() if len(t) > 2}
scored = []
for doc in allowed:
haystack = f"{doc['title']} {doc['text']}".lower()
score = sum(t in haystack for t in terms)
if score:
scored.append((score, doc))
scored.sort(key=lambda p: -p[0])
return [{"id": d["id"], "title": d["title"], "division": d["division"], "snippet": d["text"]} for _, d in scored[:limit]]"""In-memory stand-ins for a database, a vector store, and an MCP tool registry.
The point for trainees: every record carries its owning division, and every read
path filters by the caller's divisions BEFORE anything is returned.
"""
from .security import User
DATASETS = [
{"id": "fin-q3-revenue", "division": "finance", "title": "Q3 revenue ledger", "rows": 1240},
{"id": "fin-forecast", "division": "finance", "title": "FY forecast model", "rows": 312},
{"id": "res-trials-2026", "division": "research", "title": "Trial results 2026", "rows": 8850},
{"id": "res-model-evals", "division": "research", "title": "Model evaluation runs", "rows": 4120},
]
DOCUMENTS = [
{"id": "d1", "division": "finance", "title": "Revenue policy", "text": "Revenue is recognised when a signed order ships. Q3 revenue grew 12 percent."},
{"id": "d2", "division": "finance", "title": "Budget memo", "text": "The forecast assumes flat headcount and a 4 percent cost increase."},
{"id": "d3", "division": "research", "title": "Trial protocol", "text": "Trial 7 enrols 400 participants. Results are embargoed until publication."},
{"id": "d4", "division": "research", "title": "Eval notes", "text": "Model evaluation shows a 3 point gain on the held-out benchmark."},
]
# Tool -> roles allowed to call it. Same idea an MCP server needs per tool call.
TOOLS = {
"summarize_dataset": {"viewer", "admin"},
"export_dataset": {"admin"},
}
AUDIT: list[dict] = []
def log(user: User, action: str, target: str, allowed: bool) -> None:
AUDIT.append({"user": user.email or user.sub, "action": action, "target": target, "allowed": allowed})
del AUDIT[:-200]
def visible_datasets(user: User) -> list[dict]:
return [d for d in DATASETS if user.can_access_division(d["division"])]
def find_dataset(dataset_id: str) -> dict | None:
return next((d for d in DATASETS if d["id"] == dataset_id), None)
def search_documents(user: User, query: str, limit: int = 3) -> list[dict]:
allowed = [d for d in DOCUMENTS if user.can_access_division(d["division"])] # filter first
terms = {t for t in query.lower().split() if len(t) > 2}
scored = []
for doc in allowed:
haystack = f"{doc['title']} {doc['text']}".lower()
score = sum(t in haystack for t in terms)
if score:
scored.append((score, doc))
scored.sort(key=lambda p: -p[0])
return [{"id": d["id"], "title": d["title"], "division": d["division"], "snippet": d["text"]} for _, d in scored[:limit]]The routes
Look at get_dataset and invoke_tool. Both make two separate decisions: may this role do this kind of thing? and may this person touch this particular record? A tool call that checks only the first question is the classic hole in agent systems.
backend/app/main.py
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from . import data
from .config import get_settings
from .security import User, current_user, require_roles
app = FastAPI(title="Authorizer demo API")
app.add_middleware(
CORSMiddleware,
allow_origins=get_settings().origins,
allow_methods=["GET", "POST"],
allow_headers=["Authorization", "Content-Type"],
)
@app.get("/health")
def health() -> dict:
return {"status": "ok"} # public on purpose
@app.get("/api/me")
def me(user: User = Depends(current_user)) -> dict:
return {
"sub": user.sub,
"email": user.email,
"roles": sorted(user.roles),
"divisions": sorted(user.divisions),
"is_admin": user.is_admin,
}
@app.get("/api/datasets")
def list_datasets(user: User = Depends(current_user)) -> dict:
items = data.visible_datasets(user)
data.log(user, "list", "datasets", True)
return {"datasets": items}
@app.get("/api/datasets/{dataset_id}")
def get_dataset(dataset_id: str, user: User = Depends(current_user)) -> dict:
ds = data.find_dataset(dataset_id)
if ds is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "dataset not found")
allowed = user.can_access_division(ds["division"])
data.log(user, "read", dataset_id, allowed)
if not allowed:
# Resource-level check. Having a valid token is not enough.
raise HTTPException(status.HTTP_403_FORBIDDEN, f"your account has no access to the {ds['division']} division")
return ds
class Query(BaseModel):
question: str
@app.post("/api/rag/query")
def rag_query(body: Query, user: User = Depends(current_user)) -> dict:
# Permission-aware retrieval: filter by division BEFORE ranking or generating.
hits = data.search_documents(user, body.question)
data.log(user, "rag", body.question[:60], True)
return {"question": body.question, "sources": hits}
class ToolCall(BaseModel):
dataset_id: str
@app.post("/api/tools/{tool}/invoke")
def invoke_tool(tool: str, body: ToolCall, user: User = Depends(current_user)) -> dict:
allowed_roles = data.TOOLS.get(tool)
if allowed_roles is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "unknown tool")
if not user.roles & allowed_roles: # check 1: may this role call this tool?
data.log(user, f"tool:{tool}", body.dataset_id, False)
raise HTTPException(status.HTTP_403_FORBIDDEN, f"tool '{tool}' requires one of: {', '.join(sorted(allowed_roles))}")
ds = data.find_dataset(body.dataset_id)
if ds is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "dataset not found")
if not user.can_access_division(ds["division"]): # check 2: may they touch THIS data?
data.log(user, f"tool:{tool}", body.dataset_id, False)
raise HTTPException(status.HTTP_403_FORBIDDEN, f"no access to the {ds['division']} division")
data.log(user, f"tool:{tool}", body.dataset_id, True)
return {"tool": tool, "dataset": ds["id"], "result": f"{tool} completed on {ds['title']} ({ds['rows']} rows)"}
@app.get("/api/admin/audit")
def audit(user: User = Depends(require_roles("admin"))) -> dict:
return {"entries": list(reversed(data.AUDIT))}from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from . import data
from .config import get_settings
from .security import User, current_user, require_roles
app = FastAPI(title="Authorizer demo API")
app.add_middleware(
CORSMiddleware,
allow_origins=get_settings().origins,
allow_methods=["GET", "POST"],
allow_headers=["Authorization", "Content-Type"],
)
@app.get("/health")
def health() -> dict:
return {"status": "ok"} # public on purpose
@app.get("/api/me")
def me(user: User = Depends(current_user)) -> dict:
return {
"sub": user.sub,
"email": user.email,
"roles": sorted(user.roles),
"divisions": sorted(user.divisions),
"is_admin": user.is_admin,
}
@app.get("/api/datasets")
def list_datasets(user: User = Depends(current_user)) -> dict:
items = data.visible_datasets(user)
data.log(user, "list", "datasets", True)
return {"datasets": items}
@app.get("/api/datasets/{dataset_id}")
def get_dataset(dataset_id: str, user: User = Depends(current_user)) -> dict:
ds = data.find_dataset(dataset_id)
if ds is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "dataset not found")
allowed = user.can_access_division(ds["division"])
data.log(user, "read", dataset_id, allowed)
if not allowed:
# Resource-level check. Having a valid token is not enough.
raise HTTPException(status.HTTP_403_FORBIDDEN, f"your account has no access to the {ds['division']} division")
return ds
class Query(BaseModel):
question: str
@app.post("/api/rag/query")
def rag_query(body: Query, user: User = Depends(current_user)) -> dict:
# Permission-aware retrieval: filter by division BEFORE ranking or generating.
hits = data.search_documents(user, body.question)
data.log(user, "rag", body.question[:60], True)
return {"question": body.question, "sources": hits}
class ToolCall(BaseModel):
dataset_id: str
@app.post("/api/tools/{tool}/invoke")
def invoke_tool(tool: str, body: ToolCall, user: User = Depends(current_user)) -> dict:
allowed_roles = data.TOOLS.get(tool)
if allowed_roles is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "unknown tool")
if not user.roles & allowed_roles: # check 1: may this role call this tool?
data.log(user, f"tool:{tool}", body.dataset_id, False)
raise HTTPException(status.HTTP_403_FORBIDDEN, f"tool '{tool}' requires one of: {', '.join(sorted(allowed_roles))}")
ds = data.find_dataset(body.dataset_id)
if ds is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "dataset not found")
if not user.can_access_division(ds["division"]): # check 2: may they touch THIS data?
data.log(user, f"tool:{tool}", body.dataset_id, False)
raise HTTPException(status.HTTP_403_FORBIDDEN, f"no access to the {ds['division']} division")
data.log(user, f"tool:{tool}", body.dataset_id, True)
return {"tool": tool, "dataset": ds["id"], "result": f"{tool} completed on {ds['title']} ({ds['rows']} rows)"}
@app.get("/api/admin/audit")
def audit(user: User = Depends(require_roles("admin"))) -> dict:
return {"entries": list(reversed(data.AUDIT))}backend/requirements.txt and backend/Dockerfile
fastapi>=0.115
uvicorn[standard]>=0.30
pyjwt[crypto]>=2.9
pydantic-settings>=2.4
httpx>=0.27
pytest>=8.0
FROM python:3.12-slim
WORKDIR /srv
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
RUN useradd -r -u 10001 api && chown -R api /srv
USER api
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]fastapi>=0.115
uvicorn[standard]>=0.30
pyjwt[crypto]>=2.9
pydantic-settings>=2.4
httpx>=0.27
pytest>=8.0
FROM python:3.12-slim
WORKDIR /srv
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
RUN useradd -r -u 10001 api && chown -R api /srv
USER api
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]Tests as the specification
These nineteen tests run without Authorizer at all. They mint tokens with a throwaway RSA key and point the API's key lookup at the matching public key. Each test mirrors a step of the checklist: no token, garbage token, expired, wrong audience, wrong issuer, signed by a stranger, ID token used as access token, viewer hitting an admin route, cross-division reads, RAG leakage, tool checks. The last test uses the exact token shape my real server produced, which I found the hard way.
backend/tests/test_api.py
"""Runs without Authorizer: we mint tokens with a throwaway RSA key and point the
backend's JWKS lookup at the matching public key. Each test mirrors a step of the
security checklist shown to trainees."""
import time
from types import SimpleNamespace
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from fastapi.testclient import TestClient
from app import security
from app.config import get_settings
from app.main import app
ISS = get_settings().issuer
AUD = get_settings().client_id
KID = "test-key"
_private = rsa.generate_private_key(public_exponent=65537, key_size=2048)
_other = rsa.generate_private_key(public_exponent=65537, key_size=2048)
class FakeJwks:
def get_signing_key(self, kid):
return SimpleNamespace(key=_private.public_key())
def get_signing_keys(self):
return [SimpleNamespace(key=_private.public_key())]
@pytest.fixture(autouse=True)
def fake_jwks(monkeypatch):
monkeypatch.setattr(security, "jwks_client", lambda: FakeJwks())
client = TestClient(app)
def token(roles, *, extra=None, exp=300, aud=AUD, iss=ISS, key=None, token_type="access_token", email="u@demo.test"):
now = int(time.time())
claims = {"sub": "user-1", "email": email, "roles": roles, "aud": aud, "iss": iss,
"iat": now, "exp": now + exp, "token_type": token_type, **(extra or {})}
return jwt.encode(claims, key or _private, algorithm="RS256", headers={"kid": KID})
def get(path, tok=None):
return client.get(path, headers={"Authorization": f"Bearer {tok}"} if tok else {})
# --- authentication: 401 ---------------------------------------------------
def test_no_token_is_401():
assert get("/api/me").status_code == 401
def test_garbage_token_is_401():
assert get("/api/me", "not.a.jwt").status_code == 401
def test_expired_token_is_401():
assert get("/api/me", token(["viewer"], exp=-10)).status_code == 401
def test_wrong_audience_is_401():
assert get("/api/me", token(["viewer"], aud="some-other-app")).status_code == 401
def test_wrong_issuer_is_401():
assert get("/api/me", token(["viewer"], iss="http://evil.example")).status_code == 401
def test_token_signed_by_another_key_is_401():
assert get("/api/me", token(["admin"], key=_other)).status_code == 401
def test_id_token_is_not_accepted_as_access_token():
assert get("/api/me", token(["viewer"], token_type="id_token")).status_code == 401
def test_health_is_public():
assert get("/health").status_code == 200
# --- authorization: 403 ----------------------------------------------------
def test_viewer_cannot_read_audit_log():
assert get("/api/admin/audit", token(["viewer"])).status_code == 403
def test_admin_can_read_audit_log():
assert get("/api/admin/audit", token(["admin", "viewer"])).status_code == 200
def test_division_user_sees_only_own_datasets():
r = get("/api/datasets", token(["viewer", "div-finance"]))
assert {d["division"] for d in r.json()["datasets"]} == {"finance"}
def test_user_without_division_sees_nothing():
assert get("/api/datasets", token(["viewer"])).json()["datasets"] == []
def test_admin_sees_every_division():
r = get("/api/datasets", token(["admin"]))
assert {d["division"] for d in r.json()["datasets"]} == {"finance", "research"}
def test_cross_division_read_is_403():
assert get("/api/datasets/res-trials-2026", token(["viewer", "div-finance"])).status_code == 403
def test_own_division_read_is_200():
assert get("/api/datasets/fin-q3-revenue", token(["viewer", "div-finance"])).status_code == 200
# --- AI layer: retrieval and tools must apply the same rules ---------------
def test_rag_never_returns_other_divisions():
r = client.post("/api/rag/query", json={"question": "trial results embargoed enrols"},
headers={"Authorization": f"Bearer {token(['viewer', 'div-finance'])}"})
assert r.status_code == 200
assert all(s["division"] == "finance" for s in r.json()["sources"])
def test_tool_role_and_division_are_both_checked():
h = lambda roles: {"Authorization": f"Bearer {token(roles)}"}
body = {"dataset_id": "fin-q3-revenue"}
assert client.post("/api/tools/export_dataset/invoke", json=body, headers=h(["viewer", "div-finance"])).status_code == 403
assert client.post("/api/tools/summarize_dataset/invoke", json={"dataset_id": "res-model-evals"}, headers=h(["viewer", "div-finance"])).status_code == 403
assert client.post("/api/tools/summarize_dataset/invoke", json=body, headers=h(["viewer", "div-finance"])).status_code == 200
def test_roles_as_comma_separated_string_are_understood():
r = get("/api/me", token("viewer,div-finance"))
assert r.status_code == 200 and r.json()["divisions"] == ["finance"]
def test_real_authorizer_shape_active_roles_narrow_allowed_roles_wide():
"""Authorizer: `roles` = active (default) role only, `allowed_roles` = everything assigned."""
tok = token(["viewer"], extra={"allowed_roles": ["viewer", "div-finance"]})
assert get("/api/datasets/fin-q3-revenue", tok).status_code == 200
assert get("/api/datasets/res-trials-2026", tok).status_code == 403
admin = token(["viewer"], extra={"allowed_roles": ["viewer", "admin"]})
assert get("/api/admin/audit", admin).status_code == 200"""Runs without Authorizer: we mint tokens with a throwaway RSA key and point the
backend's JWKS lookup at the matching public key. Each test mirrors a step of the
security checklist shown to trainees."""
import time
from types import SimpleNamespace
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from fastapi.testclient import TestClient
from app import security
from app.config import get_settings
from app.main import app
ISS = get_settings().issuer
AUD = get_settings().client_id
KID = "test-key"
_private = rsa.generate_private_key(public_exponent=65537, key_size=2048)
_other = rsa.generate_private_key(public_exponent=65537, key_size=2048)
class FakeJwks:
def get_signing_key(self, kid):
return SimpleNamespace(key=_private.public_key())
def get_signing_keys(self):
return [SimpleNamespace(key=_private.public_key())]
@pytest.fixture(autouse=True)
def fake_jwks(monkeypatch):
monkeypatch.setattr(security, "jwks_client", lambda: FakeJwks())
client = TestClient(app)
def token(roles, *, extra=None, exp=300, aud=AUD, iss=ISS, key=None, token_type="access_token", email="u@demo.test"):
now = int(time.time())
claims = {"sub": "user-1", "email": email, "roles": roles, "aud": aud, "iss": iss,
"iat": now, "exp": now + exp, "token_type": token_type, **(extra or {})}
return jwt.encode(claims, key or _private, algorithm="RS256", headers={"kid": KID})
def get(path, tok=None):
return client.get(path, headers={"Authorization": f"Bearer {tok}"} if tok else {})
# --- authentication: 401 ---------------------------------------------------
def test_no_token_is_401():
assert get("/api/me").status_code == 401
def test_garbage_token_is_401():
assert get("/api/me", "not.a.jwt").status_code == 401
def test_expired_token_is_401():
assert get("/api/me", token(["viewer"], exp=-10)).status_code == 401
def test_wrong_audience_is_401():
assert get("/api/me", token(["viewer"], aud="some-other-app")).status_code == 401
def test_wrong_issuer_is_401():
assert get("/api/me", token(["viewer"], iss="http://evil.example")).status_code == 401
def test_token_signed_by_another_key_is_401():
assert get("/api/me", token(["admin"], key=_other)).status_code == 401
def test_id_token_is_not_accepted_as_access_token():
assert get("/api/me", token(["viewer"], token_type="id_token")).status_code == 401
def test_health_is_public():
assert get("/health").status_code == 200
# --- authorization: 403 ----------------------------------------------------
def test_viewer_cannot_read_audit_log():
assert get("/api/admin/audit", token(["viewer"])).status_code == 403
def test_admin_can_read_audit_log():
assert get("/api/admin/audit", token(["admin", "viewer"])).status_code == 200
def test_division_user_sees_only_own_datasets():
r = get("/api/datasets", token(["viewer", "div-finance"]))
assert {d["division"] for d in r.json()["datasets"]} == {"finance"}
def test_user_without_division_sees_nothing():
assert get("/api/datasets", token(["viewer"])).json()["datasets"] == []
def test_admin_sees_every_division():
r = get("/api/datasets", token(["admin"]))
assert {d["division"] for d in r.json()["datasets"]} == {"finance", "research"}
def test_cross_division_read_is_403():
assert get("/api/datasets/res-trials-2026", token(["viewer", "div-finance"])).status_code == 403
def test_own_division_read_is_200():
assert get("/api/datasets/fin-q3-revenue", token(["viewer", "div-finance"])).status_code == 200
# --- AI layer: retrieval and tools must apply the same rules ---------------
def test_rag_never_returns_other_divisions():
r = client.post("/api/rag/query", json={"question": "trial results embargoed enrols"},
headers={"Authorization": f"Bearer {token(['viewer', 'div-finance'])}"})
assert r.status_code == 200
assert all(s["division"] == "finance" for s in r.json()["sources"])
def test_tool_role_and_division_are_both_checked():
h = lambda roles: {"Authorization": f"Bearer {token(roles)}"}
body = {"dataset_id": "fin-q3-revenue"}
assert client.post("/api/tools/export_dataset/invoke", json=body, headers=h(["viewer", "div-finance"])).status_code == 403
assert client.post("/api/tools/summarize_dataset/invoke", json={"dataset_id": "res-model-evals"}, headers=h(["viewer", "div-finance"])).status_code == 403
assert client.post("/api/tools/summarize_dataset/invoke", json=body, headers=h(["viewer", "div-finance"])).status_code == 200
def test_roles_as_comma_separated_string_are_understood():
r = get("/api/me", token("viewer,div-finance"))
assert r.status_code == 200 and r.json()["divisions"] == ["finance"]
def test_real_authorizer_shape_active_roles_narrow_allowed_roles_wide():
"""Authorizer: `roles` = active (default) role only, `allowed_roles` = everything assigned."""
tok = token(["viewer"], extra={"allowed_roles": ["viewer", "div-finance"]})
assert get("/api/datasets/fin-q3-revenue", tok).status_code == 200
assert get("/api/datasets/res-trials-2026", tok).status_code == 403
admin = token(["viewer"], extra={"allowed_roles": ["viewer", "admin"]})
assert get("/api/admin/audit", admin).status_code == 200Run them with cd backend && python -m pytest -q.
Step 3: seeding people and checking everything
Three small scripts. _common.py holds shared helpers, seed_users.py signs users up and lets the admin assign roles (users cannot grant themselves protected roles), and e2e_check.py walks the security checklist against the live stack and prints PASS or FAIL. When a check fails it also prints what the API saw, which turned out to be the most useful debugging feature in the whole repo.
One design note: the scripts call Authorizer's GraphQL API and build queries with inlined string literals. A JSON-encoded string is a valid GraphQL string, so json.dumps gives safe escaping without depending on the schema's input type names.
scripts/_common.py
"""Shared helpers for the demo scripts (stdlib + httpx)."""
import base64
import json
import os
import pathlib
import httpx
ROOT = pathlib.Path(__file__).resolve().parent.parent
DEMO_USERS = [
# email, password, roles the ADMIN grants after sign-up
("admin@demo.test", "Demo@12345", ["admin", "viewer"]),
("alice@demo.test", "Demo@12345", ["viewer", "div-finance"]),
("bob@demo.test", "Demo@12345", ["viewer", "div-research"]),
("carol@demo.test", "Demo@12345", ["viewer"]),
]
def load_env() -> dict:
env = {}
for line in (ROOT / ".env").read_text().splitlines():
if line.strip() and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
env[k.strip()] = v.strip()
return env
def q(value: str) -> str:
"""A JSON string is also a valid GraphQL string literal."""
return json.dumps(value)
def gql(url: str, query: str, headers: dict | None = None) -> dict:
# Authorizer >= 2.3.0 has a CSRF guard: state-changing requests need an Origin
# header that appears in --allowed-origins. Browsers add it automatically; scripts
# must send one. We borrow the web app's origin, which docker-compose.yml allows.
merged = {"Origin": os.environ.get("AUTH_ORIGIN", "http://localhost:3000"), **(headers or {})}
r = httpx.post(f"{url}/graphql", json={"query": query}, headers=merged, timeout=15)
if r.status_code >= 400:
raise RuntimeError(f"HTTP {r.status_code} from {url}/graphql: {r.text[:300]}")
body = r.json()
if body.get("errors"):
raise RuntimeError(body["errors"][0]["message"])
return body["data"]
def claims_of(token: str | None) -> dict:
try:
part = (token or "").split(".")[1]
return json.loads(base64.urlsafe_b64decode(part + "=" * (-len(part) % 4)))
except Exception:
return {}
def login(url: str, email: str, password: str) -> dict:
data = gql(url, f"mutation {{ login(params: {{email: {q(email)}, password: {q(password)}}}) "
"{ message access_token id_token expires_in user { id email roles } } }")["login"]
if not data.get("access_token"):
# No token means the server withheld it (MFA setup/verification, unverified email, ...).
raise RuntimeError(f"login returned no access_token; server message: {data.get('message')!r}")
# Some Authorizer versions return `user: null` here. The tokens always carry the facts.
if not data.get("user"):
tc = claims_of(data["access_token"])
roles = tc.get("allowed_roles") or tc.get("roles") or claims_of(data.get("id_token")).get("roles") or []
data["user"] = {"email": claims_of(data.get("id_token")).get("email", email), "roles": roles}
return data"""Shared helpers for the demo scripts (stdlib + httpx)."""
import base64
import json
import os
import pathlib
import httpx
ROOT = pathlib.Path(__file__).resolve().parent.parent
DEMO_USERS = [
# email, password, roles the ADMIN grants after sign-up
("admin@demo.test", "Demo@12345", ["admin", "viewer"]),
("alice@demo.test", "Demo@12345", ["viewer", "div-finance"]),
("bob@demo.test", "Demo@12345", ["viewer", "div-research"]),
("carol@demo.test", "Demo@12345", ["viewer"]),
]
def load_env() -> dict:
env = {}
for line in (ROOT / ".env").read_text().splitlines():
if line.strip() and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
env[k.strip()] = v.strip()
return env
def q(value: str) -> str:
"""A JSON string is also a valid GraphQL string literal."""
return json.dumps(value)
def gql(url: str, query: str, headers: dict | None = None) -> dict:
# Authorizer >= 2.3.0 has a CSRF guard: state-changing requests need an Origin
# header that appears in --allowed-origins. Browsers add it automatically; scripts
# must send one. We borrow the web app's origin, which docker-compose.yml allows.
merged = {"Origin": os.environ.get("AUTH_ORIGIN", "http://localhost:3000"), **(headers or {})}
r = httpx.post(f"{url}/graphql", json={"query": query}, headers=merged, timeout=15)
if r.status_code >= 400:
raise RuntimeError(f"HTTP {r.status_code} from {url}/graphql: {r.text[:300]}")
body = r.json()
if body.get("errors"):
raise RuntimeError(body["errors"][0]["message"])
return body["data"]
def claims_of(token: str | None) -> dict:
try:
part = (token or "").split(".")[1]
return json.loads(base64.urlsafe_b64decode(part + "=" * (-len(part) % 4)))
except Exception:
return {}
def login(url: str, email: str, password: str) -> dict:
data = gql(url, f"mutation {{ login(params: {{email: {q(email)}, password: {q(password)}}}) "
"{ message access_token id_token expires_in user { id email roles } } }")["login"]
if not data.get("access_token"):
# No token means the server withheld it (MFA setup/verification, unverified email, ...).
raise RuntimeError(f"login returned no access_token; server message: {data.get('message')!r}")
# Some Authorizer versions return `user: null` here. The tokens always carry the facts.
if not data.get("user"):
tc = claims_of(data["access_token"])
roles = tc.get("allowed_roles") or tc.get("roles") or claims_of(data.get("id_token")).get("roles") or []
data["user"] = {"email": claims_of(data.get("id_token")).get("email", email), "roles": roles}
return datascripts/seed_users.py
#!/usr/bin/env python3
"""Create demo users, then let the ADMIN assign roles.Users cannot give themselves admin or division roles: those are configured as
--protected-roles in docker-compose.yml. Only the admin API can grant them.
Dev only. In production start Authorizer with --disable-admin-header-auth=true
and manage roles from the dashboard instead of an admin secret in a script.
"""
from _common import DEMO_USERS, gql, load_env, q
def main() -> None:
env = load_env()
url = env["AUTHORIZER_URL"].rstrip("/")
admin = {"x-authorizer-admin-secret": env["ADMIN_SECRET"]}
for email, password, _ in DEMO_USERS:
try:
gql(url, f"mutation {{ signup(params: {{email: {q(email)}, password: {q(password)}, "
f"confirm_password: {q(password)}}}) {{ message }} }}")
print(f"signed up {email}")
except RuntimeError as exc:
print(f"skip signup {email}: {exc}")
users = gql(url, "{ _users { users { id email roles } } }", admin)["_users"]["users"]
by_email = {u["email"]: u for u in users}
for email, _, roles in DEMO_USERS:
user = by_email.get(email)
if not user:
print(f"!! {email} not found")
continue
role_list = ", ".join(q(r) for r in roles)
gql(url, f"mutation {{ _update_user(params: {{id: {q(user['id'])}, roles: [{role_list}]}}) {{ id }} }}", admin)
print(f"roles set {email}: {roles}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Create demo users, then let the ADMIN assign roles.Users cannot give themselves admin or division roles: those are configured as
--protected-roles in docker-compose.yml. Only the admin API can grant them.
Dev only. In production start Authorizer with --disable-admin-header-auth=true
and manage roles from the dashboard instead of an admin secret in a script.
"""
from _common import DEMO_USERS, gql, load_env, q
def main() -> None:
env = load_env()
url = env["AUTHORIZER_URL"].rstrip("/")
admin = {"x-authorizer-admin-secret": env["ADMIN_SECRET"]}
for email, password, _ in DEMO_USERS:
try:
gql(url, f"mutation {{ signup(params: {{email: {q(email)}, password: {q(password)}, "
f"confirm_password: {q(password)}}}) {{ message }} }}")
print(f"signed up {email}")
except RuntimeError as exc:
print(f"skip signup {email}: {exc}")
users = gql(url, "{ _users { users { id email roles } } }", admin)["_users"]["users"]
by_email = {u["email"]: u for u in users}
for email, _, roles in DEMO_USERS:
user = by_email.get(email)
if not user:
print(f"!! {email} not found")
continue
role_list = ", ".join(q(r) for r in roles)
gql(url, f"mutation {{ _update_user(params: {{id: {q(user['id'])}, roles: [{role_list}]}}) {{ id }} }}", admin)
print(f"roles set {email}: {roles}")
if __name__ == "__main__":
main()
scripts/e2e_check.py
#!/usr/bin/env python3
"""Automates the trainee security checklist against the running stack."""
import sys
import httpx
from _common import claims_of, gql, load_env, login
API = "http://localhost:8000"
results: list[tuple[str, bool, str]] = []
def check(name: str, ok: bool, detail: str = "") -> None:
results.append((name, ok, detail))
print(f"{'PASS' if ok else 'FAIL'} {name} {detail}")
def api(path: str, token: str | None = None, show: bool = False) -> int:
headers = {"Authorization": f"Bearer {token}"} if token else {}
r = httpx.get(f"{API}{path}", headers=headers, timeout=15)
if show:
print(f" {path} -> {r.status_code} {r.text[:200]}")
return r.status_code
def main() -> int:
try:
httpx.get(f"{API}/health", timeout=5).raise_for_status()
except httpx.HTTPError as exc:
print(f"API not reachable at {API} ({exc.__class__.__name__}).\n"
"Check: docker compose ps | docker compose logs backend --tail 30\n"
"Or run it locally: pip install -r backend/requirements.txt && make api")
return 2
env = load_env()
url = env["AUTHORIZER_URL"].rstrip("/")
alice = login(url, "alice@demo.test", "Demo@12345")
check("login with valid credentials", bool(alice["access_token"]), f"roles={alice['user']['roles']}")
try:
login(url, "alice@demo.test", "Wrong@12345")
check("login with wrong password is rejected", False)
except RuntimeError as exc:
check("login with wrong password is rejected", True, str(exc))
tok = alice["access_token"]
claims = claims_of(tok)
print(" access-token role claims:", {k: v for k, v in claims.items() if "role" in k})
print(" access-token iss/aud:", claims.get("iss"), claims.get("aud"))
api("/api/me", tok, show=True) # what the API believes about this token
check("API without token -> 401", api("/api/me") == 401)
check("API with tampered token -> 401", api("/api/me", tok[:-4] + "AAAA") == 401)
check("API with valid token -> 200", api("/api/me", tok) == 200)
check("viewer calling admin API -> 403", api("/api/admin/audit", tok) == 403)
check("finance user, finance dataset -> 200", api("/api/datasets/fin-q3-revenue", tok, show=True) == 200)
check("finance user, research dataset -> 403", api("/api/datasets/res-trials-2026", tok) == 403)
admin = login(url, "admin@demo.test", "Demo@12345")
check("admin calling admin API -> 200", api("/api/admin/audit", admin["access_token"], show=True) == 200)
failed = [r for r in results if not r[1]]
print(f"\n{len(results) - len(failed)}/{len(results)} passed")
print("Manual: restart Authorizer (`docker compose restart authorizer`) and confirm the same token still works;"
" then test logout/revocation from the web app.")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())#!/usr/bin/env python3
"""Automates the trainee security checklist against the running stack."""
import sys
import httpx
from _common import claims_of, gql, load_env, login
API = "http://localhost:8000"
results: list[tuple[str, bool, str]] = []
def check(name: str, ok: bool, detail: str = "") -> None:
results.append((name, ok, detail))
print(f"{'PASS' if ok else 'FAIL'} {name} {detail}")
def api(path: str, token: str | None = None, show: bool = False) -> int:
headers = {"Authorization": f"Bearer {token}"} if token else {}
r = httpx.get(f"{API}{path}", headers=headers, timeout=15)
if show:
print(f" {path} -> {r.status_code} {r.text[:200]}")
return r.status_code
def main() -> int:
try:
httpx.get(f"{API}/health", timeout=5).raise_for_status()
except httpx.HTTPError as exc:
print(f"API not reachable at {API} ({exc.__class__.__name__}).\n"
"Check: docker compose ps | docker compose logs backend --tail 30\n"
"Or run it locally: pip install -r backend/requirements.txt && make api")
return 2
env = load_env()
url = env["AUTHORIZER_URL"].rstrip("/")
alice = login(url, "alice@demo.test", "Demo@12345")
check("login with valid credentials", bool(alice["access_token"]), f"roles={alice['user']['roles']}")
try:
login(url, "alice@demo.test", "Wrong@12345")
check("login with wrong password is rejected", False)
except RuntimeError as exc:
check("login with wrong password is rejected", True, str(exc))
tok = alice["access_token"]
claims = claims_of(tok)
print(" access-token role claims:", {k: v for k, v in claims.items() if "role" in k})
print(" access-token iss/aud:", claims.get("iss"), claims.get("aud"))
api("/api/me", tok, show=True) # what the API believes about this token
check("API without token -> 401", api("/api/me") == 401)
check("API with tampered token -> 401", api("/api/me", tok[:-4] + "AAAA") == 401)
check("API with valid token -> 200", api("/api/me", tok) == 200)
check("viewer calling admin API -> 403", api("/api/admin/audit", tok) == 403)
check("finance user, finance dataset -> 200", api("/api/datasets/fin-q3-revenue", tok, show=True) == 200)
check("finance user, research dataset -> 403", api("/api/datasets/res-trials-2026", tok) == 403)
admin = login(url, "admin@demo.test", "Demo@12345")
check("admin calling admin API -> 200", api("/api/admin/audit", admin["access_token"], show=True) == 200)
failed = [r for r in results if not r[1]]
print(f"\n{len(results) - len(failed)}/{len(results)} passed")
print("Manual: restart Authorizer (`docker compose restart authorizer`) and confirm the same token still works;"
" then test logout/revocation from the web app.")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())Step 4: the web app, an "access ledger"
I wanted the web app to feel like a record of decisions rather than a dashboard. Every request you send lands in a ledger with a big status stamp: green for allowed, brass for unauthenticated, red for denied. There are buttons for the things you want to try (finance dataset, research dataset, admin log, the RAG question, the export tool) and two buttons under "Break it on purpose": send no token, and send a tampered one.
A few choices worth explaining:
- The access token lives in memory only. On page load the app asks Authorizer for a fresh token using the session cookie, so a reload does not log you out and nothing sensitive sits in local storage.
- It calls the GraphQL API directly instead of an SDK. The official SDKs wrap the same calls. Going direct keeps the protocol visible and avoids version drift. Swap in
@authorizerdev/authorizer-jsonce the idea has landed. - Decoding the token in the browser is for display only. The panel that shows the claims is teaching material; the API never trusts what the browser thinks.
frontend/package.json, tsconfig.json and next.config.mjs
{
"name": "authorizer-demo-web",
"private": true,
"version": "1.0.0",
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000"
},
"dependencies": {
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.6.0"
}
}
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": ".",
"paths": { "@/*": ["./*"] },
"plugins": [{ "name": "next" }]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
/** @type {import('next').NextConfig} */
export default { reactStrictMode: true };{
"name": "authorizer-demo-web",
"private": true,
"version": "1.0.0",
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000"
},
"dependencies": {
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.6.0"
}
}
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": ".",
"paths": { "@/*": ["./*"] },
"plugins": [{ "name": "next" }]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
/** @type {import('next').NextConfig} */
export default { reactStrictMode: true };frontend/lib/authorizer.ts
// Thin client for Authorizer's GraphQL API.
//
// Trainee note: the official SDK (@authorizerdev/authorizer-js, or
// @authorizerdev/authorizer-react for ready-made forms) wraps these same calls.
// We call the API directly so the protocol is visible and there is no extra
// dependency to keep in step with the server version.
const AUTHORIZER_URL = (process.env.NEXT_PUBLIC_AUTHORIZER_URL ?? "http://localhost:8080").replace(/\/$/, "");
export type AuthUser = { id: string; email: string; roles: string[] };
export type AuthPayload = { access_token: string; expires_in?: number; user: AuthUser };
type RawAuth = { message?: string; access_token: string; id_token?: string; expires_in?: number; user?: AuthUser | null };
import { decodeClaims } from "./jwt";
// A JSON string literal is also a valid GraphQL string literal, so this is safe escaping.
const q = (value: string) => JSON.stringify(value);
async function gql<T>(query: string, accessToken?: string): Promise<T> {
const res = await fetch(`${AUTHORIZER_URL}/graphql`, {
method: "POST",
// "include" lets the browser send/receive Authorizer's HTTP-only session cookie.
credentials: "include",
headers: {
"Content-Type": "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
body: JSON.stringify({ query }),
});
const body = await res.json();
if (body.errors?.length) throw new Error(body.errors[0].message);
return body.data as T;
}
const AUTH_FIELDS = "message access_token id_token expires_in user { id email roles }";
/** Some Authorizer versions return `user: null`. The tokens always carry the facts. */
function normalise(raw: RawAuth, typedEmail = ""): AuthPayload {
if (raw.user) return { access_token: raw.access_token, expires_in: raw.expires_in, user: raw.user };
const access = decodeClaims(raw.access_token);
const id = raw.id_token ? decodeClaims(raw.id_token) : {};
const roles = (access.allowed_roles ?? access.roles ?? access.role ?? id.roles ?? []) as string[] | string;
return {
access_token: raw.access_token,
expires_in: raw.expires_in,
user: { id: String(access.sub ?? ""), email: String(id.email ?? access.email ?? typedEmail), roles: Array.isArray(roles) ? roles : [roles] },
};
}
export async function login(email: string, password: string): Promise<AuthPayload> {
const data = await gql<{ login: RawAuth }>(
`mutation { login(params: {email: ${q(email)}, password: ${q(password)}}) { ${AUTH_FIELDS} } }`,
);
if (!data.login.access_token) {
// The server withheld tokens (MFA step, unverified email, ...). Show its reason.
throw new Error(data.login.message ?? "Sign-in did not return a token");
}
return normalise(data.login, email);
}
export async function signup(email: string, password: string): Promise<AuthPayload> {
await gql(
`mutation { signup(params: {email: ${q(email)}, password: ${q(password)}, confirm_password: ${q(password)}}) { message } }`,
);
return login(email, password);
}
/** Uses the session cookie to get a fresh access token. Returns null if signed out. */
export async function restoreSession(): Promise<AuthPayload | null> {
try {
const data = await gql<{ session: RawAuth }>(`query { session { ${AUTH_FIELDS} } }`);
return data.session?.access_token ? normalise(data.session) : null;
} catch {
return null;
}
}
export async function logout(accessToken: string): Promise<void> {
await gql(`mutation { logout { message } }`, accessToken);
}// Thin client for Authorizer's GraphQL API.
//
// Trainee note: the official SDK (@authorizerdev/authorizer-js, or
// @authorizerdev/authorizer-react for ready-made forms) wraps these same calls.
// We call the API directly so the protocol is visible and there is no extra
// dependency to keep in step with the server version.
const AUTHORIZER_URL = (process.env.NEXT_PUBLIC_AUTHORIZER_URL ?? "http://localhost:8080").replace(/\/$/, "");
export type AuthUser = { id: string; email: string; roles: string[] };
export type AuthPayload = { access_token: string; expires_in?: number; user: AuthUser };
type RawAuth = { message?: string; access_token: string; id_token?: string; expires_in?: number; user?: AuthUser | null };
import { decodeClaims } from "./jwt";
// A JSON string literal is also a valid GraphQL string literal, so this is safe escaping.
const q = (value: string) => JSON.stringify(value);
async function gql<T>(query: string, accessToken?: string): Promise<T> {
const res = await fetch(`${AUTHORIZER_URL}/graphql`, {
method: "POST",
// "include" lets the browser send/receive Authorizer's HTTP-only session cookie.
credentials: "include",
headers: {
"Content-Type": "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
body: JSON.stringify({ query }),
});
const body = await res.json();
if (body.errors?.length) throw new Error(body.errors[0].message);
return body.data as T;
}
const AUTH_FIELDS = "message access_token id_token expires_in user { id email roles }";
/** Some Authorizer versions return `user: null`. The tokens always carry the facts. */
function normalise(raw: RawAuth, typedEmail = ""): AuthPayload {
if (raw.user) return { access_token: raw.access_token, expires_in: raw.expires_in, user: raw.user };
const access = decodeClaims(raw.access_token);
const id = raw.id_token ? decodeClaims(raw.id_token) : {};
const roles = (access.allowed_roles ?? access.roles ?? access.role ?? id.roles ?? []) as string[] | string;
return {
access_token: raw.access_token,
expires_in: raw.expires_in,
user: { id: String(access.sub ?? ""), email: String(id.email ?? access.email ?? typedEmail), roles: Array.isArray(roles) ? roles : [roles] },
};
}
export async function login(email: string, password: string): Promise<AuthPayload> {
const data = await gql<{ login: RawAuth }>(
`mutation { login(params: {email: ${q(email)}, password: ${q(password)}}) { ${AUTH_FIELDS} } }`,
);
if (!data.login.access_token) {
// The server withheld tokens (MFA step, unverified email, ...). Show its reason.
throw new Error(data.login.message ?? "Sign-in did not return a token");
}
return normalise(data.login, email);
}
export async function signup(email: string, password: string): Promise<AuthPayload> {
await gql(
`mutation { signup(params: {email: ${q(email)}, password: ${q(password)}, confirm_password: ${q(password)}}) { message } }`,
);
return login(email, password);
}
/** Uses the session cookie to get a fresh access token. Returns null if signed out. */
export async function restoreSession(): Promise<AuthPayload | null> {
try {
const data = await gql<{ session: RawAuth }>(`query { session { ${AUTH_FIELDS} } }`);
return data.session?.access_token ? normalise(data.session) : null;
} catch {
return null;
}
}
export async function logout(accessToken: string): Promise<void> {
await gql(`mutation { logout { message } }`, accessToken);
}frontend/lib/api.ts and frontend/lib/jwt.ts
const API_URL = (process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000").replace(/\/$/, "");
export type ApiResult = { status: number; ms: number; body: unknown };
export async function callApi(
path: string,
opts: { token?: string; method?: "GET" | "POST"; json?: unknown } = {},
): Promise<ApiResult> {
const started = performance.now();
try {
const res = await fetch(`${API_URL}${path}`, {
method: opts.method ?? "GET",
headers: {
...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}),
...(opts.json ? { "Content-Type": "application/json" } : {}),
},
body: opts.json ? JSON.stringify(opts.json) : undefined,
});
const text = await res.text();
let body: unknown = text;
try {
body = JSON.parse(text);
} catch {
/* non-JSON body */
}
return { status: res.status, ms: Math.round(performance.now() - started), body };
} catch (err) {
return { status: 0, ms: Math.round(performance.now() - started), body: { detail: `network error: ${String(err)}` } };
}
}
/** Decodes a JWT payload for DISPLAY only. The browser never verifies signatures
* for authorization; the API does that on every request. */
export function decodeClaims(token: string): Record<string, unknown> {
try {
const part = token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
const json = decodeURIComponent(
atob(part)
.split("")
.map((c) => "%" + c.charCodeAt(0).toString(16).padStart(2, "0"))
.join(""),
);
return JSON.parse(json);
} catch {
return {};
}
}
export function tamper(token: string): string {
return token.slice(0, -6) + (token.endsWith("AAAAAA") ? "BBBBBB" : "AAAAAA");
}const API_URL = (process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000").replace(/\/$/, "");
export type ApiResult = { status: number; ms: number; body: unknown };
export async function callApi(
path: string,
opts: { token?: string; method?: "GET" | "POST"; json?: unknown } = {},
): Promise<ApiResult> {
const started = performance.now();
try {
const res = await fetch(`${API_URL}${path}`, {
method: opts.method ?? "GET",
headers: {
...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}),
...(opts.json ? { "Content-Type": "application/json" } : {}),
},
body: opts.json ? JSON.stringify(opts.json) : undefined,
});
const text = await res.text();
let body: unknown = text;
try {
body = JSON.parse(text);
} catch {
/* non-JSON body */
}
return { status: res.status, ms: Math.round(performance.now() - started), body };
} catch (err) {
return { status: 0, ms: Math.round(performance.now() - started), body: { detail: `network error: ${String(err)}` } };
}
}
/** Decodes a JWT payload for DISPLAY only. The browser never verifies signatures
* for authorization; the API does that on every request. */
export function decodeClaims(token: string): Record<string, unknown> {
try {
const part = token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
const json = decodeURIComponent(
atob(part)
.split("")
.map((c) => "%" + c.charCodeAt(0).toString(16).padStart(2, "0"))
.join(""),
);
return JSON.parse(json);
} catch {
return {};
}
}
export function tamper(token: string): string {
return token.slice(0, -6) + (token.endsWith("AAAAAA") ? "BBBBBB" : "AAAAAA");
}frontend/app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Access ledger: Authorizer demo",
description: "See what an identity provider proves and what your API still has to decide.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,500;6..72,700&family=Instrument+Sans:wght@400;500;600&display=swap"
/>
</head>
<body>{children}</body>
</html>
);
}import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Access ledger: Authorizer demo",
description: "See what an identity provider proves and what your API still has to decide.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,500;6..72,700&family=Instrument+Sans:wght@400;500;600&display=swap"
/>
</head>
<body>{children}</body>
</html>
);
}frontend/app/page.tsx
"use client";
import { FormEvent, useCallback, useEffect, useState } from "react";
import { AuthPayload, login, logout, restoreSession, signup } from "@/lib/authorizer";
import { ApiResult, callApi } from "@/lib/api";
import { decodeClaims, tamper } from "@/lib/jwt";
type Entry = { id: number; label: string; request: string; result: ApiResult };
const DEMO_ACCOUNTS = [
{ email: "alice@demo.test", note: "finance" },
{ email: "bob@demo.test", note: "research" },
{ email: "carol@demo.test", note: "no division" },
{ email: "admin@demo.test", note: "admin" },
];
const outcome = (s: number) => (s >= 200 && s < 300 ? "allowed" : s === 401 ? "unauthenticated" : s === 403 ? "denied" : "other");
export default function Home() {
const [auth, setAuth] = useState<AuthPayload | null>(null);
const [booting, setBooting] = useState(true);
const [mode, setMode] = useState<"signin" | "signup">("signin");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [formError, setFormError] = useState("");
const [busy, setBusy] = useState(false);
const [entries, setEntries] = useState<Entry[]>([]);
const [question, setQuestion] = useState("trial results and revenue policy");
useEffect(() => {
restoreSession().then((s) => {
setAuth(s);
setBooting(false);
});
}, []);
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setFormError("");
try {
setAuth(mode === "signin" ? await login(email, password) : await signup(email, password));
setPassword("");
} catch (err) {
setFormError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setBusy(false);
}
}
async function signOut() {
if (auth) await logout(auth.access_token).catch(() => undefined);
setAuth(null);
setEntries([]);
}
async function refresh() {
const s = await restoreSession();
if (s) setAuth(s);
else setAuth(null);
}
const run = useCallback(
async (label: string, path: string, opts: { token?: string | null; method?: "GET" | "POST"; json?: unknown } = {}) => {
const token = opts.token === undefined ? auth?.access_token : opts.token ?? undefined;
const result = await callApi(path, { token, method: opts.method, json: opts.json });
const request = `${opts.method ?? "GET"} ${path}`;
setEntries((prev) => [{ id: Date.now() + Math.random(), label, request, result }, ...prev]);
},
[auth],
);
if (booting) return <main className="shell"><p className="muted">Checking for an existing sessionβ¦</p></main>;
if (!auth) {
return (
<main className="shell narrow">
<h1>Access ledger</h1>
<p className="lede">Sign in, then watch what the API lets through and what it refuses.</p>
<form className="card" onSubmit={submit}>
<h2>{mode === "signin" ? "Sign in" : "Create an account"}</h2>
<label>
Email
<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="username" />
</label>
<label>
Password
<input type="password" required value={password} onChange={(e) => setPassword(e.target.value)}
autoComplete={mode === "signin" ? "current-password" : "new-password"} />
</label>
{formError && <p className="error" role="alert">{formError}</p>}
<button className="primary" disabled={busy}>{busy ? "Workingβ¦" : mode === "signin" ? "Sign in" : "Create account"}</button>
<button type="button" className="link" onClick={() => setMode(mode === "signin" ? "signup" : "signin")}>
{mode === "signin" ? "Need an account? Create one" : "Have an account? Sign in"}
</button>
</form>
<div className="card">
<h2>Demo accounts</h2>
<p className="muted">Created by the seed script. Password for all: Demo@12345</p>
<div className="chips">
{DEMO_ACCOUNTS.map((a) => (
<button key={a.email} type="button" className="chip" onClick={() => { setMode("signin"); setEmail(a.email); setPassword("Demo@12345"); }}>
{a.email.split("@")[0]} <span className="muted">({a.note})</span>
</button>
))}
</div>
</div>
</main>
);
}
const claims = decodeClaims(auth.access_token);
const exp = typeof claims.exp === "number" ? new Date(claims.exp * 1000).toLocaleTimeString() : "unknown";
return (
<main className="shell wide">
<header className="top">
<h1>Access ledger</h1>
<button className="link" onClick={signOut}>Sign out</button>
</header>
<div className="grid">
<section className="left">
<div className="card badge">
<h2>{auth.user.email}</h2>
<p className="muted">Token expires at {exp}</p>
<div className="chips">
{auth.user.roles.map((r) => <span key={r} className={`role ${r.startsWith("div-") ? "div" : r}`}>{r}</span>)}
</div>
<button className="link" onClick={refresh}>Refresh token from session</button>
<details>
<summary>Decoded access token</summary>
<pre>{JSON.stringify(claims, null, 2)}</pre>
</details>
</div>
<div className="card">
<h2>Send a request</h2>
<div className="actions">
<button onClick={() => run("Who am I", "/api/me")}>Who am I</button>
<button onClick={() => run("My datasets", "/api/datasets")}>My datasets</button>
<button onClick={() => run("Finance dataset", "/api/datasets/fin-q3-revenue")}>Finance dataset</button>
<button onClick={() => run("Research dataset", "/api/datasets/res-trials-2026")}>Research dataset</button>
<button onClick={() => run("Audit log", "/api/admin/audit")}>Admin audit log</button>
<button onClick={() => run("Export tool", "/api/tools/export_dataset/invoke", { method: "POST", json: { dataset_id: "fin-q3-revenue" } })}>
Export dataset tool
</button>
</div>
<label>
Ask the knowledge base
<div className="inline">
<input value={question} onChange={(e) => setQuestion(e.target.value)} />
<button onClick={() => run("Knowledge base", "/api/rag/query", { method: "POST", json: { question } })}>Ask</button>
</div>
</label>
</div>
<div className="card">
<h2>Break it on purpose</h2>
<div className="actions">
<button onClick={() => run("No token", "/api/me", { token: null })}>No token</button>
<button onClick={() => run("Tampered token", "/api/me", { token: tamper(auth.access_token) })}>Tampered token</button>
</div>
</div>
</section>
<section className="right" aria-live="polite">
<div className="ledgerhead">
<h2>Ledger</h2>
{entries.length > 0 && <button className="link" onClick={() => setEntries([])}>Clear</button>}
</div>
{entries.length === 0 && <p className="muted">No requests yet. Pick one on the left to see what the API allows.</p>}
{entries.map((e) => (
<article key={e.id} className={`entry ${outcome(e.result.status)}`}>
<div className="stamp">
<strong>{e.result.status || "ERR"}</strong>
<span>{outcome(e.result.status)}</span>
</div>
<div className="detail">
<h3>{e.label}</h3>
<code>{e.request}</code>
<span className="muted"> {e.result.ms} ms</span>
<details open={e.result.status !== 200}>
<summary>Response</summary>
<pre>{JSON.stringify(e.result.body, null, 2)}</pre>
</details>
</div>
</article>
))}
</section>
</div>
</main>
);
}"use client";
import { FormEvent, useCallback, useEffect, useState } from "react";
import { AuthPayload, login, logout, restoreSession, signup } from "@/lib/authorizer";
import { ApiResult, callApi } from "@/lib/api";
import { decodeClaims, tamper } from "@/lib/jwt";
type Entry = { id: number; label: string; request: string; result: ApiResult };
const DEMO_ACCOUNTS = [
{ email: "alice@demo.test", note: "finance" },
{ email: "bob@demo.test", note: "research" },
{ email: "carol@demo.test", note: "no division" },
{ email: "admin@demo.test", note: "admin" },
];
const outcome = (s: number) => (s >= 200 && s < 300 ? "allowed" : s === 401 ? "unauthenticated" : s === 403 ? "denied" : "other");
export default function Home() {
const [auth, setAuth] = useState<AuthPayload | null>(null);
const [booting, setBooting] = useState(true);
const [mode, setMode] = useState<"signin" | "signup">("signin");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [formError, setFormError] = useState("");
const [busy, setBusy] = useState(false);
const [entries, setEntries] = useState<Entry[]>([]);
const [question, setQuestion] = useState("trial results and revenue policy");
useEffect(() => {
restoreSession().then((s) => {
setAuth(s);
setBooting(false);
});
}, []);
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setFormError("");
try {
setAuth(mode === "signin" ? await login(email, password) : await signup(email, password));
setPassword("");
} catch (err) {
setFormError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setBusy(false);
}
}
async function signOut() {
if (auth) await logout(auth.access_token).catch(() => undefined);
setAuth(null);
setEntries([]);
}
async function refresh() {
const s = await restoreSession();
if (s) setAuth(s);
else setAuth(null);
}
const run = useCallback(
async (label: string, path: string, opts: { token?: string | null; method?: "GET" | "POST"; json?: unknown } = {}) => {
const token = opts.token === undefined ? auth?.access_token : opts.token ?? undefined;
const result = await callApi(path, { token, method: opts.method, json: opts.json });
const request = `${opts.method ?? "GET"} ${path}`;
setEntries((prev) => [{ id: Date.now() + Math.random(), label, request, result }, ...prev]);
},
[auth],
);
if (booting) return <main className="shell"><p className="muted">Checking for an existing sessionβ¦</p></main>;
if (!auth) {
return (
<main className="shell narrow">
<h1>Access ledger</h1>
<p className="lede">Sign in, then watch what the API lets through and what it refuses.</p>
<form className="card" onSubmit={submit}>
<h2>{mode === "signin" ? "Sign in" : "Create an account"}</h2>
<label>
Email
<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="username" />
</label>
<label>
Password
<input type="password" required value={password} onChange={(e) => setPassword(e.target.value)}
autoComplete={mode === "signin" ? "current-password" : "new-password"} />
</label>
{formError && <p className="error" role="alert">{formError}</p>}
<button className="primary" disabled={busy}>{busy ? "Workingβ¦" : mode === "signin" ? "Sign in" : "Create account"}</button>
<button type="button" className="link" onClick={() => setMode(mode === "signin" ? "signup" : "signin")}>
{mode === "signin" ? "Need an account? Create one" : "Have an account? Sign in"}
</button>
</form>
<div className="card">
<h2>Demo accounts</h2>
<p className="muted">Created by the seed script. Password for all: Demo@12345</p>
<div className="chips">
{DEMO_ACCOUNTS.map((a) => (
<button key={a.email} type="button" className="chip" onClick={() => { setMode("signin"); setEmail(a.email); setPassword("Demo@12345"); }}>
{a.email.split("@")[0]} <span className="muted">({a.note})</span>
</button>
))}
</div>
</div>
</main>
);
}
const claims = decodeClaims(auth.access_token);
const exp = typeof claims.exp === "number" ? new Date(claims.exp * 1000).toLocaleTimeString() : "unknown";
return (
<main className="shell wide">
<header className="top">
<h1>Access ledger</h1>
<button className="link" onClick={signOut}>Sign out</button>
</header>
<div className="grid">
<section className="left">
<div className="card badge">
<h2>{auth.user.email}</h2>
<p className="muted">Token expires at {exp}</p>
<div className="chips">
{auth.user.roles.map((r) => <span key={r} className={`role ${r.startsWith("div-") ? "div" : r}`}>{r}</span>)}
</div>
<button className="link" onClick={refresh}>Refresh token from session</button>
<details>
<summary>Decoded access token</summary>
<pre>{JSON.stringify(claims, null, 2)}</pre>
</details>
</div>
<div className="card">
<h2>Send a request</h2>
<div className="actions">
<button onClick={() => run("Who am I", "/api/me")}>Who am I</button>
<button onClick={() => run("My datasets", "/api/datasets")}>My datasets</button>
<button onClick={() => run("Finance dataset", "/api/datasets/fin-q3-revenue")}>Finance dataset</button>
<button onClick={() => run("Research dataset", "/api/datasets/res-trials-2026")}>Research dataset</button>
<button onClick={() => run("Audit log", "/api/admin/audit")}>Admin audit log</button>
<button onClick={() => run("Export tool", "/api/tools/export_dataset/invoke", { method: "POST", json: { dataset_id: "fin-q3-revenue" } })}>
Export dataset tool
</button>
</div>
<label>
Ask the knowledge base
<div className="inline">
<input value={question} onChange={(e) => setQuestion(e.target.value)} />
<button onClick={() => run("Knowledge base", "/api/rag/query", { method: "POST", json: { question } })}>Ask</button>
</div>
</label>
</div>
<div className="card">
<h2>Break it on purpose</h2>
<div className="actions">
<button onClick={() => run("No token", "/api/me", { token: null })}>No token</button>
<button onClick={() => run("Tampered token", "/api/me", { token: tamper(auth.access_token) })}>Tampered token</button>
</div>
</div>
</section>
<section className="right" aria-live="polite">
<div className="ledgerhead">
<h2>Ledger</h2>
{entries.length > 0 && <button className="link" onClick={() => setEntries([])}>Clear</button>}
</div>
{entries.length === 0 && <p className="muted">No requests yet. Pick one on the left to see what the API allows.</p>}
{entries.map((e) => (
<article key={e.id} className={`entry ${outcome(e.result.status)}`}>
<div className="stamp">
<strong>{e.result.status || "ERR"}</strong>
<span>{outcome(e.result.status)}</span>
</div>
<div className="detail">
<h3>{e.label}</h3>
<code>{e.request}</code>
<span className="muted"> {e.result.ms} ms</span>
<details open={e.result.status !== 200}>
<summary>Response</summary>
<pre>{JSON.stringify(e.result.body, null, 2)}</pre>
</details>
</div>
</article>
))}
</section>
</div>
</main>
);
}frontend/app/globals.css
:root {
--paper: #e9eeeb;
--card: #f7f9f7;
--ink: #10201f;
--muted: #566664;
--pine: #0f3d3a;
--brass: #a87a1f;
--deny: #b3261e;
--allow: #1c7c4d;
--line: #c9d3cf;
--serif: "Newsreader", Georgia, "Times New Roman", serif;
--sans: "Instrument Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
* { box-sizing: border-box; }
html { color-scheme: light; }
body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--sans); font-size: 16px; line-height: 1.5; }
h1, h2, h3 { font-family: var(--serif); margin: 0; line-height: 1.15; }
h1 { font-size: 2.25rem; letter-spacing: -0.01em; }
h2 { font-size: 1.25rem; margin-bottom: 0.6rem; }
h3 { font-size: 1.05rem; }
.shell { padding: 2rem 1.25rem 4rem; margin: 0 auto; }
.shell.narrow { max-width: 30rem; }
.shell.wide { max-width: 76rem; }
.lede { color: var(--muted); max-width: 34rem; margin: 0.5rem 0 1.5rem; }
.muted { color: var(--muted); font-size: 0.9rem; }
.top { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.5rem; }
.grid { display: grid; gap: 1.5rem; grid-template-columns: 1fr; }
@media (min-width: 62rem) { .grid { grid-template-columns: minmax(20rem, 26rem) 1fr; align-items: start; } }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 1.1rem 1.2rem; margin-bottom: 1rem; display: grid; gap: 0.75rem; }
label { display: grid; gap: 0.3rem; font-weight: 500; }
input { font: inherit; padding: 0.55rem 0.7rem; border: 1px solid #9fb0ab; border-radius: 4px; background: #fff; color: var(--ink); width: 100%; }
button { font: inherit; cursor: pointer; padding: 0.5rem 0.85rem; border: 1px solid var(--pine); background: transparent; color: var(--pine); border-radius: 4px; }
button:hover { background: #dce6e2; }
button.primary { background: var(--pine); color: #fff; }
button.primary:hover { background: #0b2f2d; }
button:disabled { opacity: 0.6; cursor: progress; }
button.link { border: 0; padding: 0; text-align: left; text-decoration: underline; color: var(--pine); background: none; justify-self: start; }
:focus-visible { outline: 3px solid var(--brass); outline-offset: 2px; }
.error { color: var(--deny); margin: 0; }
.chips, .actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.chip { border-radius: 999px; }
.inline { display: flex; gap: 0.5rem; }
.role { padding: 0.15rem 0.65rem; border-radius: 999px; font-size: 0.85rem; font-weight: 600; background: #dfe7e4; color: var(--pine); }
.role.admin { background: var(--pine); color: #fff; }
.role.div { background: #f0e2bf; color: #5c4210; }
.badge { border-left: 6px solid var(--brass); }
details summary { cursor: pointer; color: var(--pine); font-weight: 500; }
pre { background: #fff; border: 1px solid var(--line); border-radius: 4px; padding: 0.7rem; overflow-x: auto; font-size: 0.82rem; margin: 0.5rem 0 0; }
code { font-size: 0.85rem; }
.ledgerhead { display: flex; justify-content: space-between; align-items: baseline; }
.entry { display: grid; grid-template-columns: 6.5rem 1fr; border: 1px solid var(--line); background: var(--card); border-radius: 6px; margin-bottom: 0.75rem; overflow: hidden; }
.stamp { display: grid; align-content: center; justify-items: center; padding: 0.75rem 0.4rem; color: #fff; }
.stamp strong { font-family: var(--serif); font-size: 2rem; line-height: 1; }
.stamp span { font-size: 0.78rem; }
.entry.allowed .stamp { background: var(--allow); }
.entry.unauthenticated .stamp { background: var(--brass); }
.entry.denied .stamp { background: var(--deny); }
.entry.other .stamp { background: var(--muted); }
.detail { padding: 0.75rem 1rem; display: grid; gap: 0.25rem; min-width: 0; }
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; transition: none !important; } }:root {
--paper: #e9eeeb;
--card: #f7f9f7;
--ink: #10201f;
--muted: #566664;
--pine: #0f3d3a;
--brass: #a87a1f;
--deny: #b3261e;
--allow: #1c7c4d;
--line: #c9d3cf;
--serif: "Newsreader", Georgia, "Times New Roman", serif;
--sans: "Instrument Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
* { box-sizing: border-box; }
html { color-scheme: light; }
body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--sans); font-size: 16px; line-height: 1.5; }
h1, h2, h3 { font-family: var(--serif); margin: 0; line-height: 1.15; }
h1 { font-size: 2.25rem; letter-spacing: -0.01em; }
h2 { font-size: 1.25rem; margin-bottom: 0.6rem; }
h3 { font-size: 1.05rem; }
.shell { padding: 2rem 1.25rem 4rem; margin: 0 auto; }
.shell.narrow { max-width: 30rem; }
.shell.wide { max-width: 76rem; }
.lede { color: var(--muted); max-width: 34rem; margin: 0.5rem 0 1.5rem; }
.muted { color: var(--muted); font-size: 0.9rem; }
.top { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.5rem; }
.grid { display: grid; gap: 1.5rem; grid-template-columns: 1fr; }
@media (min-width: 62rem) { .grid { grid-template-columns: minmax(20rem, 26rem) 1fr; align-items: start; } }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 1.1rem 1.2rem; margin-bottom: 1rem; display: grid; gap: 0.75rem; }
label { display: grid; gap: 0.3rem; font-weight: 500; }
input { font: inherit; padding: 0.55rem 0.7rem; border: 1px solid #9fb0ab; border-radius: 4px; background: #fff; color: var(--ink); width: 100%; }
button { font: inherit; cursor: pointer; padding: 0.5rem 0.85rem; border: 1px solid var(--pine); background: transparent; color: var(--pine); border-radius: 4px; }
button:hover { background: #dce6e2; }
button.primary { background: var(--pine); color: #fff; }
button.primary:hover { background: #0b2f2d; }
button:disabled { opacity: 0.6; cursor: progress; }
button.link { border: 0; padding: 0; text-align: left; text-decoration: underline; color: var(--pine); background: none; justify-self: start; }
:focus-visible { outline: 3px solid var(--brass); outline-offset: 2px; }
.error { color: var(--deny); margin: 0; }
.chips, .actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.chip { border-radius: 999px; }
.inline { display: flex; gap: 0.5rem; }
.role { padding: 0.15rem 0.65rem; border-radius: 999px; font-size: 0.85rem; font-weight: 600; background: #dfe7e4; color: var(--pine); }
.role.admin { background: var(--pine); color: #fff; }
.role.div { background: #f0e2bf; color: #5c4210; }
.badge { border-left: 6px solid var(--brass); }
details summary { cursor: pointer; color: var(--pine); font-weight: 500; }
pre { background: #fff; border: 1px solid var(--line); border-radius: 4px; padding: 0.7rem; overflow-x: auto; font-size: 0.82rem; margin: 0.5rem 0 0; }
code { font-size: 0.85rem; }
.ledgerhead { display: flex; justify-content: space-between; align-items: baseline; }
.entry { display: grid; grid-template-columns: 6.5rem 1fr; border: 1px solid var(--line); background: var(--card); border-radius: 6px; margin-bottom: 0.75rem; overflow: hidden; }
.stamp { display: grid; align-content: center; justify-items: center; padding: 0.75rem 0.4rem; color: #fff; }
.stamp strong { font-family: var(--serif); font-size: 2rem; line-height: 1; }
.stamp span { font-size: 0.78rem; }
.entry.allowed .stamp { background: var(--allow); }
.entry.unauthenticated .stamp { background: var(--brass); }
.entry.denied .stamp { background: var(--deny); }
.entry.other .stamp { background: var(--muted); }
.detail { padding: 0.75rem 1rem; display: grid; gap: 0.25rem; min-width: 0; }
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; transition: none !important; } }Start it with cd frontend && npm install && npm run dev and open [http://localhost:3000](http://localhost:3000.).
Step 5: a Flet client, because Python people deserve a UI too
Many of the readers think in Python. Flet lets them build a real desktop (and mobile) UI without learning a new language, so the client is a Flet app that does the same job as the web ledger.
It supports two ways to sign in, and the difference is a good lesson on its own:
- Password sign-in. The app shows its own form and calls Authorizer's login. It is simple and works everywhere, but the app sees the password.
- Browser sign-in with PKCE. The app opens the system browser, the user signs in on Authorizer's page, and the app receives a one-time code on a loopback address (
127.0.0.1) and exchanges it for tokens using a secret it generated itself, the PKCE verifier. The app never sees the password and holds no client secret. This is the pattern the OAuth standards recommend for native apps.
All the Authorizer-specific logic lives in auth_client.py, with no UI code in it, so it is unit-tested on its own: PKCE math, state checking against forged callbacks, escaping of hostile passwords, and more.
One honest check: the browser flow uses a loopback redirect, so it works in desktop builds. Mobile builds work with the password flow. And whether your Authorizer version accepts a token exchange from a public client with no secret is something to confirm on your own instance. If it insists on a secret, do not put one in the app. Use the password flow or a small backend-for-frontend.
flet_app/requirements.txt, pyproject.toml
flet>=1.0,<2
httpx>=0.27
pytest>=8.0
# Needed by `flet build apk|ipa|windows|macos|linux`. For `flet run` it is optional.
[project]
name = "authorizer-demo-flet"
version = "1.0.0"
requires-python = ">=3.10"
dependencies = ["flet>=1.0,<2", "httpx>=0.27"]
[tool.flet]
product = "Authorizer demo"flet>=1.0,<2
httpx>=0.27
pytest>=8.0
# Needed by `flet build apk|ipa|windows|macos|linux`. For `flet run` it is optional.
[project]
name = "authorizer-demo-flet"
version = "1.0.0"
requires-python = ">=3.10"
dependencies = ["flet>=1.0,<2", "httpx>=0.27"]
[tool.flet]
product = "Authorizer demo"flet_app/config.py
"""Runtime settings. Environment variables win; the repo's root .env is a dev fallback."""
import os
import pathlib
from dataclasses import dataclass
def _read_root_env() -> dict:
path = pathlib.Path(__file__).resolve().parent.parent / ".env"
out: dict = {}
if path.exists():
for line in path.read_text().splitlines():
if line.strip() and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
out[k.strip()] = v.strip()
return out
@dataclass(frozen=True)
class Config:
authorizer_url: str
client_id: str
api_url: str
redirect_port: int = 8765
scopes: str = "openid profile email"
@property
def redirect_uri(self) -> str:
# Loopback redirect (RFC 8252). Desktop only; see README for mobile.
return f"http://127.0.0.1:{self.redirect_port}/callback"
def load_config() -> Config:
env = _read_root_env()
get = lambda k, d: os.environ.get(k) or env.get(k) or d
return Config(
authorizer_url=get("AUTHORIZER_URL", "http://localhost:8080").rstrip("/"),
client_id=get("CLIENT_ID", "demo-client-id"),
api_url=get("API_URL", "http://localhost:8000").rstrip("/"),
)"""Runtime settings. Environment variables win; the repo's root .env is a dev fallback."""
import os
import pathlib
from dataclasses import dataclass
def _read_root_env() -> dict:
path = pathlib.Path(__file__).resolve().parent.parent / ".env"
out: dict = {}
if path.exists():
for line in path.read_text().splitlines():
if line.strip() and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
out[k.strip()] = v.strip()
return out
@dataclass(frozen=True)
class Config:
authorizer_url: str
client_id: str
api_url: str
redirect_port: int = 8765
scopes: str = "openid profile email"
@property
def redirect_uri(self) -> str:
# Loopback redirect (RFC 8252). Desktop only; see README for mobile.
return f"http://127.0.0.1:{self.redirect_port}/callback"
def load_config() -> Config:
env = _read_root_env()
get = lambda k, d: os.environ.get(k) or env.get(k) or d
return Config(
authorizer_url=get("AUTHORIZER_URL", "http://localhost:8080").rstrip("/"),
client_id=get("CLIENT_ID", "demo-client-id"),
api_url=get("API_URL", "http://localhost:8000").rstrip("/"),
)flet_app/auth_client.py
"""Everything Authorizer-specific for the Flet app. No UI code here, so it is unit-testable.Two ways to sign in:
1. login_with_password - the app shows its own form and calls Authorizer's GraphQL `login`.
Simplest. Works on every platform. The app sees the password.
2. login_with_browser - Authorization Code + PKCE in the system browser (RFC 7636 / 8252).
The app never sees the password and holds no client secret.
Uses a loopback redirect, so it works on desktop builds.
"""
import base64
import hashlib
import json
import secrets
import threading
import webbrowser
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlencode, urlparse
import httpx
from config import Config
class AuthError(Exception):
pass
@dataclass
class Session:
access_token: str
email: str
roles: list[str]
id_token: str | None = None
refresh_token: str | None = None
# ---------- helpers -------------------------------------------------------
def _b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def decode_claims(token: str) -> dict:
"""DISPLAY only. The API verifies signatures; the client never should decide access."""
try:
payload = token.split(".")[1]
payload += "=" * (-len(payload) % 4)
return json.loads(base64.urlsafe_b64decode(payload))
except Exception:
return {}
def _roles_from(claims: dict) -> list[str]:
raw = claims.get("allowed_roles") or claims.get("roles") or claims.get("role") or []
return [raw] if isinstance(raw, str) else list(raw)
def _origin(cfg: Config) -> dict:
"""Authorizer >= 2.3.0 rejects state-changing requests unless Origin is in --allowed-origins
(CSRF guard). A browser sends one for you; a native app must add it. We use the loopback
origin, which docker-compose.yml already allows."""
return {"Origin": f"http://127.0.0.1:{cfg.redirect_port}"}
def _q(value: str) -> str:
return json.dumps(value) # a JSON string is a valid GraphQL string literal
# ---------- 1. password login --------------------------------------------
def login_with_password(cfg: Config, email: str, password: str, client: httpx.Client | None = None) -> Session:
query = (
f"mutation {{ login(params: {{email: {_q(email)}, password: {_q(password)}}}) "
"{ message access_token id_token refresh_token user { email roles } } }"
)
http = client or httpx.Client(timeout=15)
try:
body = http.post(f"{cfg.authorizer_url}/graphql", json={"query": query}, headers=_origin(cfg)).json()
except httpx.HTTPError as exc:
raise AuthError(f"cannot reach Authorizer at {cfg.authorizer_url}: {exc}") from exc
if body.get("errors"):
raise AuthError(body["errors"][0]["message"])
data = body["data"]["login"]
if not data.get("access_token"):
raise AuthError(f"no token issued (MFA or verification step pending?): {data.get('message')}")
user = data.get("user") # some Authorizer versions return null here; the tokens still carry the facts
return Session(
access_token=data["access_token"],
email=(user or {}).get("email") or decode_claims(data.get("id_token") or "").get("email") or email,
roles=(user or {}).get("roles") or _roles_from(decode_claims(data["access_token"])),
id_token=data.get("id_token"),
refresh_token=data.get("refresh_token"),
)
# ---------- 2. browser login (Authorization Code + PKCE) -------------------
def pkce_pair() -> tuple[str, str]:
"""Returns (code_verifier, code_challenge) using the S256 method."""
verifier = _b64url(secrets.token_bytes(48)) # 64 chars, within RFC 7636's 43-128
challenge = _b64url(hashlib.sha256(verifier.encode()).digest())
return verifier, challenge
def discover(cfg: Config, client: httpx.Client | None = None) -> dict:
"""Read endpoints from OIDC discovery, falling back to Authorizer's defaults."""
http = client or httpx.Client(timeout=10)
try:
meta = http.get(f"{cfg.authorizer_url}/.well-known/openid-configuration").json()
return {"authorization_endpoint": meta["authorization_endpoint"], "token_endpoint": meta["token_endpoint"]}
except Exception:
return {
"authorization_endpoint": f"{cfg.authorizer_url}/authorize",
"token_endpoint": f"{cfg.authorizer_url}/oauth/token",
}
def build_authorize_url(cfg: Config, endpoint: str, state: str, challenge: str) -> str:
params = {
"client_id": cfg.client_id,
"response_type": "code",
"redirect_uri": cfg.redirect_uri,
"scope": cfg.scopes,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
return f"{endpoint}?{urlencode(params)}"
def wait_for_callback(port: int, expected_state: str, timeout: float = 180) -> str:
"""Listen on 127.0.0.1 for the redirect and return the authorization code."""
result: dict = {}
class Handler(BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802
qs = parse_qs(urlparse(self.path).query)
result.update({k: v[0] for k, v in qs.items()})
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(b"<h3>Signed in. You can close this tab and return to the app.</h3>")
def log_message(self, *args): # keep the console quiet
pass
server = HTTPServer(("127.0.0.1", port), Handler)
server.timeout = timeout
try:
server.handle_request()
finally:
server.server_close()
if not result:
raise AuthError("timed out waiting for the browser sign-in")
if result.get("error"):
raise AuthError(f"{result['error']}: {result.get('error_description', '')}")
if result.get("state") != expected_state:
raise AuthError("state mismatch (possible CSRF); sign-in aborted")
if "code" not in result:
raise AuthError("no authorization code returned")
return result["code"]
def exchange_code(cfg: Config, token_endpoint: str, code: str, verifier: str, client: httpx.Client | None = None) -> Session:
"""No client_secret here on purpose: a mobile/desktop app is a public client."""
http = client or httpx.Client(timeout=15)
resp = http.post(
token_endpoint,
headers=_origin(cfg),
data={
"grant_type": "authorization_code",
"client_id": cfg.client_id,
"code": code,
"code_verifier": verifier,
"redirect_uri": cfg.redirect_uri,
},
)
body = resp.json()
if resp.status_code >= 400 or "access_token" not in body:
raise AuthError(body.get("error_description") or body.get("error") or f"token endpoint returned {resp.status_code}")
claims = decode_claims(body.get("id_token") or body["access_token"])
return Session(
access_token=body["access_token"],
email=claims.get("email", "unknown"),
roles=_roles_from(decode_claims(body["access_token"])),
id_token=body.get("id_token"),
refresh_token=body.get("refresh_token"),
)
def login_with_browser(cfg: Config) -> Session:
verifier, challenge = pkce_pair()
state = secrets.token_urlsafe(16)
endpoints = discover(cfg)
url = build_authorize_url(cfg, endpoints["authorization_endpoint"], state, challenge)
box: dict = {}
listener = threading.Thread(target=lambda: box.update(code=_safe_wait(cfg.redirect_port, state)), daemon=True)
listener.start()
webbrowser.open(url)
listener.join()
outcome = box["code"]
if isinstance(outcome, Exception):
raise outcome
return exchange_code(cfg, endpoints["token_endpoint"], outcome, verifier)
def _safe_wait(port: int, state: str):
try:
return wait_for_callback(port, state)
except Exception as exc: # returned to the caller thread
return exc
"""Everything Authorizer-specific for the Flet app. No UI code here, so it is unit-testable.Two ways to sign in:
1. login_with_password - the app shows its own form and calls Authorizer's GraphQL `login`.
Simplest. Works on every platform. The app sees the password.
2. login_with_browser - Authorization Code + PKCE in the system browser (RFC 7636 / 8252).
The app never sees the password and holds no client secret.
Uses a loopback redirect, so it works on desktop builds.
"""
import base64
import hashlib
import json
import secrets
import threading
import webbrowser
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlencode, urlparse
import httpx
from config import Config
class AuthError(Exception):
pass
@dataclass
class Session:
access_token: str
email: str
roles: list[str]
id_token: str | None = None
refresh_token: str | None = None
# ---------- helpers -------------------------------------------------------
def _b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def decode_claims(token: str) -> dict:
"""DISPLAY only. The API verifies signatures; the client never should decide access."""
try:
payload = token.split(".")[1]
payload += "=" * (-len(payload) % 4)
return json.loads(base64.urlsafe_b64decode(payload))
except Exception:
return {}
def _roles_from(claims: dict) -> list[str]:
raw = claims.get("allowed_roles") or claims.get("roles") or claims.get("role") or []
return [raw] if isinstance(raw, str) else list(raw)
def _origin(cfg: Config) -> dict:
"""Authorizer >= 2.3.0 rejects state-changing requests unless Origin is in --allowed-origins
(CSRF guard). A browser sends one for you; a native app must add it. We use the loopback
origin, which docker-compose.yml already allows."""
return {"Origin": f"http://127.0.0.1:{cfg.redirect_port}"}
def _q(value: str) -> str:
return json.dumps(value) # a JSON string is a valid GraphQL string literal
# ---------- 1. password login --------------------------------------------
def login_with_password(cfg: Config, email: str, password: str, client: httpx.Client | None = None) -> Session:
query = (
f"mutation {{ login(params: {{email: {_q(email)}, password: {_q(password)}}}) "
"{ message access_token id_token refresh_token user { email roles } } }"
)
http = client or httpx.Client(timeout=15)
try:
body = http.post(f"{cfg.authorizer_url}/graphql", json={"query": query}, headers=_origin(cfg)).json()
except httpx.HTTPError as exc:
raise AuthError(f"cannot reach Authorizer at {cfg.authorizer_url}: {exc}") from exc
if body.get("errors"):
raise AuthError(body["errors"][0]["message"])
data = body["data"]["login"]
if not data.get("access_token"):
raise AuthError(f"no token issued (MFA or verification step pending?): {data.get('message')}")
user = data.get("user") # some Authorizer versions return null here; the tokens still carry the facts
return Session(
access_token=data["access_token"],
email=(user or {}).get("email") or decode_claims(data.get("id_token") or "").get("email") or email,
roles=(user or {}).get("roles") or _roles_from(decode_claims(data["access_token"])),
id_token=data.get("id_token"),
refresh_token=data.get("refresh_token"),
)
# ---------- 2. browser login (Authorization Code + PKCE) -------------------
def pkce_pair() -> tuple[str, str]:
"""Returns (code_verifier, code_challenge) using the S256 method."""
verifier = _b64url(secrets.token_bytes(48)) # 64 chars, within RFC 7636's 43-128
challenge = _b64url(hashlib.sha256(verifier.encode()).digest())
return verifier, challenge
def discover(cfg: Config, client: httpx.Client | None = None) -> dict:
"""Read endpoints from OIDC discovery, falling back to Authorizer's defaults."""
http = client or httpx.Client(timeout=10)
try:
meta = http.get(f"{cfg.authorizer_url}/.well-known/openid-configuration").json()
return {"authorization_endpoint": meta["authorization_endpoint"], "token_endpoint": meta["token_endpoint"]}
except Exception:
return {
"authorization_endpoint": f"{cfg.authorizer_url}/authorize",
"token_endpoint": f"{cfg.authorizer_url}/oauth/token",
}
def build_authorize_url(cfg: Config, endpoint: str, state: str, challenge: str) -> str:
params = {
"client_id": cfg.client_id,
"response_type": "code",
"redirect_uri": cfg.redirect_uri,
"scope": cfg.scopes,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
return f"{endpoint}?{urlencode(params)}"
def wait_for_callback(port: int, expected_state: str, timeout: float = 180) -> str:
"""Listen on 127.0.0.1 for the redirect and return the authorization code."""
result: dict = {}
class Handler(BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802
qs = parse_qs(urlparse(self.path).query)
result.update({k: v[0] for k, v in qs.items()})
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(b"<h3>Signed in. You can close this tab and return to the app.</h3>")
def log_message(self, *args): # keep the console quiet
pass
server = HTTPServer(("127.0.0.1", port), Handler)
server.timeout = timeout
try:
server.handle_request()
finally:
server.server_close()
if not result:
raise AuthError("timed out waiting for the browser sign-in")
if result.get("error"):
raise AuthError(f"{result['error']}: {result.get('error_description', '')}")
if result.get("state") != expected_state:
raise AuthError("state mismatch (possible CSRF); sign-in aborted")
if "code" not in result:
raise AuthError("no authorization code returned")
return result["code"]
def exchange_code(cfg: Config, token_endpoint: str, code: str, verifier: str, client: httpx.Client | None = None) -> Session:
"""No client_secret here on purpose: a mobile/desktop app is a public client."""
http = client or httpx.Client(timeout=15)
resp = http.post(
token_endpoint,
headers=_origin(cfg),
data={
"grant_type": "authorization_code",
"client_id": cfg.client_id,
"code": code,
"code_verifier": verifier,
"redirect_uri": cfg.redirect_uri,
},
)
body = resp.json()
if resp.status_code >= 400 or "access_token" not in body:
raise AuthError(body.get("error_description") or body.get("error") or f"token endpoint returned {resp.status_code}")
claims = decode_claims(body.get("id_token") or body["access_token"])
return Session(
access_token=body["access_token"],
email=claims.get("email", "unknown"),
roles=_roles_from(decode_claims(body["access_token"])),
id_token=body.get("id_token"),
refresh_token=body.get("refresh_token"),
)
def login_with_browser(cfg: Config) -> Session:
verifier, challenge = pkce_pair()
state = secrets.token_urlsafe(16)
endpoints = discover(cfg)
url = build_authorize_url(cfg, endpoints["authorization_endpoint"], state, challenge)
box: dict = {}
listener = threading.Thread(target=lambda: box.update(code=_safe_wait(cfg.redirect_port, state)), daemon=True)
listener.start()
webbrowser.open(url)
listener.join()
outcome = box["code"]
if isinstance(outcome, Exception):
raise outcome
return exchange_code(cfg, endpoints["token_endpoint"], outcome, verifier)
def _safe_wait(port: int, state: str):
try:
return wait_for_callback(port, state)
except Exception as exc: # returned to the caller thread
return exc
flet_app/api_client.py
from dataclasses import dataclass
import httpx
@dataclass
class ApiResult:
status: int
body: object
ms: int
def call_api(base: str, path: str, token: str | None = None, method: str = "GET", json: dict | None = None) -> ApiResult:
import time
headers = {"Authorization": f"Bearer {token}"} if token else {}
started = time.perf_counter()
try:
r = httpx.request(method, f"{base}{path}", headers=headers, json=json, timeout=15)
try:
body: object = r.json()
except ValueError:
body = r.text
return ApiResult(r.status_code, body, int((time.perf_counter() - started) * 1000))
except httpx.HTTPError as exc:
return ApiResult(0, {"detail": f"network error: {exc}"}, int((time.perf_counter() - started) * 1000))from dataclasses import dataclass
import httpx
@dataclass
class ApiResult:
status: int
body: object
ms: int
def call_api(base: str, path: str, token: str | None = None, method: str = "GET", json: dict | None = None) -> ApiResult:
import time
headers = {"Authorization": f"Bearer {token}"} if token else {}
started = time.perf_counter()
try:
r = httpx.request(method, f"{base}{path}", headers=headers, json=json, timeout=15)
try:
body: object = r.json()
except ValueError:
body = r.text
return ApiResult(r.status_code, body, int((time.perf_counter() - started) * 1000))
except httpx.HTTPError as exc:
return ApiResult(0, {"detail": f"network error: {exc}"}, int((time.perf_counter() - started) * 1000))flet_app/main.py
"""Access ledger: the Flet client for the Authorizer demo.Run: flet run main.py (desktop window)
flet run --web main.py (browser; password sign-in only)
"""
import json
import flet as ft
from api_client import ApiResult, call_api
from auth_client import AuthError, Session, decode_claims, login_with_browser, login_with_password
from config import Config, load_config
PAPER, CARD, INK, MUTED = "#e9eeeb", "#f7f9f7", "#10201f", "#566664"
PINE, BRASS, DENY, ALLOW = "#0f3d3a", "#a87a1f", "#b3261e", "#1c7c4d"
DEMO = [("alice@demo.test", "finance"), ("bob@demo.test", "research"), ("carol@demo.test", "no division"), ("admin@demo.test", "admin")]
def status_colour(status: int) -> str:
return ALLOW if 200 <= status < 300 else DENY if status == 403 else BRASS if status == 401 else MUTED
def status_word(status: int) -> str:
return "allowed" if 200 <= status < 300 else "denied" if status == 403 else "unauthenticated" if status == 401 else "error"
def card(*controls: ft.Control) -> ft.Container:
return ft.Container(
content=ft.Column(list(controls), spacing=10, tight=True),
bgcolor=CARD,
border=ft.Border.all(1, "#c9d3cf"),
border_radius=6,
padding=16,
)
def role_chip(role: str) -> ft.Container:
admin, division = role == "admin", role.startswith("div-")
return ft.Container(
content=ft.Text(role, size=13, weight=ft.FontWeight.W_600, color="#ffffff" if admin else "#5c4210" if division else PINE),
bgcolor=PINE if admin else "#f0e2bf" if division else "#dfe7e4",
padding=ft.Padding.symmetric(horizontal=10, vertical=3),
border_radius=999,
)
def ledger_entry(label: str, request: str, result: ApiResult) -> ft.Control:
stamp = ft.Container(
content=ft.Column(
[ft.Text(str(result.status or "ERR"), size=26, weight=ft.FontWeight.BOLD, color="#ffffff"),
ft.Text(status_word(result.status), size=11, color="#ffffff")],
horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=0, tight=True),
bgcolor=status_colour(result.status), width=100, padding=10, alignment=ft.Alignment.CENTER,
)
detail = ft.Column(
[ft.Text(label, weight=ft.FontWeight.W_600, size=15, color=INK),
ft.Text(f"{request} Β· {result.ms} ms", size=12, color=MUTED, selectable=True),
ft.Text(json.dumps(result.body, indent=2), size=12, color=INK, selectable=True, font_family="monospace")],
spacing=3, expand=True, tight=True,
)
return ft.Container(
content=ft.Row([stamp, ft.Container(detail, padding=10, expand=True)], spacing=0, vertical_alignment=ft.CrossAxisAlignment.STRETCH),
bgcolor=CARD, border=ft.Border.all(1, "#c9d3cf"), border_radius=6, clip_behavior=ft.ClipBehavior.HARD_EDGE,
)
def login_view(cfg: Config, on_password, on_browser, message: str = "", busy: bool = False) -> ft.Control:
email = ft.TextField(label="Email", autofocus=True, keyboard_type=ft.KeyboardType.EMAIL)
password = ft.TextField(label="Password", password=True, can_reveal_password=True)
def submit(_=None):
on_password(email.value or "", password.value or "")
def fill(addr: str):
def _f(_):
email.value, password.value = addr, "Demo@12345"
email.update(); password.update()
return _f
password.on_submit = submit
return ft.Column(
[
ft.Text("Access ledger", size=34, weight=ft.FontWeight.BOLD, color=INK),
ft.Text("Sign in, then watch what the API lets through and what it refuses.", color=MUTED),
card(
ft.Text("Sign in", size=20, weight=ft.FontWeight.W_600, color=INK),
email, password,
ft.Text(message, color=DENY, visible=bool(message)),
ft.Button("Sign in with password", on_click=submit, disabled=busy, bgcolor=PINE, color="#ffffff"),
ft.OutlinedButton("Sign in with browser (PKCE)", on_click=lambda _: on_browser(), disabled=busy),
ft.Text("Browser sign-in uses a loopback redirect and works in desktop builds.", size=12, color=MUTED),
),
card(
ft.Text("Demo accounts", size=18, weight=ft.FontWeight.W_600, color=INK),
ft.Text("Password for all: Demo@12345", size=12, color=MUTED),
ft.Row([ft.OutlinedButton(f"{a.split('@')[0]} ({n})", on_click=fill(a)) for a, n in DEMO], wrap=True, spacing=8),
),
ft.Text(f"Authorizer: {cfg.authorizer_url} API: {cfg.api_url}", size=11, color=MUTED, selectable=True),
],
spacing=14, width=520,
)
def home_view(cfg: Config, s: Session, entries: list[ft.Control], on_call, on_signout) -> ft.Control:
claims = decode_claims(s.access_token)
tool_body = {"dataset_id": "fin-q3-revenue"}
question = ft.TextField(label="Ask the knowledge base", value="trial results and revenue policy", expand=True)
def go(label, path, **kw):
return lambda _: on_call(label, path, **kw)
return ft.Column(
[
ft.Row([ft.Text("Access ledger", size=30, weight=ft.FontWeight.BOLD, color=INK, expand=True),
ft.TextButton("Sign out", on_click=lambda _: on_signout())]),
card(
ft.Text(s.email, size=20, weight=ft.FontWeight.W_600, color=INK),
ft.Row([role_chip(r) for r in s.roles], wrap=True, spacing=6),
ft.Text(f"Token expires (unix): {claims.get('exp', 'unknown')}", size=12, color=MUTED),
),
card(
ft.Text("Send a request", size=18, weight=ft.FontWeight.W_600, color=INK),
ft.Row(
[
ft.OutlinedButton("Who am I", on_click=go("Who am I", "/api/me")),
ft.OutlinedButton("My datasets", on_click=go("My datasets", "/api/datasets")),
ft.OutlinedButton("Finance dataset", on_click=go("Finance dataset", "/api/datasets/fin-q3-revenue")),
ft.OutlinedButton("Research dataset", on_click=go("Research dataset", "/api/datasets/res-trials-2026")),
ft.OutlinedButton("Admin audit log", on_click=go("Audit log", "/api/admin/audit")),
ft.OutlinedButton("Export tool", on_click=go("Export tool", "/api/tools/export_dataset/invoke", method="POST", json=tool_body)),
],
wrap=True, spacing=8,
),
ft.Row([question, ft.Button("Ask", on_click=lambda _: on_call("Knowledge base", "/api/rag/query", method="POST", json={"question": question.value or ""}))]),
),
card(
ft.Text("Break it on purpose", size=18, weight=ft.FontWeight.W_600, color=INK),
ft.Row([
ft.OutlinedButton("No token", on_click=go("No token", "/api/me", token=None)),
ft.OutlinedButton("Tampered token", on_click=go("Tampered token", "/api/me", token=s.access_token[:-6] + "AAAAAA")),
], spacing=8),
),
ft.Text("Ledger", size=22, weight=ft.FontWeight.W_600, color=INK),
*(entries or [ft.Text("No requests yet. Pick one above to see what the API allows.", color=MUTED)]),
],
spacing=14, width=720,
)
def main(page: ft.Page):
cfg = load_config()
page.title = "Access ledger"
page.bgcolor = PAPER
page.padding = 20
page.scroll = ft.ScrollMode.AUTO
page.horizontal_alignment = ft.CrossAxisAlignment.CENTER
state: dict = {"session": None, "entries": []}
def show(control: ft.Control):
page.clean()
page.add(control)
page.update()
def show_login(message: str = "", busy: bool = False):
show(login_view(cfg, password_login, browser_login, message, busy))
def show_home():
show(home_view(cfg, state["session"], state["entries"], call, signout))
def finish(session: Session):
state.update(session=session, entries=[])
show_home()
def password_login(email: str, password: str):
show_login("Signing inβ¦", busy=True)
try:
finish(login_with_password(cfg, email, password))
except AuthError as exc:
show_login(str(exc))
def browser_login():
show_login("Complete the sign-in in your browserβ¦", busy=True)
try:
finish(login_with_browser(cfg))
except AuthError as exc:
show_login(str(exc))
def call(label: str, path: str, method: str = "GET", json: dict | None = None, token="__session__"):
tok = state["session"].access_token if token == "__session__" else token
result = call_api(cfg.api_url, path, tok, method, json)
state["entries"].insert(0, ledger_entry(label, f"{method} {path}", result))
show_home()
def signout():
state.update(session=None, entries=[])
show_login()
show_login()
if __name__ == "__main__":
ft.run(main)
"""Access ledger: the Flet client for the Authorizer demo.Run: flet run main.py (desktop window)
flet run --web main.py (browser; password sign-in only)
"""
import json
import flet as ft
from api_client import ApiResult, call_api
from auth_client import AuthError, Session, decode_claims, login_with_browser, login_with_password
from config import Config, load_config
PAPER, CARD, INK, MUTED = "#e9eeeb", "#f7f9f7", "#10201f", "#566664"
PINE, BRASS, DENY, ALLOW = "#0f3d3a", "#a87a1f", "#b3261e", "#1c7c4d"
DEMO = [("alice@demo.test", "finance"), ("bob@demo.test", "research"), ("carol@demo.test", "no division"), ("admin@demo.test", "admin")]
def status_colour(status: int) -> str:
return ALLOW if 200 <= status < 300 else DENY if status == 403 else BRASS if status == 401 else MUTED
def status_word(status: int) -> str:
return "allowed" if 200 <= status < 300 else "denied" if status == 403 else "unauthenticated" if status == 401 else "error"
def card(*controls: ft.Control) -> ft.Container:
return ft.Container(
content=ft.Column(list(controls), spacing=10, tight=True),
bgcolor=CARD,
border=ft.Border.all(1, "#c9d3cf"),
border_radius=6,
padding=16,
)
def role_chip(role: str) -> ft.Container:
admin, division = role == "admin", role.startswith("div-")
return ft.Container(
content=ft.Text(role, size=13, weight=ft.FontWeight.W_600, color="#ffffff" if admin else "#5c4210" if division else PINE),
bgcolor=PINE if admin else "#f0e2bf" if division else "#dfe7e4",
padding=ft.Padding.symmetric(horizontal=10, vertical=3),
border_radius=999,
)
def ledger_entry(label: str, request: str, result: ApiResult) -> ft.Control:
stamp = ft.Container(
content=ft.Column(
[ft.Text(str(result.status or "ERR"), size=26, weight=ft.FontWeight.BOLD, color="#ffffff"),
ft.Text(status_word(result.status), size=11, color="#ffffff")],
horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=0, tight=True),
bgcolor=status_colour(result.status), width=100, padding=10, alignment=ft.Alignment.CENTER,
)
detail = ft.Column(
[ft.Text(label, weight=ft.FontWeight.W_600, size=15, color=INK),
ft.Text(f"{request} Β· {result.ms} ms", size=12, color=MUTED, selectable=True),
ft.Text(json.dumps(result.body, indent=2), size=12, color=INK, selectable=True, font_family="monospace")],
spacing=3, expand=True, tight=True,
)
return ft.Container(
content=ft.Row([stamp, ft.Container(detail, padding=10, expand=True)], spacing=0, vertical_alignment=ft.CrossAxisAlignment.STRETCH),
bgcolor=CARD, border=ft.Border.all(1, "#c9d3cf"), border_radius=6, clip_behavior=ft.ClipBehavior.HARD_EDGE,
)
def login_view(cfg: Config, on_password, on_browser, message: str = "", busy: bool = False) -> ft.Control:
email = ft.TextField(label="Email", autofocus=True, keyboard_type=ft.KeyboardType.EMAIL)
password = ft.TextField(label="Password", password=True, can_reveal_password=True)
def submit(_=None):
on_password(email.value or "", password.value or "")
def fill(addr: str):
def _f(_):
email.value, password.value = addr, "Demo@12345"
email.update(); password.update()
return _f
password.on_submit = submit
return ft.Column(
[
ft.Text("Access ledger", size=34, weight=ft.FontWeight.BOLD, color=INK),
ft.Text("Sign in, then watch what the API lets through and what it refuses.", color=MUTED),
card(
ft.Text("Sign in", size=20, weight=ft.FontWeight.W_600, color=INK),
email, password,
ft.Text(message, color=DENY, visible=bool(message)),
ft.Button("Sign in with password", on_click=submit, disabled=busy, bgcolor=PINE, color="#ffffff"),
ft.OutlinedButton("Sign in with browser (PKCE)", on_click=lambda _: on_browser(), disabled=busy),
ft.Text("Browser sign-in uses a loopback redirect and works in desktop builds.", size=12, color=MUTED),
),
card(
ft.Text("Demo accounts", size=18, weight=ft.FontWeight.W_600, color=INK),
ft.Text("Password for all: Demo@12345", size=12, color=MUTED),
ft.Row([ft.OutlinedButton(f"{a.split('@')[0]} ({n})", on_click=fill(a)) for a, n in DEMO], wrap=True, spacing=8),
),
ft.Text(f"Authorizer: {cfg.authorizer_url} API: {cfg.api_url}", size=11, color=MUTED, selectable=True),
],
spacing=14, width=520,
)
def home_view(cfg: Config, s: Session, entries: list[ft.Control], on_call, on_signout) -> ft.Control:
claims = decode_claims(s.access_token)
tool_body = {"dataset_id": "fin-q3-revenue"}
question = ft.TextField(label="Ask the knowledge base", value="trial results and revenue policy", expand=True)
def go(label, path, **kw):
return lambda _: on_call(label, path, **kw)
return ft.Column(
[
ft.Row([ft.Text("Access ledger", size=30, weight=ft.FontWeight.BOLD, color=INK, expand=True),
ft.TextButton("Sign out", on_click=lambda _: on_signout())]),
card(
ft.Text(s.email, size=20, weight=ft.FontWeight.W_600, color=INK),
ft.Row([role_chip(r) for r in s.roles], wrap=True, spacing=6),
ft.Text(f"Token expires (unix): {claims.get('exp', 'unknown')}", size=12, color=MUTED),
),
card(
ft.Text("Send a request", size=18, weight=ft.FontWeight.W_600, color=INK),
ft.Row(
[
ft.OutlinedButton("Who am I", on_click=go("Who am I", "/api/me")),
ft.OutlinedButton("My datasets", on_click=go("My datasets", "/api/datasets")),
ft.OutlinedButton("Finance dataset", on_click=go("Finance dataset", "/api/datasets/fin-q3-revenue")),
ft.OutlinedButton("Research dataset", on_click=go("Research dataset", "/api/datasets/res-trials-2026")),
ft.OutlinedButton("Admin audit log", on_click=go("Audit log", "/api/admin/audit")),
ft.OutlinedButton("Export tool", on_click=go("Export tool", "/api/tools/export_dataset/invoke", method="POST", json=tool_body)),
],
wrap=True, spacing=8,
),
ft.Row([question, ft.Button("Ask", on_click=lambda _: on_call("Knowledge base", "/api/rag/query", method="POST", json={"question": question.value or ""}))]),
),
card(
ft.Text("Break it on purpose", size=18, weight=ft.FontWeight.W_600, color=INK),
ft.Row([
ft.OutlinedButton("No token", on_click=go("No token", "/api/me", token=None)),
ft.OutlinedButton("Tampered token", on_click=go("Tampered token", "/api/me", token=s.access_token[:-6] + "AAAAAA")),
], spacing=8),
),
ft.Text("Ledger", size=22, weight=ft.FontWeight.W_600, color=INK),
*(entries or [ft.Text("No requests yet. Pick one above to see what the API allows.", color=MUTED)]),
],
spacing=14, width=720,
)
def main(page: ft.Page):
cfg = load_config()
page.title = "Access ledger"
page.bgcolor = PAPER
page.padding = 20
page.scroll = ft.ScrollMode.AUTO
page.horizontal_alignment = ft.CrossAxisAlignment.CENTER
state: dict = {"session": None, "entries": []}
def show(control: ft.Control):
page.clean()
page.add(control)
page.update()
def show_login(message: str = "", busy: bool = False):
show(login_view(cfg, password_login, browser_login, message, busy))
def show_home():
show(home_view(cfg, state["session"], state["entries"], call, signout))
def finish(session: Session):
state.update(session=session, entries=[])
show_home()
def password_login(email: str, password: str):
show_login("Signing inβ¦", busy=True)
try:
finish(login_with_password(cfg, email, password))
except AuthError as exc:
show_login(str(exc))
def browser_login():
show_login("Complete the sign-in in your browserβ¦", busy=True)
try:
finish(login_with_browser(cfg))
except AuthError as exc:
show_login(str(exc))
def call(label: str, path: str, method: str = "GET", json: dict | None = None, token="__session__"):
tok = state["session"].access_token if token == "__session__" else token
result = call_api(cfg.api_url, path, tok, method, json)
state["entries"].insert(0, ledger_entry(label, f"{method} {path}", result))
show_home()
def signout():
state.update(session=None, entries=[])
show_login()
show_login()
if __name__ == "__main__":
ft.run(main)
Run it with cd flet_app && pip install -r requirements.txt && flet run main.py.
Running the demo
With everything in place, the whole stack starts in five commands:
./scripts/init.sh
docker compose up -d --build
python3 scripts/seed_users.py
python3 scripts/e2e_check.py
cd frontend && npm install && npm run dev./scripts/init.sh
docker compose up -d --build
python3 scripts/seed_users.py
python3 scripts/e2e_check.py
cd frontend && npm install && npm run devThe demo accounts all use the password Demo@12345. Here is a session I run with it, each step short enough to keep people awake.
Identity is not permission. Sign in as Carol and click Who am I, then My datasets. She is authenticated, so she gets a 200, and her list is empty because she belongs to no division.
Read a token. Open the decoded access token. Point at iss, aud, exp and the role claims. The browser can read this, but only the API's signature check makes it trustworthy.
401 versus 403. Click No token and Tampered token: 401, who are you? Then, as Alice, click Admin audit log: 403, I know you, and no.
Division rules. As Alice, the finance dataset is a 200 and the research dataset is a 403. Switch to Bob and it flips. Switch to the admin and both work.
The AI layer. As Alice, ask the knowledge base for "trial results and revenue policy". Only finance documents come back. Then try the export tool: 403, because the tool needs the admin role. Open search_documents and show that the filter runs before the ranking.
Break the config on purpose. Remove div-finance from --protected-roles, restart Authorizer, and sign up a new user asking for that role. Discuss what happens to everything above.
Automate it. Run the end-to-end script and the test suite. Then restart Authorizer and show that the same token still works, because the signing keys live on disk.
The six walls
This is the part I promised. On the first real run, the stack met reality and lost six times. In each case the fix was small once I understood the cause. Understanding the cause was the whole job.
Wall 1: "Forbidden," with no explanation
My seed script died with a bare 403 Forbidden. The traceback said nothing else, because my helper called raise_for_status() and threw away the response body. The first fix was to my own tooling: print the body. It said:
{"error":"csrf_validation_failed","error_description":"Origin not allowed"}
Lesson: when an error is unhelpful, make it helpful before you make any other change.
Wall 2: the CSRF guard wants an Origin
Recent Authorizer versions (2.3.0 onward, as far as the project's own notes say) refuse state-changing requests that arrive without an Origin header. Browsers add that header automatically. A Python script does not, and neither does a native app. So the scripts and the Flet client now send one.
Wall 3: my own origin was not on the list
My first fix sent Authorizer's own address as the origin, and the guard still said no. The catch: once --allowed-origins is restricted, every origin must be on it, including the server's own. The scripts now borrow the web app's origin, the Flet app uses its loopback origin, and the list includes the dashboard's own address too. That last one bit me again when the admin dashboard login showed a red "[Network] Forbidden" toast: the dashboard is a web page served from Authorizer itself, so it needs the same permission as anything else.
Wall 4: login succeeded and returned no token
The next failure was strange. The login call worked but access_token was empty, so the script reported a failed login and then crashed on None. In the version I ran, multi-factor authentication methods are on by default, and when a second factor is pending the server withholds tokens and returns a message instead. For a scripted demo I added --disable-mfa, after which the same login returned tokens. I also made every client print the server's message when no token arrives. In production you keep MFA on and build the second-factor step properly.
Wall 5: user was null
Once tokens flowed, the login response had user: null, and my code dereferenced it. The tokens themselves carry everything needed (subject, roles, sometimes email), so the clients now fall back to decoding them. Small change, and a good reminder that a client should never assume an optional field is present.
Wall 6: roles is not the roles you assigned
This one taught me the most. The e2e script reported seven of nine passes, and the two failures made no sense: Alice logged in with roles viewer and div-finance, but the API said she had no access to finance. The diagnostic output I had just added showed the answer:
access-token role claims: {'allowed_roles': ['viewer', 'div-finance'], 'roles': ['viewer']}
/api/me -> 200 {"roles":["viewer"],"divisions":[],"is_admin":false}access-token role claims: {'allowed_roles': ['viewer', 'div-finance'], 'roles': ['viewer']}
/api/me -> 200 {"roles":["viewer"],"divisions":[],"is_admin":false}Authorizer puts the roles activated for this session in roles, which is just the default role unless the client asks for more at login. Everything an admin assigned is in allowed_roles. My API was reading the narrow one. And here is the unsettling part: two of my "passing" checks were passing by accident. Alice was correctly denied the research dataset, but only because she was denied everything.
A test that passes for the wrong reason is worse than a failing test. That is why the backend suite now has a test built from the exact token shape above, and why the e2e script prints what the API believes when something fails.
There are two legitimate designs here. The demo authorizes on allowed_roles, which is simple. A least-privilege design has clients request specific roles at login and the API reads roles. If you choose that, set ROLE_CLAIM=roles and add a role picker to your clients.
What I would change before production
This is a learning stack. Before a real deployment I would do the following.
- Pin the Authorizer image to a tag you have tested instead of
latest. - Put everything behind HTTPS, and switch the cookie-secure flags back on.
- Use managed PostgreSQL with backups, and keep signing keys in a secret manager rather than in files with relaxed permissions.
- Turn MFA back on and build the second-factor step.
- Stop using an admin secret in scripts. Disable admin-header authentication and manage roles through the dashboard or a proper admin workflow.
- Turn off GraphQL introspection, add rate limits, ship audit logs somewhere durable, and rehearse key rotation and database restores.
- Decide what happens to an already-issued token when you remove someone's role. Short token lifetimes, and a plan for revocation, are the honest answer.
And the reminder for anyone building AI products: your RAG pipeline and your tool server are APIs. Give them the same two checks, may this role do this and may this person touch this record, on every call, before any retrieval or execution happens.