SSO / SAML¶
The TaskQ admin UI supports optional single sign-on (SSO) via OIDC (primary,
for Microsoft Entra ID and any OIDC-compliant provider) and SAML (for legacy
IdPs), behind a shared abstraction. Both are purely additive: the default
(TASKQ_SSO_BACKEND=none) remains the unauthenticated / bring-your-own-auth
behavior described in admin-ui.md.
Which one should I use?¶
OIDC (taskq[oidc]) |
SAML (taskq[saml]) |
|
|---|---|---|
| Native dependencies | None (pure Python) | None on common platforms; see SAML container requirements |
| Setup complexity | Lower | Slightly higher (IdP metadata exchange; no container changes needed on common platforms) |
| Entra support | App registrations (recommended) | Enterprise applications (SAML gallery/non-gallery) |
| When to use | Default for new integrations | Only if an IdP or compliance requirement mandates SAML |
Default to OIDC. It requires no system packages, no container changes, and works against any OIDC provider. Reserve SAML for IdPs that genuinely require it.
Quick start: OIDC with taskq ui serve¶
pip install 'taskq-py[fastapi]' 'taskq-py[oidc]'
export TASKQ_SSO_BACKEND=oidc
export TASKQ_OIDC_ISSUER='https://login.microsoftonline.com/{tenant}/v2.0'
export TASKQ_OIDC_CLIENT_ID='your-client-id'
export TASKQ_OIDC_CLIENT_SECRET='your-client-secret'
export TASKQ_OIDC_REDIRECT_URI='https://admin.example.com/admin/callback'
export TASKQ_OIDC_SESSION_SECRET='$(python -c "import secrets; print(secrets.token_urlsafe(32))")'
taskq ui serve
The CLI reads TASKQ_SSO_BACKEND and the matching TASKQ_OIDC_* settings,
builds the auth bundle, and mounts the /login, /callback, and /logout
routes alongside the admin router at /admin. When TASKQ_SSO_BACKEND=oidc
(or saml), a non-None auth_dependency is passed to create_router, which
also satisfies the admin_ui_require_auth fail-closed check.
TASKQ_SSO_BACKEND=none (the default) preserves today's unauthenticated /
BYO-auth behavior unchanged.
Cookie security in local dev¶
secure_cookie is derived from TASKQ_ENVIRONMENT: set
TASKQ_ENVIRONMENT=dev (or development) to use non-secure cookies over
local http://localhost. In any other environment, cookies are Secure
(HTTPS only).
OIDC configuration¶
OIDCAuthConfig¶
| Field | Type | Default | Env var |
|---|---|---|---|
issuer |
str |
n/a | TASKQ_OIDC_ISSUER |
client_id |
str |
n/a | TASKQ_OIDC_CLIENT_ID |
client_secret |
str |
n/a | TASKQ_OIDC_CLIENT_SECRET |
redirect_uri |
str |
n/a | TASKQ_OIDC_REDIRECT_URI |
session_secret |
str |
n/a | TASKQ_OIDC_SESSION_SECRET |
session_max_age_seconds |
int |
28800 (8h) |
TASKQ_OIDC_SESSION_MAX_AGE_SECONDS |
scope |
str |
openid profile email |
TASKQ_OIDC_SCOPE |
group_claim |
str \| None |
None |
TASKQ_OIDC_GROUP_CLAIM |
allowed_groups |
str |
"" (empty) |
TASKQ_OIDC_ALLOWED_GROUPS (comma-separated) |
group_resolver |
Callable \| None |
None |
(programmatic only) |
session_secret should be at least 32 bytes of random data. Rotating it
invalidates every outstanding session at once, since there is no session store to flush.
Login flow¶
/login: generates a PKCEcode_verifier+state, stores both in a short-lived signed cookie (separate from the session cookie), and redirects to the IdP authorization endpoint./callback: validatesstate, exchanges the code for tokens, validates the ID token (issuer, audience, signature via JWKS), extracts claims intoIdentityClaims, sets the session cookie, and redirects to the admin UI root./logout(POST), requires a CSRF token bound to the live session (the admin UI's Sign out control posts it), clears the session cookie, and redirects to the admin root.GET /logoutis refused (405), so a forced top-level navigation from any page cannot clear an admin session (see Logging out).
On any error during /callback (token exchange failure, JWKS fetch timeout,
invalid ID token), the user is redirected with a generic
?error=authentication+failed; never raw exception text. The full
exception is logged server-side.
Discovery and JWKS caching¶
/login needs the issuer's discovery document and /callback needs the
discovery document plus the JWKS. Both are cached in memory per issuer with
a 300 s TTL (bounded to the most recently used 16 issuers), so a flood of
unauthenticated /login requests costs at most one outbound fetch per
issuer per TTL window instead of one per request. A refresh that fails
invalidates the issuer's cache entry, the next request starts from a fresh
fetch rather than serving a document the IdP would not refresh, and a
rotated IdP signing key is picked up when the entry expires, at most one
TTL window later. The cache is process-local, like every store in the SSO
layer; replicas fetch independently.
Group overage (Entra-specific)¶
Once a user belongs to more than ~200 groups, Entra omits the groups claim
from the ID token and emits a _claim_names/hasgroups marker instead. The
optional group_resolver callable handles this: it receives an
OIDCTokenContext (ID token claims + access token) and returns a
frozenset[str] of groups, typically by calling Microsoft Graph
/me/memberOf. To use it, add Group.Read.All to TASKQ_OIDC_SCOPE.
A reference Graph-API resolver (using httpx2) ships as a documented example;
it is not a hard dependency of taskq[oidc]:
import httpx2
from taskq.web.admin.auth import OIDCAuthConfig, OIDCTokenContext, create_oidc_auth
async def graph_group_resolver(ctx: OIDCTokenContext) -> frozenset[str]:
if ctx.access_token is None:
return frozenset()
headers = {"Authorization": f"Bearer {ctx.access_token}"}
groups: set[str] = set()
url = "https://graph.microsoft.com/v1.0/me/memberOf?$select=id"
while url:
resp = await httpx2.AsyncClient().get(url, headers=headers)
resp.raise_for_status()
data = resp.json()
for g in data.get("value", []):
if g.get("@odata.type", "").endswith("group"):
groups.add(g["id"])
url = data.get("@odata.nextLink")
return frozenset(groups)
config = OIDCAuthConfig(
issuer="https://login.microsoftonline.com/{tenant}/v2.0",
client_id="...",
client_secret="...",
redirect_uri="https://admin.example.com/admin/callback",
session_secret="...",
scope="openid profile email Group.Read.All",
group_claim="groups",
allowed_groups=frozenset({"admin-group-object-id"}),
group_resolver=graph_group_resolver,
)
bundle = create_oidc_auth(config, base_path="/admin")
SAML configuration¶
SAMLAuthConfig¶
| Field | Type | Default | Env var |
|---|---|---|---|
entity_id |
str |
n/a | TASKQ_SAML_ENTITY_ID |
acs_url |
str |
n/a | TASKQ_SAML_ACS_URL |
idp_entity_id |
str |
n/a | TASKQ_SAML_IDP_ENTITY_ID |
idp_sso_url |
str |
n/a | TASKQ_SAML_IDP_SSO_URL |
idp_x509_cert |
str (PEM) |
n/a | TASKQ_SAML_IDP_X509_CERT |
sp_x509_cert |
str \| None |
None |
TASKQ_SAML_SP_X509_CERT |
sp_private_key |
str \| None |
None |
TASKQ_SAML_SP_PRIVATE_KEY |
session_secret |
str |
n/a | TASKQ_SAML_SESSION_SECRET |
session_max_age_seconds |
int |
28800 |
TASKQ_SAML_SESSION_MAX_AGE_SECONDS (same default as OIDC) |
group_attribute |
str \| None |
None |
TASKQ_SAML_GROUP_ATTRIBUTE |
allowed_groups |
str |
"" (empty) |
TASKQ_SAML_ALLOWED_GROUPS (comma-separated) |
allow_cookieless_fallback |
bool |
false |
TASKQ_SAML_ALLOW_COOKIELESS_FALLBACK |
Routes¶
/login: builds a SAMLAuthnRequestand redirects to the IdP SSO URL./callback(POST, the ACS endpoint): validates the signed SAML response, requires the AuthnRequest correlation cookie set by/login(see Session handling; a cookie-less callback is refused unlessallow_cookieless_fallbackis on), extracts the NameID + attributes intoIdentityClaims, sets the session cookie, and redirects to the admin root./metadata(GET): returns SP metadata XML for IdP configuration./logout(POST), requires the session-bound CSRF token; clears the session cookie (see Logging out).
v1 supports SP-initiated flow only (the user hits /login first). IdP-initiated
SSO is a non-goal for v1.
SAML container requirements¶
python3-saml depends on the xmlsec Python package, which historically
bound to the system libxmlsec1 C library at both build and runtime. As
currently pinned, this is no longer the case on common platforms: xmlsec
ships prebuilt manylinux/musllinux wheels (Linux x86_64/aarch64, both
glibc and musl) as well as macOS and Windows wheels, each bundling its
native dependencies internally, confirmed via ldd against the installed
extension module, which links only against base glibc (libc, libm,
libpthread, librt), nothing libxmlsec1/libxml2/libssl-related.
No system package installation is required to install or run
taskq-py[saml] on any of these platforms: a plain uv add "taskq-py[saml]"
(or pip install) into your application environment is sufficient, no
system packages and no base-image rebuild needed.
Container installs: the production image ships no pip and no uv (both are removed at build time — see the Dockerfile's runtime stage), so
pip install taskq-py[saml]inside ataskq-worker-based container fails withpip: not found. Extras must be added by your extending Dockerfile. Note the build inherits the image's non-rootUSER, so the install step needsUSER rootand an out-of-$HOMEuv cache (/appis root-owned):FROM taskq-worker # this repo's Dockerfile, as built by CI USER root COPY --from=ghcr.io/astral-sh/uv:0.12.21 /uv /usr/local/bin/uv RUN UV_CACHE_DIR=/tmp/uv-cache uv pip install --python /app/.venv/bin/python "taskq-py[saml]" USER taskq COPY myapp/ myapp/ CMD ["taskq", "worker", "--actors", "myapp.actors:registry"](The published distribution is
taskq-py;taskq[saml]on PyPI resolves to an unrelated package with nosamlextra.)
The one case that still needs system build dependencies is an unsupported
platform/architecture with no matching prebuilt wheel (e.g. a niche or very
new architecture), where the resolver would fall back to building xmlsec
from source. If that happens, you'll need:
RUN apt-get update && apt-get install -y --no-install-recommends \
libxml2-dev libxmlsec1-dev libxmlsec1-openssl pkg-config build-essential \
&& rm -rf /var/lib/apt/lists/*
Check which case applies to your deployment target by running
uv add "taskq-py[saml]" (or pip install "taskq-py[saml]") directly; if it
resolves a wheel (no compilation step in the install output), no system
packages are needed.
Authorization model¶
Default: authentication-only (IdP-side assignment)¶
When no group field is configured (group_claim=None for OIDC,
group_attribute=None for SAML), any user who completes the login flow is
authorized. This is the recommended configuration for Entra regardless of
protocol: enable "User assignment required" on the enterprise application
and assign the specific users/groups who should have admin access. Entra then
refuses to issue a token/assertion to anyone not assigned, so the app never sees
a login attempt from an unauthorized user, no group-claim parsing is needed, and
the group-overage edge case never comes up.
Optional: group/role allowlist¶
When configured, the auth dependency additionally checks IdentityClaims.groups
against allowed_groups. Empty intersection = 401 (redirect to /login for
browser navigation). Use this for app-side, in-repo-configurable control instead
of (or in addition to) IdP-side assignment, or with IdPs that lack an
app-assignment concept.
Fail-closed: if allowed_groups is non-empty but group membership cannot be
determined (the claim/attribute is absent and no group_resolver is configured),
the login is rejected; no session cookie is issued. The user is never silently
authorized.
Entra ID app-registration walkthrough¶
OIDC (app registration)¶
- In the Entra portal, go to App registrations → New registration.
- Set the Redirect URI to
https://admin.example.com/admin/callback(Web platform). - Note the Application (client) ID and Directory (tenant) ID.
- Under Certificates & secrets, create a client secret →
TASKQ_OIDC_CLIENT_SECRET. - Set
TASKQ_OIDC_ISSUERtohttps://login.microsoftonline.com/{tenant_id}/v2.0. - (Optional) Under Token configuration, add the
groupsclaim to the ID token, then setTASKQ_OIDC_GROUP_CLAIM=groupsandTASKQ_OIDC_ALLOWED_GROUPSto the allowed group object IDs. - (Optional, overage fallback) Add Microsoft Graph →
Group.Read.All (delegated) permission and set
TASKQ_OIDC_SCOPE=openid profile email Group.Read.Allwith agroup_resolver.
SAML (enterprise application)¶
- In the Entra portal, go to Enterprise applications → New application → Create your own application → "Non-gallery" SAML app.
- Under Single sign-on → SAML, set:
- Identifier (Entity ID) =
TASKQ_SAML_ENTITY_ID - Reply URL (ACS URL) =
TASKQ_SAML_ACS_URL - Download the IdP certificate →
TASKQ_SAML_IDP_X509_CERT(PEM). - Set
TASKQ_SAML_IDP_ENTITY_IDandTASKQ_SAML_IDP_SSO_URLfrom the Entra-provided metadata. - Visit
https://admin.example.com/admin/metadatato fetch SP metadata for Entra's "Upload metadata file" option (or enter the values manually). - (Optional) Under User attributes & claims, add a group claim
(attribute name
http://schemas.microsoft.com/ws/2008/06/identity/claims/groups) → setTASKQ_SAML_GROUP_ATTRIBUTEto that name andTASKQ_SAML_ALLOWED_GROUPSto the allowed group object IDs. - Enable User assignment required and assign the users/groups who should have admin access.
Mounting into an existing FastAPI app¶
Instead of taskq ui serve, embed the admin router and SSO router together:
from contextlib import asynccontextmanager
import asyncpg
from fastapi import FastAPI
from taskq.settings import TaskQSettings
from taskq.web.admin import create_router, setup_admin_state
from taskq.web.admin.auth import OIDCAuthConfig, create_oidc_auth
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = TaskQSettings.load()
pool = await asyncpg.create_pool(str(settings.pg_dsn))
oidc = settings.oidc
sso_config = OIDCAuthConfig(
issuer=oidc.issuer,
client_id=oidc.client_id,
# Settings secret fields are SecretStr (masked in any repr or error);
# unwrap at the boundary where the runtime auth config needs the
# plain string.
client_secret=oidc.client_secret.get_secret_value(),
redirect_uri=oidc.redirect_uri,
session_secret=oidc.session_secret.get_secret_value(),
group_claim=oidc.group_claim,
allowed_groups=oidc.allowed_groups_set,
)
sso_bundle = create_oidc_auth(sso_config, base_path="/admin")
bundle = create_router(
pool,
schema=settings.schema_name,
auth_dependency=sso_bundle.dependency,
base_path="/admin",
)
setup_admin_state(app, bundle)
app.include_router(sso_bundle.router, prefix="/admin")
app.include_router(bundle.router, prefix="/admin")
yield
await pool.close()
app = FastAPI(lifespan=lifespan)
The SAML equivalent uses SAMLAuthConfig and create_saml_auth; the wiring is
identical. SSO sub-configs are separate DotEnvConfig classes with their own
env_prefix (TASKQ_OIDC_*, TASKQ_SAML_*), accessed via lazy properties:
settings.oidc.issuer, settings.saml.entity_id, etc. The env var names are
unchanged from the flat-field layout.
Session handling¶
Sessions are stateless signed cookies (itsdangerous.URLSafeTimedSerializer),
no Redis/DB dependency. The cookie payload stores only subject, email, and
groups (as a sorted list). Cookie flags: HttpOnly, Secure (configurable),
SameSite=Lax.
The SAML backend also sets a second, short-lived cookie (taskq_saml_request,
5 minutes) that correlates the ACS callback with the AuthnRequest that started
the login. A hosted IdP POSTs its assertion from a different site, and a
browser withholds a SameSite=Lax cookie from a cross-site POST, so this one
is marked SameSite=None when secure_cookie is on and is scoped to the
/callback path alone. The session cookie's policy is untouched.
The OIDC backend sets the analogous short-lived cookie (taskq_oidc_state,
5 minutes) carrying the state, the PKCE code_verifier, and the nonce in
one signed record. It is scoped to the callback route the same way, it is
consumed by exactly one route, so it is offered on exactly one.
The correlation cookie is the binding: the callback accepts an assertion only
when its InResponseTo matches the request ID the signed cookie carries
(python3-saml enforces the same comparison inside process_response), and the
cookie is single-use: cleared on every callback outcome, and its request ID
recorded as answered so a re-supplied captured copy cannot buy a second
assertion on the process that answered it, with a 300 s TTL. Because it is
signed with session_secret, any replica sharing the secret can verify
it: multi-replica deployments and uvicorn --workers N need no sticky
sessions, and a login whose callback lands on a different process than the
one that issued it completes normally.
One caveat remains, and it is a different one than it used to be: the
consumed-assertion replay record and the answered-request record live in a
shared Postgres table (saml_replay_store, created by the bundled
migrations) that every replica reads and writes, so a captured ACS POST
replayed against a sibling process is refused there the same as on the
accepting process, and no flood of valid assertions can evict a consumed
record (rows expire by TTL, never by pressure). The store needs the admin
app's Postgres pool, which taskq ui serve always wires; mounting the SAML
router on a bare app of your own (no pg_pool/schema in app.state)
falls back to per-process records with exactly the old limits. Over HTTPS a
captured POST still implies a compromised client, a MITM, or a TLS break,
each of which already yields session theft directly.
The cookie-less fallback (opt-in, default off)¶
A hosted IdP's ACS POST is a genuine cross-site POST, and some browsers
withhold even a SameSite=None cookie from it (third-party cookie blocking,
privacy modes). On a default deployment such a callback is refused, so the user
sees the standard login error and can retry from the same browser, which
re-issues a fresh correlation cookie.
Deployments that must serve cookie-blocking browsers can opt in:
The callback then also accepts an assertion with no usable cookie when, after
full signature and timestamp validation, its InResponseTo names an
AuthnRequest the receiving process issued in the last 5 minutes and has not
yet spent.
The tradeoff, stated directly: nothing ties that response to the browser posting it. A party who starts a login, authenticates at the IdP as themselves, and captures the signed response without posting it to the ACS can have a cookie-less victim's browser POST it within the 5-minute window; the victim receives a session cookie for that party's NameID. The replay cache only blocks the second presentation of the same assertion. If your threat model cannot carry that, leave the flag off.
Two operational consequences of opting in:
- The pending-request record is per process. A cookie-less callback must land on the same process that issued the login: put the SSO routes behind sticky sessions, or expect an occasional retry when the browser withholds the cookie and the callback lands on a different replica than the login. This is the only configuration that needs sticky sessions: the cookie path above works on any replica.
- The record is capped (10,000 entries, soonest-to-expire eviction) and
/loginis unauthenticated, so a flood of login starts can evict a real pending ID and force that one cookie-less login to retry. The cookie path is immune to eviction pressure.
Why OIDC does not have this fallback¶
The OIDC callback is a top-level GET redirect, which browsers allow
SameSite=Lax cookies on: the state cookie (carrying state, the PKCE
code_verifier, and the nonce in one signed cookie) arrives where the SAML
correlation cookie does not, because the ACS POST is not a safe-method
navigation. PKCE also makes a cookie-less OIDC callback structurally unable
to complete: without the code_verifier from the cookie there is no token
exchange at all, and the nonce binds the ID token to the login that started
it. OIDC therefore took the stateless-no-fallback tradeoff: a browser that
loses the state cookie cannot log in, and has neither the
cross-replica dependency nor the cookie-less acceptance shape.
The auth dependency re-checks the group allowlist on every request, so changing
allowed_groups takes effect immediately for existing sessions (a user whose
group no longer intersects the allowlist gets 401 on the next request). Rotating
session_secret invalidates all sessions at once.
Long-lived SSE streams are the one place a per-request check is not
enough, so both streaming endpoints (the per-job progress stream and the
admin /sse/{topic} channel) re-run the same session verification while the
stream is open: before every streamed event and at every keepalive tick. A
session invalidated mid-stream -- a session that ages past
max_age_seconds, an allowed_groups change that excludes the identity,
or a rotated session_secret -- ends the live stream within one keepalive
interval (at most 60 seconds, whatever sse_heartbeat_interval is set to; a
larger interval is clamped to that 60 s cap and the clamp is logged at
startup); the browser's EventSource reconnects, is refused with 401, and
the user logs in again.
What the re-check consults, exactly: the request's cookie bytes and nothing else. There is no session store, no database round trip, no IdP call -- one signature verification per tick, microseconds of CPU. That is the same statelessness the per-request check has, and it carries the same limits:
- Server-side revocation of a still-validly-signed, still-unexpired
cookie is impossible. "Log this session out" is not an operation a
stateless cookie supports; a revocation reaches a live stream only by
making its cookie stop verifying (rotate the secret, let it age out, or
change
allowed_groups). - Rotation is read per tick from the session manager's signing key.
Assigning a new
session_secret(the manager built the stream's re-check around the manager object, somanager.secret = "new"-- the rotationsession_secret's "rotate to invalidate all sessions" describes) ends every live stream within one tick. Rebuilding the router with a fresh manager ends new requests, but a stream already running keeps the verifier it was built with. A secret restored to its old value within one keepalive tick re-validates the streams it briefly ended; that one-tick window is inherent to a stateless cookie design. - Each re-check invocation is bounded (5 s). A host-supplied verifier that hangs -- a wedged introspection endpoint -- is treated as revocation, fail closed, rather than freezing the stream inside its own keepalive path.
Streams built with taskq's own create_auth_dependency or token_auth get
the re-check automatically. A host supplying its own auth_dependency
must pass a session_verifier (Callable[[Request], Awaitable[bool]])
to create_router explicitly to keep the re-check: a third-party dependency
carries no verifier attribute for the router to derive, the stream then
authenticates once (the pre-#316 behavior), and both routers warn at
startup -- admin-sse-no-session-verifier and
progress-stream-no-session-verifier, once per router, not per request.
Logging out¶
Logout is a POST, not a GET. Both backends refuse GET /logout (405), so a
forced top-level navigation, a link, an image, a redirect from any page ,
cannot clear an admin session, and the POST must carry a CSRF token derived
from the live session cookie (an HMAC of the cookie value under
session_secret). The admin UI's Sign out control is a small POST form
that the server renders with that token in a hidden field whenever an SSO
session is live, and the token ends where the session ends: a fresh login
mints a fresh value, and a logged-out or expired session has no valid token
at all. A cross-site attacker has neither the HttpOnly session cookie the
token is derived from nor session_secret, so a forged form POST cannot
carry a valid token either; the SameSite=Lax session cookie would not ride
a cross-site POST in the first place.
Integrations that logged out by fetching GET /admin/logout must switch to
an authenticated POST /admin/logout with the token. There is no endpoint
that ends a session without one.
Machine-token auth (token_auth)¶
For endpoints that serve machine-to-machine traffic (Prometheus scrapers,
kubelet probes, CI scripts) an interactive OIDC/SAML redirect isn't practical.
token_auth provides a lightweight bearer-token dependency with no extra
dependencies:
from taskq.web.admin.auth import token_auth
health_dependency = token_auth("your-secret-token")
# Use as auth_dependency on specific routes or the whole router:
app.include_router(
create_router(pool, auth_dependency=health_dependency, base_path="/admin"),
prefix="/admin",
)
The dependency uses hmac.compare_digest for timing-safe comparison and
raises HTTPException(401) on missing or mismatched tokens. Passing an empty
string to token_auth() raises ValueError; an empty token is never
accepted.
When using taskq ui serve, set TASKQ_HEALTH_TOKEN instead of wiring
token_auth manually; the CLI applies it to health and metrics routes
automatically (see admin-ui.md).