Build an app on the Shyft portal.
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.
How it fits together
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
| 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 |
user_sessions · snapshot groups · set HttpOnly cookieWhy 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
iss; rely on aud, sub, groups, and app_role.{
// standard claims
"iss": "https://portal.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-authcoordinated with the portal's minter — your pinned library version protects you from surprises.
Shape your app
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
from fastapi import Depends, HTTPException, Request
from shyft_auth import (
AppClaims,
MissingTokenError,
PortalAuthError,
verify_portal_jwt,
)
def require_user(request: Request) -> AppClaims:
try:
return verify_portal_jwt(
request,
expected_aud="my-app",
expected_iss="https://portal.shyftsolutions.io",
jwks_url="https://portal.shyftsolutions.io/.well-known/jwks.json",
)
except MissingTokenError:
raise HTTPException(401, "Not authenticated")
except PortalAuthError:
raise HTTPException(401, "Invalid credentials")
# usage:
# @app.get("/things")
# def list_things(user: AppClaims = Depends(require_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.
Quickstart
1. Install the library
shyft-authis published to GitHub Packages — npm for Node, PyPI for Python. You'll need a personal access token with read:packages scope. See Install & configure for the auth-token dance.
# uv (recommended)
uv add shyft-auth --index https://__token__:$GH_TOKEN@pypi.pkg.github.com/shyftsolutions/
# or pip
pip install shyft-auth --index-url https://__token__:$GH_TOKEN@pypi.pkg.github.com/shyftsolutions/2. Tell the library who you are
Set three environment variables. The portal will give you the slug when you register your app — until then, pick something descriptive (it's the identifier you'll request from the admin).
# The aud claim the portal will mint for your app
SHYFT_APP_AUD=training
# The portal's JWKS endpoint (no trailing slash)
SHYFT_PORTAL_ISSUER=https://portal.shyftsolutions.io
SHYFT_PORTAL_JWKS_URL=https://portal.shyftsolutions.io/.well-known/jwks.json3. 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
from fastapi import FastAPI, Depends
from shyft_auth.fastapi import verify_portal_jwt
from shyft_auth import AppClaims
app = FastAPI()
@app.get("/whoami")
def whoami(claims: AppClaims = Depends(verify_portal_jwt(expected_aud="training"))):
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 app_roles_router
app.include_router(app_roles_router([
{"name": "user", "description": "Submit training records"},
{"name": "admin", "description": "Manage all training data"},
]))
# now GET /.well-known/app-roles → { "roles": [...] }5. Register your app with the portal
Ask a portal admin (or use the /admin/appsUI 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
1. Generate a token
Visit github.com/settings/tokens/new (classic) and create a token with only the read:packagesscope. Set the expiry to whatever your team's policy allows. Save the token in your password manager — it's shown once.
2. Configure your package manager
[global]
extra-index-url = https://__token__:ghp_yourTokenHere@pypi.pkg.github.com/shyftsolutions/In CI, set NPM_TOKEN / GH_TOKEN as a repo secret and interpolate it into a project-local .npmrc or pip.conf as part of the workflow — never commit one with a real token in it.
3. Verify the install worked
python -c "from shyft_auth import __version__; print(__version__)"
# 0.1.0Verify a request
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 |
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 nottell 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
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 app_roles_router
from .roles import APP_ROLES
app.include_router(app_roles_router(APP_ROLES))GET /.well-known/app-roles HTTP/1.1
Host: training.shyftsolutions.internal
HTTP/1.1 200 OK
Content-Type: application/json
{
"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 verify_portal_jwt
def require_role(role: str):
def dep(claims: AppClaims = Depends(verify_portal_jwt(expected_aud="training"))):
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 = Depends(require_role("admin"))):
...How groups become roles
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
@shyft/auth-devserver is a tiny standalone process that mints JWTs against a localhost JWKS endpoint — point your app at it instead of the portal.1. Run the devserver
# anywhere — no install needed
npx @shyft/auth-devserver
# devserver listens on :9999 by default
# GET /.well-known/jwks.json - the public key
# GET /login - mint a token via web form
# POST /mint - mint a token via JSON2. Point your app at it
SHYFT_APP_AUD=training
SHYFT_PORTAL_ISSUER=http://localhost:9999
SHYFT_PORTAL_JWKS_URL=http://localhost:9999/.well-known/jwks.json3. Mint a token and hit your endpoint
# 1. Open http://localhost:9999/login in a browser
# 2. Fill: aud=training, app_role=admin, groups=training-admins,employees
# 3. Submit — page shows the JWT, sets a dev_jwt cookie
# 4. Click "Copy curl" and run itPass --rotate-keyto the devserver to force a new keypair. Your app's cached JWKS will miss on the next request and refetch — confirming your production rotation will be transparent.
The devserver's private key lives on disk in plaintext at ~/.cache/shyft-auth-devserver/keys.json. The production shyft-auth library has no devserver codepath at all. The dev-vs-prod distinction is which JWKS URL you point at, nothing more.
@shyft/auth-devserver publishes to GitHub Packages in Phase D of the initial-auth plan. Until then, clone the shyft-auth-devserver repo and pnpm dev.
Portal admin walkthrough
/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.
- 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
/.well-known/app-rolesuser· Submit training recordsapprover· Approve or reject submissionsadmin· Manage everyone's training data
training-admins→ adminuuid linkedtraining-approvers→ approveruuid linkedtraining-users→ useruuid linkedemployees→ useruuid linkedGroups & mappings
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 groupswork 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 → adminat priority 0 in each app's mappings. Lower number wins, so it'll always beat per-app mappings.
Health & liveliness
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/deepthat's polled less frequently.
Testing
buildClaims() / build_claims() helper to construct fake claims without touching crypto — fast, no JWKS. Integration tests boot the dev-server and mint real signed JWTs — slow, but proves the wire contract.Unit tests with fake claims
# tests/test_handlers.py
from fastapi.testclient import TestClient
from shyft_auth.testing import build_claims
from shyft_auth.fastapi import verify_portal_jwt
from app.main import app
client = TestClient(app)
def test_admin_can_delete():
fake_admin = build_claims(app_role="admin", email="alice@shyftsolutions.io")
app.dependency_overrides[verify_portal_jwt(expected_aud="training")] = lambda: fake_admin
res = client.delete("/records/abc")
assert res.status_code == 204
def test_user_cannot_delete():
fake_user = build_claims(app_role="user")
app.dependency_overrides[verify_portal_jwt(expected_aud="training")] = lambda: fake_user
res = client.delete("/records/abc")
assert res.status_code == 403Integration test with the devserver
For the wire contract — "does my app actually verify a real signed JWT against a real JWKS" — boot @shyft/auth-devserveras a fixture, mint a token, and hit your handler. This is what the library's own CI does for every pull request.
# tests/conftest.py
import subprocess, time, requests, pytest
@pytest.fixture(scope="session")
def devserver():
proc = subprocess.Popen(["npx", "-y", "@shyft/auth-devserver", "--port", "19999"])
for _ in range(30):
try:
requests.get("http://localhost:19999/.well-known/jwks.json", timeout=0.5)
break
except Exception:
time.sleep(0.2)
yield "http://localhost:19999"
proc.terminate()
@pytest.fixture
def mint(devserver):
def _mint(**claims):
return requests.post(f"{devserver}/mint", json=claims).json()["token"]
return _mintKey rotation
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.
Run npx @shyft/auth-devserver --rotate-keywhile your app is hitting it. Your next request should succeed without restart — that's the rotation path.
FAQ
#platformand 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 yourapp 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 #platformand 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.
How do I migrate from the portal proxy to a standalone OIDC client (topology A)?
shyft-auth exposes oidcClient() / oidc_client() as a parallel entry point with the same AppClaimsshape. Swap the import, wire up the callback handler, point Authentik at your new subdomain — your business logic doesn't change. See .claude/artifacts/initial-design/docs/internal-auth-contract.md for the full cutover playbook.
Can I read issto decide which mode I'm running in?
No. Mode is chosen by which entry point you import, not by inspecting the token. Hidden mode-switching is the kind of bug that surfaces six months later in production.
This guide condenses .claude/features/initial-auth/plan.md, .claude/features/initial-auth/research.md, and the four design docs under .claude/artifacts/initial-design/docs/. When the docs and this page disagree, the design docs win — file an issue and we'll reconcile.