Every Shyft internal tool plugs into the portal the same way: it gets reached through a
reverse-proxy that mints a short-lived JWT, the app verifies it with shyft-auth,
and reads the user's identity, groups, and role from the claims. This guide walks you through
the contract end-to-end and gives you concrete Python and Node snippets you can copy.
This is the developer-facing companion to the auth design docs. Read it once before you start wiring up a new app, and again when you're ready to register your app with the portal admin UI. Each language-specific snippet sits behind a tab — the concepts above the snippet are agnostic, the code below them is whatever you're shipping.
Working assumption: you're building a new internal app (FastAPI, Flask, Express, or Next.js) that needs to read "who is this user, and what are they allowed to do here?" from every incoming request. You do not need to implement sign-in, sign-out, sessions, or password reset — the portal handles all of that. You just need to verify the JWT the portal forwards and act on the claims.
This guide is the auth slice. For the repo layout it slots into — the co-located Next.js + FastAPI structure, the stack choices, and the path to a deployed tile — read Creating a new app first.
How it fits together
The portal is a Backend-for-Frontend (BFF). It owns the browser session, talks to Authentik on your behalf, and forwards each request to your app over a private network with a freshly-minted JWT. Your app only ever sees those JWTs.
Three rules that shake out of the topology and constrain everything you build:
Apps only see the JWT in Authorization: Bearer …. You do not parse, forward, or
persist portal_session — you wouldn't have it in topology A anyway.
JWTs live 60 seconds. There is no in-app session cache. Re-verify on every request; there's no "login" in your app to keep alive.
You declare your roles in code and expose them at /.well-known/app-roles. The
portal admin maps Authentik groups → your role names in the registration UI.
Tokens & sessions
Three tokens exist in this system. Your app sees exactly one of them.
| Token | Issued by | Audience | Lifetime | Apps see it? |
|---|---|---|---|---|
Authentik ID token | Authentik | Portal (OIDC client) | ~1 hour | No |
Portal session cookie | Portal | Browser only | ~12 hours | No |
Portal-minted app JWT | Portal (RS256) | aud=<your-slug> | 60 seconds | Yes — every request |
INSERT into user_sessions · snapshot groups · set HttpOnly cookie
Why 60 seconds? Short enough that we don't need a revocation list. If the portal session is killed (sign-out, admin disable, group removed) the next request stops at the proxy before a fresh JWT is minted, and any JWT the user holds in flight expires within a minute. See the FAQ for details.
Claim contract
Every JWT the portal mints — and every JWT Authentik would mint, if we ever flip to topology A —
has the same shape. Your app should never branch on iss; rely on aud, sub, groups,
and app_role.
{
// standard claims
"iss": "https://portal.internal.shyftsolutions.io",
"aud": "training",
"sub": "alice@shyftsolutions.io",
"iat": 1715600000,
"exp": 1715600060,
"jti": "01HX...",
// identity
"name": "Alice Example",
"email": "alice@shyftsolutions.io",
"groups": ["employees", "training-users"],
// authorization
"app_role": "user"
}| Claim | Required | Library checks | What you read it for |
|---|---|---|---|
iss | Yes | Validated against your allowlist | Informational — don't branch on it |
aud | Yes | Must equal your app slug | Defense in depth — library rejects mismatches |
sub | Yes | — | Stable user identifier (email today) |
exp, iat | Yes | ±5s clock skew | Library rejects expired tokens automatically |
name, email | Yes | — | Display, audit logs, ownership |
groups | Yes | — | Raw Authentik group names, filtered to those this app cares about |
app_role | Yes | — | Resolved by the portal from your group→role mapping. This is what you gate features on. |
Adding new claims is non-breaking. Renaming or removing a claim is a major version bump of
shyft-auth coordinated with the portal's minter — your pinned library version
protects you from surprises.
Shape your app
The portal doesn't care if your app is one process or three. But the typical Shyft internal app is Next.js front-end + FastAPI back-end + Postgres, and that shape has a recommended layout. Read this before you register your app so the slug, audience, and role mapping all line up.
One slug, one role mapping, one audience. The FastAPI runs on the internal podman network
and never publishes a port outside the host. The Next.js forwards the user's portal JWT to
FastAPI on every request, and FastAPI verifies the same JWT with the same aud=my-app using
shyft-auth.
Two slugs means two group→role mappings to keep in sync. That's a maintenance burden and a likely source of "I gave Alice admin but the API still 403s" bugs.
aud answers "which app is this token for?" The user sees one app.
Splitting aud across deployment layers leaks topology into the security model.
It's only called by your own Next.js. There's no second user flow to gate; the role mapping is already settled by the time Next.js reaches the API.
Forwarding the JWT
For server-side fetches (Server Components, Route Handlers), pull Authorization off the
incoming request and forward verbatim. The portal already put the JWT there.
import { headers } from "next/headers";
export default async function Dashboard() {
const auth = (await headers()).get("authorization");
const res = await fetch(`${process.env.MY_APP_API_URL}/things`, {
headers: { Authorization: auth ?? "" },
cache: "no-store",
});
const things = await res.json();
return <ThingsView things={things} />;
}For browser-initiated fetches, route them through your Next.js /api/* routes (which
forward the JWT as above). Don't expose the FastAPI publicly to call it directly from the
browser — that defeats the internal-only property that makes "trust the JWT" safe.
FastAPI verification
The require_user factory in shyft_auth.fastapi wraps the verify call and the 401-on-failure
mapping for you. Configure it once at module scope, use it as a Depends in every route.
from fastapi import Depends, FastAPI, HTTPException
from shyft_auth import AppClaims
from shyft_auth.fastapi import require_user
app = FastAPI()
current_user = require_user(
expected_iss="https://portal.internal.shyftsolutions.io",
expected_aud="my-app",
jwks_url="https://portal.internal.shyftsolutions.io/.well-known/jwks.json",
)
@app.get("/things")
def list_things(user: AppClaims = Depends(current_user)):
if user.app_role not in {"admin", "editor", "viewer"}:
raise HTTPException(403)
...Rare. (1) The API has a second consumer — another app, a CLI, a webhook. That's server-to-server
auth, deferred to a future phase. (2) The API and the front-end need different access lists. You
can already express this with app_role checks inside the API — cheaper than splitting the
registration.
Deployment shape: both halves run as podman quadlets on the same internal network. Only the
Next.js publishes a port to the host. Both expose /health. The FastAPI's Postgres is its own
database — don't share with the portal's session store.
Run behind the /apps/<slug> prefix
The portal reaches your app at /apps/<slug>/… and strips the slug before
forwarding — your handler sees a bare path (/dashboard, not /apps/my-app/dashboard). That's
convenient for routing, but it means your app must still generate URLs under the prefix: every
asset link, Location: redirect, form action, and OIDC redirect_uri has to start with
/apps/<slug>/, or the browser will request it from the portal and get a 404. An app that ignores
this is the #1 cause of broken assets and redirect loops behind the proxy.
To help, the proxy sends one extra header on every request:
GET /dashboard HTTP/1.1
Authorization: Bearer <jwt>
X-Forwarded-Prefix: /apps/my-app
X-Forwarded-Host: portal.internal.shyftsolutions.io
X-Forwarded-Proto: httpsThere are two ways to make your URLs prefix-correct. Which one you use is dictated by your framework, and it determines the Base URL an admin registers (see Portal registration → Subpaths and the prefix):
Route at root; fold X-Forwarded-Prefix into the framework's "I'm mounted under a
prefix" knob (SCRIPT_NAME / root_path). The slug lives in exactly one
place — the portal registration — and nothing is hardcoded in your code. Base URL is registered
without the prefix.
The prefix is fixed at build/boot time and the framework expects it in the path. Base URL includes the prefix so the stripped slug gets re-added before it reaches you.
// basePath is build-time and can't read a request header, so Next uses the
// static-prefix model. It prefixes routes, <Link> hrefs, router pushes, and
// every /_next/* asset URL — and moves your /health and /.well-known/app-roles
// under the prefix too, which is exactly what the portal's baseUrl+path probes
// then expect.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
basePath: "/apps/my-app", // = /apps/<your-slug>
};
export default nextConfig;
// Register Base URL: http://my-app:3000/apps/my-app (prefix included)Prefer A (header-driven) whenever your framework lets you — the slug stays in one place and a
rename is a portal-only edit. Fall back to B (static prefix) only when the prefix is fixed before
a request exists: Next.js basePath, or an off-the-shelf app like Grafana
(GF_SERVER_ROOT_URL=…/apps/<slug>/ + GF_SERVER_SERVE_FROM_SUB_PATH=true).
Whatever you pick, apply it on both sides — app config and Base URL — never one without the other.
Quickstart
Five steps from a fresh repo to a JWT-verified route. The deep dives below each step are linked in the sidebar — but if you skim this section top-to-bottom you have everything you need.
1. Install the library
Both libraries install from a pinned git tag on github.com/ShyftSolutions.
Your SSH key for github.com needs read access to the target repo — see
Install & configure for the SSH setup, including the container-build secret flow.
# Pick the extras for your framework: [fastapi], [flask], or omit for core only.
uv add "shyft-auth[fastapi] @ git+ssh://git@github.com/ShyftSolutions/shyft-auth-py.git@v0.1.0"
# pip works too, same URL:
pip install "shyft-auth[fastapi] @ git+ssh://git@github.com/ShyftSolutions/shyft-auth-py.git@v0.1.0"2. Tell the library who you are
Set three environment variables. The library reads these at startup and uses them on every request — they're not per-request config.
# The aud claim the portal mints for *your* app — must match the URL slug
# you'll pick in step 5. See callout below.
SHYFT_APP_AUD=training
# The portal's own URL (no trailing slash) — JWTs come with iss=<this>.
SHYFT_PORTAL_ISSUER=https://portal.internal.shyftsolutions.io
# Where the portal publishes its JWT signing keys. Library caches the JWKS
# in-process; rotation just means cached keys miss + refetch.
SHYFT_PORTAL_JWKS_URL=https://portal.internal.shyftsolutions.io/.well-known/jwks.jsonWhat: the same lowercase-hyphen string you'll use as the URL slug when
you do Portal registration in step 5. If you
register your app as training, SHYFT_APP_AUD=training. If you register
it as claims-portal, SHYFT_APP_AUD=claims-portal. It is not a
secret, it is not the user's identity, and it is not the portal's
identity — it identifies which app the token was minted for.
Where: wherever your app's process reads its environment.
- Dev:
.env.localin the app repo, loaded by your framework ordotenv. - Prod:
Environment=SHYFT_APP_AUD=…in the[Container]section of your quadlet — see the CI/CD walkthrough.
Why: when a user clicks your tile, the portal mints a fresh JWT with
aud=<your-slug>. The library refuses any token whose aud doesn't match
this value. Without that check, a token minted for app A could be replayed
against app B — anyone with a working portal session could impersonate
users across every Shyft tool. The aud check is the wall that stops it.
Picking the value before registration: technically you can develop
locally with any string; just pick the one you intend to register with so
there's no rename later. Stick to [a-z0-9][a-z0-9-]* — the portal slug
validator does too.
3. Verify a request
One function call, one line in your handler. The library handles signature verification, issuer
- audience checks, expiry, clock skew, and JWKS caching.
# main.py
import os
from fastapi import FastAPI, Depends
from shyft_auth import AppClaims
from shyft_auth.fastapi import require_user
app = FastAPI()
# Reads the three env vars from step 2. os.environ["…"] (not .get) so a
# missing var fails loudly at startup, not silently at first-request time.
current_user = require_user(
expected_iss=os.environ["SHYFT_PORTAL_ISSUER"],
expected_aud=os.environ["SHYFT_APP_AUD"],
jwks_url=os.environ["SHYFT_PORTAL_JWKS_URL"],
)
@app.get("/whoami")
def whoami(claims: AppClaims = Depends(current_user)):
return {
"email": claims.email,
"groups": claims.groups,
"role": claims.app_role,
}4. Declare your roles
The portal admin needs to know which role names your app understands so they can map Authentik
groups to them. Expose them at /.well-known/app-roles:
from shyft_auth.fastapi import serve_app_roles_handler
app.add_api_route(
"/.well-known/app-roles",
serve_app_roles_handler({
"slug": "training",
"version": 1,
"roles": [
{"name": "user", "description": "Submit training records"},
{"name": "admin", "description": "Manage all training data"},
],
}),
methods=["GET"],
)5. Register your app with the portal
Ask a portal admin (or use the /admin/apps UI if you have access) to register your app's slug,
base URL, and group→role mappings. The admin clicks "Discover roles" — your
/.well-known/app-roles response is what pre-fills the form.
The /admin/apps UI ships in Phase H. Until it lands, registration is a DM to whoever has portal
DB access. See Portal admin walkthrough for the planned screens.
Install & configure
Both libraries install from a pinned git tag at git@github.com:ShyftSolutions/shyft-auth-{js,py}.git.
Your SSH key for github.com needs read access on the org. Until the packages move to a
registry, the install URL is the same on every machine — dev laptop, devcontainer, or CI runner.
1. Add the dep
uv add "shyft-auth[fastapi] @ git+ssh://git@github.com/ShyftSolutions/shyft-auth-py.git@v0.1.0"
# or, written by hand:
# [project]
# dependencies = ["shyft-auth[fastapi]"]
#
# [tool.uv.sources]
# shyft-auth = { git = "ssh://git@github.com/ShyftSolutions/shyft-auth-py.git", tag = "v0.1.0" }2. SSH access
Your default identity for github.com must have read access to
ShyftSolutions/shyft-auth-{js,py}. If you maintain multiple GitHub identities (personal + shyft)
via SSH aliases, either add a Host github.com block that resolves to your shyft key, or set
GIT_SSH_COMMAND for the install. uv and npm both inherit the env var transparently.
3. Building containers (BuildKit secret)
Container image builds don't inherit your SSH agent. Use BuildKit's secret mount to forward the
key file read-only into the build — no agent, no key in any image layer. The compose stack in
sample-auth-app is wired up this way; mirror it in your app:
# syntax=docker/dockerfile:1.4
FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends git openssh-client \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p -m 0700 /root/.ssh \
&& ssh-keyscan -t rsa,ecdsa,ed25519 github.com >> /root/.ssh/known_hosts
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=ssh_key,target=/root/.ssh/id_rsa,mode=0600 \
npm ciservices:
app:
build:
context: .
secrets: [ssh_key]
secrets:
ssh_key:
file: ${SHYFT_SSH_KEY:-${HOME}/.ssh/id_rsa_shyft}If GitHub is unreachable, clone the repo elsewhere and build locally (uv build / npm pack),
then point your consumer at the resulting wheel / tarball with a file: source. See the library
READMEs' Build from source sections.
4. Smoke-test the install
python -c "from shyft_auth import verify_portal_jwt; print('shyft-auth ok:', verify_portal_jwt)"Verify a request
Anatomy of what verifyPortalJwt / verify_portal_jwt actually does for you, what it can
throw, and how to respond.
Both libraries do the same six things, in this order:
- Read the bearer token from the
Authorizationheader. - Decode the JWT header to extract
kid. - Fetch the JWKS (cached) from the portal, look up the public key.
- Verify the RS256 signature.
- Check
issagainst your allowlist,audagainst your slug,exp+iatwith ±5s skew. - Return a strongly-typed
AppClaimsobject — or raise a typed error you can pattern-match on.
If any check fails, the call raises a specific error subclass. Map them to HTTP statuses in one place — middleware, error handler, or decorator wrapper — so your route handlers stay clean.
Error taxonomy
| Error | Means | Recommended response |
|---|---|---|
MissingTokenError | No Authorization header on the request. | 401 · "authentication required" |
InvalidSignatureError | JWKS lookup OK but signature does not verify. | 401 · log it, this should not happen with a healthy portal |
ExpiredTokenError | exp in the past beyond skew. | 401 · client should retry through portal |
AudienceMismatchError | aud in the JWT does not equal your expected_aud. | 401 · usually means your app was deployed with the wrong slug |
IssuerMismatchError | iss not in your allowlist. | 401 · likely a misconfigured env var |
InvalidTokenError | Catch-all: malformed token, unsupported alg, JWKS resolution failure, missing required claim. | 401 · the base PortalAuthError handler covers it |
Reusable error-to-HTTP mapping
from fastapi import Request
from fastapi.responses import JSONResponse
from shyft_auth import PortalAuthError, MissingTokenError
@app.exception_handler(PortalAuthError)
def auth_error_handler(request: Request, exc: PortalAuthError):
status = 401
if isinstance(exc, MissingTokenError):
return JSONResponse({"error": "authentication_required"}, status_code=status)
return JSONResponse({"error": exc.__class__.__name__}, status_code=status)verify_portal_jwt tells you who the user is and what role they have. It does not tell
you whether they're allowed to do a specific action — that's your app's business logic. Use
has_role(claims, "admin") / hasRole(claims, "admin") at the action boundary, not at the
middleware boundary.
Roles & discovery
Roles are your app's vocabulary. The portal doesn't care what they mean; it just maps
Authentik groups to them and forwards the resolved value in the JWT's app_role claim. Two
things matter: declaring them clearly, and gating features on them precisely.
1. Define your roles in code
Keep your role list in a single file. Reference it everywhere — the well-known endpoint, your authorization checks, your tests. Then a future role rename is one commit instead of grep-and-pray.
# app/roles.py
APP_ROLES = [
{"name": "user", "description": "Submit training records"},
{"name": "approver", "description": "Approve or reject submissions"},
{"name": "admin", "description": "Manage everyone's training data"},
]
ROLE_NAMES = {r["name"] for r in APP_ROLES}2. Expose /.well-known/app-roles
The portal's admin UI calls this endpoint during registration (and any time an admin clicks "Re-discover") to pre-fill the role dropdown. If the endpoint is unreachable, the admin can type role names manually — but discovery is the happy path, so wire it up.
# app/main.py
from shyft_auth.fastapi import serve_app_roles_handler
from .roles import APP_ROLES
app.add_api_route(
"/.well-known/app-roles",
serve_app_roles_handler({"slug": "training", "version": 1, "roles": APP_ROLES}),
methods=["GET"],
)GET /.well-known/app-roles HTTP/1.1
Host: training.shyftsolutions.internal
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=300
{
"slug": "training",
"version": 1,
"roles": [
{ "name": "user", "description": "Submit training records" },
{ "name": "approver", "description": "Approve or reject submissions" },
{ "name": "admin", "description": "Manage everyone's training data" }
]
}3. Gate features on the role
app_role is a single string. Pick one of three idioms; they all map to the same library
helpers.
from fastapi import HTTPException, Depends
from shyft_auth import has_role, AppClaims
from shyft_auth.fastapi import require_user
current_user = require_user(
expected_iss="https://portal.internal.shyftsolutions.io",
expected_aud="training",
jwks_url="https://portal.internal.shyftsolutions.io/.well-known/jwks.json",
)
def require_role(role: str):
def dep(claims: AppClaims = Depends(current_user)) -> AppClaims:
if not has_role(claims, role):
raise HTTPException(status_code=403, detail=f"requires {role}")
return claims
return dep
@app.delete("/records/{id}")
def delete_record(id: str, claims: AppClaims = Depends(require_role("admin"))):
...How groups become roles
You declare role names; an admin maps Authentik groups to them in the portal. The mapping has an explicit priority — lowest number wins when a user is in multiple matching groups.
The portal stores each mapping's Authentik group reference as a UUID (the pk field), not the
name. A rename in Authentik is invisible to your app — the mapping still resolves, and the
admin UI always shows the current name.
Local development
Two ways to exercise auth-protected endpoints without standing up a full prod stack: run the
portal locally for end-to-end clicks through a real Authentik sign-in, or mock the verified
claims in unit tests with build_claims() / buildClaims().
1. Run the portal locally
Clone internal-portal, fill in .env.local with the Authentik OIDC creds, and start the dev
stack. The portal sits at http://localhost:3000 and proxies registered apps under
/apps/<slug>/….
git clone git@github.com:ShyftSolutions/internal-portal.git
cd internal-portal
cp .env.local.example .env.local # fill in AUTHENTIK_* values
just dev-setup
just dev-stack2. Point your app's env at the local portal
SHYFT_APP_AUD=training
SHYFT_PORTAL_ISSUER=http://localhost:3000
SHYFT_PORTAL_JWKS_URL=http://localhost:3000/.well-known/jwks.json3. Register your app in the portal admin UI
Sign in at http://localhost:3000, visit /admin/apps, register your app's slug + base URL,
hit "Discover roles" to pull /.well-known/app-roles, and map an Authentik group to your
admin role so you can test authenticated flows.
Mocking claims in unit tests
For unit tests that don't need a real signature — gate-checking logic, handler shape, response
shape — use the library's testing helper to construct an AppClaims directly. See
Testing for the full pattern.
The portal's dev JWKS endpoint serves the same RS256 keypair the prod portal would. To exercise
rotation, restart the portal dev process — it generates a fresh keypair on cold start (the
previous one is persisted in data/, so use rm -rf data/jwt-keys.json first to force the new
kid). Your app's next request should succeed without restart.
Portal admin walkthrough
Once your app verifies a JWT locally, the last step is telling the portal it exists. An admin
does this through /admin/apps — the screens below are the planned UX. Until the Phase H build
lands, the same data can be inserted directly into the portal Postgres.
/admin/apps, /admin/groups, and /admin/audit are wireframed and locked in
.claude/artifacts/initial-design/docs/mockup-spec.md. They'll ship after the portal's JWT
minter + reverse-proxy are working end-to-end. The flow described below is what you'll see on
day one.
Register a new app
Step 1 of 3 · Identity
- Slug
training— appears in/apps/<slug>/and asaudin the JWT- Display name
- Training Tracker
- Base URL
http://training:8000(internal podman name)- Health path
/health- Category
- Compliance
- Status
live·beta·alpha·planned
Discover roles
Step 2 of 3 · Pulled from /.well-known/app-roles
✓ Found 3 roles at http://training:8000/.well-known/app-roles
user· Submit training recordsapprover· Approve or reject submissionsadmin· Manage everyone's training data
If discovery fails (unreachable, malformed JSON), an inline error reveals a manual textarea — and a Re-discover button on the edit page lets you retry once the app is up.
Map groups → roles
Step 3 of 3 · Drag rows to reorder priority — lowest number wins
training-admins→ adminuuid linkedtraining-approvers→ approveruuid linkedtraining-users→ useruuid linkedemployees→ useruuid linkedThe mapping stores Authentik group UUIDs, not names. Group renames in Authentik are transparent.
Groups & mappings
Authentik groups are the source of truth for who can do what. The portal admin UI is a thin shell over Authentik's REST API — every create / edit / delete is logged in the portal's audit table with the admin's email, even though Authentik sees only the portal's service-account token.
Group naming is freeform. The admin UI suggests <app-slug>-<role> as a placeholder (e.g.
training-admins, training-approvers), but cross-app groups like internal-admin or
finance-leads are fully supported — map them into multiple apps as needed.
Nested groups work if Authentik resolves parents at issuance time. If it doesn't, the
portal resolves them at sign-in and snapshots the flattened set into the session record. Either
way, your app sees the resolved list in claims.groups.
Suggested layout
# Cross-app
- internal-admin # super-admin for everything
- employees # everyone with a badge
# Per-app, scoped
- training-admins # full admin in training tracker
- training-approvers # mid-tier
- training-users # read/submit
- stipend-admins
- stipend-approvers
- stipend-usersWant every internal admin to be admin in every app? Map internal-admin → admin at priority 0
in each app's mappings. Lower number wins, so it'll always beat per-app mappings.
Health & liveliness
The portal polls every registered app's health endpoint every 30 seconds. The result colors the tile and signals to admins whether your app is reachable, degraded, or simply not deployed yet.
The contract
GET /health HTTP/1.1
Host: training.shyftsolutions.internal
HTTP/1.1 200 OK
Content-Type: application/json
{
"ok": true,
"version": "1.4.0",
"uptime_s": 18241
}| State | Trigger | Tile appearance |
|---|---|---|
live | Last poll 2xx within 60s | Green dot · clickable |
degraded | Last poll 3xx / 4xx (besides 404) | Orange dot · clickable (warning banner inside app) |
unreachable | 5xx or timeout for > 2 consecutive polls | Red dot · clickable (502 page if launched) |
not-deployed | Registered < 5 min ago, never returned 2xx | Grey dot · "Coming soon" |
disabled | Admin flipped enabled = false | Hidden from non-admins entirely |
Keep /health cheap and unauthenticated. The portal hits it 2,880 times per app per day. If
your app needs to verify dependencies (DB, queue), do it on a separate /health/deep that's
polled less frequently.
Testing
Two layers. Unit tests use build_claims() / buildClaims() to construct AppClaims
directly — fast, no crypto, no JWKS. End-to-end tests run against a local portal instance so
the wire contract (signature, JWKS lookup, kid resolution) gets exercised too.
Unit tests with fake claims
# tests/test_handlers.py
from fastapi.testclient import TestClient
from shyft_auth.testing import build_claims
from app.main import app, current_user # current_user = require_user(...)
client = TestClient(app)
def test_admin_can_delete():
app.dependency_overrides[current_user] = lambda: build_claims(
app_role="admin", email="alice@shyftsolutions.io"
)
res = client.delete("/records/abc")
assert res.status_code == 204
app.dependency_overrides.clear()
def test_user_cannot_delete():
app.dependency_overrides[current_user] = lambda: build_claims(app_role="user")
res = client.delete("/records/abc")
assert res.status_code == 403
app.dependency_overrides.clear()End-to-end against the local portal
For the wire contract — "does my app actually verify a real signed JWT against the real JWKS?"
— run the portal locally and drive it through an automated browser. The portal mints real RS256
JWTs against its own in-process keypair (persisted in data/jwt-keys.json), so the signature +
JWKS + kid + clock-skew paths are all exercised end-to-end.
Sketch: in CI / test setup, just dev-stack to boot portal + your app, drive a Playwright
(or similar) session through Authentik sign-in, then hit your endpoints — the portal proxies
and forwards the JWT for you. See internal-portal/tests/ for a working example.
A shyft-auth-devserver CLI (mint-a-JWT-without-the-portal) is on the wishlist but not built.
Until then, the local portal is the dev JWT source. For unit-only tests, the build_claims()
approach above sidesteps signing entirely.
Key rotation
The portal signs JWTs with an RS256 keypair. Rotation is quarterly + on-incident and your app does not need to be redeployed for it.
How it works:
- Portal generates a new keypair with a fresh
kid. - Portal publishes both the old and new public keys in
/.well-known/jwks.json. - Portal switches signing to the new key.
- Wait ≥ 61 minutes (1h JWKS cache + 60s JWT lifetime).
- Portal removes the old public key from JWKS.
shyft-auth reads the JWT's kid header, checks its cached JWKS, and re-fetches on miss. The
whole rotation is transparent to your code.
With just dev-stack running, delete the portal's persisted keypair
(rm internal-portal/data/jwt-keys.json) and restart the portal dev process. It generates a
new keypair on cold start, JWKS advertises the new kid, and your app re-fetches on the next
cache miss — no app restart needed. That's the rotation path, end to end.
FAQ
The questions we already know you'll ask. If something isn't covered, ask in #platform and
we'll add it here.
Can I cache the verified claims for the rest of my request?
Yes — verification is the only step that's expensive (the signature check). Once you've called
verify_portal_jwt once, stash the result on request.state / req.shyftClaims / context for
the duration of the request. Do not cache across requests — every request is its own JWT.
What if my app is down when the portal tries to verify it during registration?
Registration succeeds anyway. The admin can hit "Re-discover" later, or type the role names
manually. Tile shows not-deployed until the first successful health probe.
How long do users stay signed in? Do I need to refresh tokens?
User sessions live ~12 hours in the portal's DB. The portal mints a fresh 60s JWT for your app on every proxied request — there's nothing for you to refresh.
Group memberships changed in Authentik. When does my app see the new groups?
On the user's next sign-in. The portal snapshots groups into the session record at sign-in and doesn't re-fetch mid-session. Users with stale groups need to sign out and back in.
Does the portal proxy WebSockets?
Not in v1. Plain HTTP only (every verb, but no Upgrade: websocket). If your app needs
real-time, flag it in #platform and we'll re-evaluate the deferral.
Server-to-server: can my background worker call another app?
Also deferred in v1. There's no end-user identity for a worker request, so the current JWT shape doesn't fit. When a real use case shows up we'll introduce a service-account JWT shape; until then, workers should talk to each other's data through shared DBs or queues.
What if I need to call another Shyft app from my app?
Same answer — deferred. For v1, do data integration at the DB / queue layer, not through the proxy.
Can my app do its own OIDC sign-in instead of going through the portal proxy?
Not today. The portal is the only OIDC client; apps see portal JWTs, not Authentik tokens. An
"apps-as-RPs" mode where shyft-auth exposes a parallel oidcClient() entry point is a future
capability, not a shipped one. File an issue if you have a real use case for it.