Skip to content

Signing in

SSO only, through Authentik at sso.pedshub.com — "One Sign In".

There is no sign-up form, no invite code and no email sign-in code. All three were this application doing the identity provider's job, and all three were removed; a migration drops their tables.

Every password door is closed. POST /auth/login answers 410 Gone, and /register in the browser redirects to /login. The address is kept so an old client is told plainly rather than getting a 404.

The flow

GET  /api/v1/auth/sso/login          302 to the provider
     …the person signs in there…
GET  /api/v1/auth/sso/callback       match on the email claim, apply the role,
                                     park the token in Redis under a one-time
                                     code, redirect with ?code=
POST /api/v1/auth/sso/exchange       spend the code (GETDEL), return the token

Three details that are load-bearing:

  • sso/login must stay async and the redirect must be awaited. authlib's Starlette client returns a coroutine, and returning it unawaited is a 500 on every click.
  • The callback refuses an explicit email_verified: false.
  • The access token never travels in a URL. It is parked in Redis under sso:exchange:<code> for 60 seconds and exchanged in a POST body. nginx logs the request line, so a token in a query string is written to disk on every sign-in.

Accounts are created on first sign-in, matched by email address. A different address is a different account.

Roles from groups

With OIDC_ROLE_CLAIM set, services/sso_roles.apply() runs at every SSO sign-in and brings the role into line with the person's groups — so removing somebody from a group at the provider takes the role away here at their next sign-in.

pedshub-admins and pedshub-moderators are the groups on this deployment. Admin wins over moderator. The one demotion the sync refuses is the last administrator.

While the mapping is on, the two in-app role endpoints answer 409 — see Access.

Staying signed in

An access token is a signed statement good for a day. Nothing consults a table before believing it, so it cannot be withdrawn — fine for a browser, which can send the person back to a sign-in page, and no use to an app, which would have to keep a credential to survive the night.

So a client that asks for one gets a refresh token:

POST /api/v1/auth/sso/exchange
{ "code": "<one-time code>", "refresh": true, "device": "PedsHub for iPhone" }
Endpoint Does
POST /api/v1/auth/refresh Trades a refresh token for a new pair. The old one is spent.
POST /api/v1/auth/logout Ends this session, or all of them with everywhere: true.
GET /api/v1/auth/sessions Where this account is signed in — one row per device, not per rotation.
DELETE /api/v1/auth/sessions/{family} Ends one of them.

Three properties:

  1. Rotation. Every refresh issues a new token and spends the old one.
  2. A spent token coming back ends the whole session. Either it was copied or a client is replaying, and from the server those look identical — so the safe reading is the unsafe one. A thief who spends a token first makes the real client's next attempt fail, which is the theft announcing itself.
  3. Only the hash is stored. A database that leaks does not hand over live sessions with it.

A browser is not given one. It has nowhere safe to put it and a person sitting in front of it to ask again.

Logging out ends the ability to get another token. An access token already issued is a signature and cannot be recalled; it dies of old age within the day.

Configuring a provider

The redirect URI is always {APP_URL}/api/auth/sso/callback.

Applications → Providers → Create OAuth2/OpenID Provider, then an Application linked to it.

OIDC_PROVIDER_URL=https://auth.example.com/application/o/pedshub
OIDC_PROVIDER_NAME=Authentik

Cloud Console → APIs & Services → Credentials → OAuth client ID → Web application.

OIDC_PROVIDER_URL=https://accounts.google.com
OIDC_PROVIDER_NAME=Google

You may need to configure the OAuth consent screen first.

Azure Portal → App registrations → New registration. Redirect URI type Web. Take the client secret's value, not its id.

OIDC_PROVIDER_URL=https://login.microsoftonline.com/{tenant-id}/v2.0
OIDC_PROVIDER_NAME=Microsoft

Use common for personal Microsoft accounts. Under API permissions, ensure openid, email and profile are granted. For roles, set OIDC_ROLE_CLAIM=roles.

Clients → Create client, OpenID Connect. The secret is on the Credentials tab.

OIDC_PROVIDER_URL=https://keycloak.example.com/realms/your-realm
OIDC_PROVIDER_NAME=Keycloak

A Regular Web Application. Add the callback to Allowed Callback URLs.

OIDC_PROVIDER_URL=https://your-tenant.auth0.com
OIDC_PROVIDER_NAME=Auth0

After changing any of these, restart — they are environment, not code: docker compose restart backend celery celery-beat.

When sign-in breaks

"SSO login failed" after the redirect.

  • APP_URL must match the actual domain, with https.
  • The provider's redirect URI must match {APP_URL}/api/auth/sso/callback exactly.
  • docker compose logs backend --tail=30 | grep -i "sso\|oidc\|oauth"

The name is wrong on a new account. The name claim is read first, then preferred_username, then the email prefix. Some providers only include a name when the profile scope is requested.

The provider is down, or you are locked out of it.

Sessions already open keep working — nothing here consults the provider after sign-in. Fix the provider rather than the application. Authentik's own break-glass is a one-time recovery link for its own admin, minted from the host:

docker exec authentik-pedshub-server ak create_recovery_key 10 akadmin

Open the printed link within ten minutes to land in the Authentik admin interface without a password, then repair whatever broke — the flow, the outpost or the password.

If the provider is down entirely, docker compose up -d in /home/danvics/docker/authentik-pedshub is the fix, not a side door here. A second credential nobody rotates is a hole, not a spare key. That is why the last password login was removed rather than kept for administrators.