Shyft SolutionsDev guideAdding portal auth to your app
GitHub

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.

Library
shyft-auth
Python · Node · more on demand
JWT lifetime
60 seconds
minted per request by the portal
Signing
RS256 + JWKS
rotation transparent to apps
Status
v0.1.0
installs from a git tag, see Install

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.

Starting from an empty repo?

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.

Request path
Browser
portal_session cookie
Portal (Next.js)
this repo
Your app
FastAPI · Flask · Next.js · …
Authentik
federates to Google
Portal JWKS
/.well-known/jwks.json

Three rules that shake out of the topology and constrain everything you build:

Rule 1
Your app never sees portal cookies

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.

Rule 2
Every request is independently authorized

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.

Rule 3
Role names belong to the app

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.

TokenIssued byAudienceLifetimeApps see it?
Authentik ID tokenAuthentikPortal (OIDC client)~1 hourNo
Portal session cookiePortalBrowser only~12 hoursNo
Portal-minted app JWTPortal (RS256)aud=<your-slug>60 secondsYes — every request
Sign-in (happens once, ~12h)
Browser
Portal
Next.js
Authentik
Google
GET /
302 → /authorize (PKCE)
302 → Google OIDC
Render Google login (+ MFA)
callback(code_g)
302 → /api/auth/callback(code_a)
POST /token (code_a)
id_token + refresh + groups

INSERT into user_sessions · snapshot groups · set HttpOnly cookie

302 → / + Set-Cookie portal_session
App launch (happens on every request to your app)
Browser
Portal
BFF proxy
Your app
shyft-auth
GET /apps/training/dashboard + cookie
resolve session · pick app_role from groups
mint JWT (RS256, exp = now + 60s)
GET /dashboard + Authorization: Bearer <jwt>
(cache miss) GET /.well-known/jwks.json
JWKS · cached 1h
verify sig · iss · aud · exp · extract app_role
200 OK
200 OK (streamed)

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"
}
ClaimRequiredLibrary checksWhat you read it for
issYesValidated against your allowlistInformational — don't branch on it
audYesMust equal your app slugDefense in depth — library rejects mismatches
subYesStable user identifier (email today)
exp, iatYes±5s clock skewLibrary rejects expired tokens automatically
name, emailYesDisplay, audit logs, ownership
groupsYesRaw Authentik group names, filtered to those this app cares about
app_roleYes

Resolved by the portal from your group→role mapping. This is what you gate features on.

Stability promise

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.

Recommended: one registration, internal API
Browser
portal_session cookie
Portal
mints JWT (aud=my-app)
Next.js
frontend · the only public face
FastAPI
internal podman net only
Postgres

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.

Why
Admin UX

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.

Why
Audience semantics

aud answers "which app is this token for?" The user sees one app. Splitting aud across deployment layers leaks topology into the security model.

Why
The API isn't user-facing

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.

app/dashboard/page.tsx
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)
  ...
When two registrations make sense

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: https

There 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):

A
Header-driven — preferred for Flask / FastAPI

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.

B
Static prefix — required for Next.js

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.

next.config.ts
// 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)
Which model do I pick?

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.

uv (recommended)
# 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.json
What to set SHYFT_APP_AUD to (and why it matters)

What: 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.local in the app repo, loaded by your framework or dotenv.
  • 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.
Depends factory
# 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:

handler
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.

Coming soon

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

pyproject.toml
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 ci
services:
app:
  build:
    context: .
    secrets: [ssh_key]
secrets:
ssh_key:
  file: ${SHYFT_SSH_KEY:-${HOME}/.ssh/id_rsa_shyft}
Break-glass: build the wheel/tarball offline

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:

  1. Read the bearer token from the Authorization header.
  2. Decode the JWT header to extract kid.
  3. Fetch the JWKS (cached) from the portal, look up the public key.
  4. Verify the RS256 signature.
  5. Check iss against your allowlist, aud against your slug, exp + iat with ±5s skew.
  6. Return a strongly-typed AppClaims object — 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

ErrorMeansRecommended response
MissingTokenErrorNo Authorization header on the request.401 · "authentication required"
InvalidSignatureErrorJWKS lookup OK but signature does not verify.401 · log it, this should not happen with a healthy portal
ExpiredTokenErrorexp in the past beyond skew.401 · client should retry through portal
AudienceMismatchErroraud in the JWT does not equal your expected_aud.401 · usually means your app was deployed with the wrong slug
IssuerMismatchErroriss not in your allowlist.401 · likely a misconfigured env var
InvalidTokenErrorCatch-all: malformed token, unsupported alg, JWKS resolution failure, missing required claim.401 · the base PortalAuthError handler covers it

Reusable error-to-HTTP mapping

exception handler
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)
Authorization vs. authentication

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.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.

handler
# 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.

dependency
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.

Mapping resolution at sign-in
User's groups
alice ∈ training-admins, training-users
Mappings (sorted)
1 · training-admins → admin 2 · training-users → user
app_role
admin
Why groups use UUIDs, not names

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().

Dev-time topology — real portal
Browser
signed in via Authentik
Portal
localhost:3000, mints JWT
Your app
verifies with shyft-auth

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-stack

2. 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.json

3. 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.

Test key rotation locally

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.

Coming soon — Phase H

/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.

portal.internal.shyftsolutions.io / admin / apps / new

Register a new app

Step 1 of 3 · Identity

Slug

training — appears in /apps/<slug>/ and as aud in the JWT

Display name
Training Tracker
Base URL
http://training:8000 (internal podman name)
Health path
/health
Category
Compliance
Status

live · beta · alpha · planned

portal.internal.shyftsolutions.io / admin / apps / new — step 2

Discover roles

Step 2 of 3 · Pulled from /.well-known/app-roles

  • user · Submit training records

  • approver · Approve or reject submissions

  • admin · 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.

portal.internal.shyftsolutions.io / admin / apps / new — step 3

Map groups → roles

Step 3 of 3 · Drag rows to reorder priority — lowest number wins

⋮⋮ 1training-adminsadminuuid linked
⋮⋮ 2training-approversapproveruuid linked
⋮⋮ 3training-usersuseruuid linked
⋮⋮ 4employeesuseruuid linked

The 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-users
Cross-app shortcut

Want 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
}
StateTriggerTile appearance
liveLast poll 2xx within 60sGreen dot · clickable
degradedLast poll 3xx / 4xx (besides 404)Orange dot · clickable (warning banner inside app)
unreachable5xx or timeout for > 2 consecutive pollsRed dot · clickable (502 page if launched)
not-deployedRegistered < 5 min ago, never returned 2xxGrey dot · "Coming soon"
disabledAdmin flipped enabled = falseHidden from non-admins entirely
Tip

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

FastAPI dependency_overrides
# 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.

No standalone JWT minter today

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:

  1. Portal generates a new keypair with a fresh kid.
  2. Portal publishes both the old and new public keys in /.well-known/jwks.json.
  3. Portal switches signing to the new key.
  4. Wait ≥ 61 minutes (1h JWKS cache + 60s JWT lifetime).
  5. 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.

Verify locally

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.