Casdoor login setup (auth.trance-0.com)

Step-by-step runbook for wiring the Notechondria backend to a Casdoor instance — covers the admin-UI walkthrough, the env-var contract on the Notechondria side, and the per-app redirect URIs. Pairs with casdoor-migration.md, which covers the why + the phased migration plan.

This document assumes:

  • The Casdoor instance is deployed at https://auth.trance-0.com. (Self-hosted via docker-compose from a separate Gitea repo; see auth.trance-0.com.conf + docker-compose.yml next to init_data.json for the reverse-proxy + container topology.)
  • The backend is on 0.1.96 or later (docs/versions/0.1.96.md) — that's the round that landed CasdoorJWTAuthentication and the /api/v1/auth/casdoor/{config,exchange}/ endpoints.
  • The frontend is on 0.1.97 or later (docs/versions/0.1.97.md) for the shared launchOAuth('casdoor', ...) plumbing, and ideally 0.1.99+ (docs/versions/0.1.99.md) for the Casdoor-primary login surface.

1. Casdoor admin-UI walkthrough

Sign in to https://auth.trance-0.com with the bootstrap admin user (set at first boot via init_data.json or the seeded built-in/admin account).

1a. Create the organization

Top nav → OrganizationsAdd.

FieldValue
Namenotechondria
Display nameNotechondria
Tags(optional, e.g. notes, productivity)

Save. The organization name is the value of CASDOOR_ORG_NAME on the backend.

1b. Create the application

Top nav → ApplicationsAdd.

FieldValue
Organizationnotechondria (the one you just created)
Namenotechondria
Display nameNotechondria
Logo URL(optional)
Login URLhttps://auth.trance-0.com/login/oauth/authorize (Casdoor sets this automatically)
Redirect URIsone entry per Flutter app — see §1d
Token formatJWT
Token signing algorithmRS256
Token expire2 hours (the Notechondria SDK only needs ~9 minutes; longer is fine)
Refresh token expire7 days (or per your security policy)
Providers(optional) attach Google / GitHub / etc. so Casdoor itself can act as the OAuth proxy; otherwise it will accept username/password against the Casdoor user table only

Save. Casdoor reveals a Client ID and Client secret for this application — those are the next two env vars you'll need.

1c. Generate (or pick) the signing certificate

If the application doesn't already show a certificate under the Cert field, create one: top nav → CertsAdd.

FieldValue
Namenotechondria-cert
Display nameNotechondria signing cert
Typex509
Crypto algorithmRS256
Bit size4096
Expire in years5 (or longer; Casdoor lets you rotate)

Save, then download the public key half. The Notechondria backend verifies inbound JWTs against this PEM.

Back on the Applications → notechondria screen, set the Cert field to notechondria-cert.

1d. Per-app redirect URIs

Casdoor's redirect-URI allow-list is centralised on the application. Each Flutter frontend lives at a different origin, so add one entry per app:

AppRedirect URI
Editorhttps://trance-0.github.io/Notechondria/editor/
Plannerhttps://trance-0.github.io/Notechondria/planner/
Portalhttps://trance-0.github.io/Notechondria/portal/

For local dev add the localhost equivalents too:

AppRedirect URI
Editorhttp://localhost:8001/
Plannerhttp://localhost:8002/
Portalhttp://localhost:8003/

(The exact ports depend on what flutter run -d chrome picks for each app — pin them via --web-port if you want stable values.)

The Notechondria backend computes the redirect_uri from the caller's Origin header per the launchOAuth('casdoor', ...) flow, so each app's outbound authorization URL ends with the matching origin. Any URI not on Casdoor's allow-list above will be rejected with a redirect_uri mismatch error.

1e. (Optional) configure email + invitation gates

If you want Casdoor to send the verification / password-reset emails (Notechondria's own SMTP path stops being used after the phase-4 cutover):

  • Top nav → ProvidersAdd → category Email, type SMTP. The instance ships with a sample provider_email_smtp row preloaded by init_data.json; reuse or replace.
  • Application → notechondriaEmail provider = the SMTP provider above.

If you want sign-up gated by an invitation code (matches the existing InvitationCode table on Notechondria):

  • Application → notechondriaEnable signup = off.
  • Top nav → InvitationsAdd → assign to the notechondria org.

Until phase 4 ships, the legacy invitation flow on Notechondria keeps working in parallel — Casdoor doesn't need to take this over yet.

2. Notechondria backend env vars

Drop these into the backend .env (or the deployment-method equivalent — see deploy.md, render_free_tier.md, northflank.md):

CASDOOR_ENDPOINT=https://auth.trance-0.com
CASDOOR_CLIENT_ID=<from the application "Client ID" field>
CASDOOR_CLIENT_SECRET=<from the application "Client secret" field>
CASDOOR_ORG_NAME=notechondria
CASDOOR_APP_NAME=notechondria
CASDOOR_CERTIFICATE=<single-line PEM with literal \n escapes>
# Optional: how long to cache JWT-verification results in seconds
# (default 300). The verifier itself is stateless; this just
# amortises the cert-parsing cost when the same token shows up
# again within the window.
CASDOOR_TOKEN_CACHE_TTL=300

CASDOOR_CERTIFICATE is the public-key PEM you downloaded in §1c. The newline-to-\n escape is so the PEM survives a single-line shell .env. _normalize_pem in backend/creators/casdoor_auth.py converts the escaped form back to multi-line at signing time. Same trick used by the GitHub data-sync app's private key.

When any of the first four are empty, every Casdoor surface on the backend is a no-op (auth class returns None, exchange endpoint returns 503, config endpoint returns {configured: false}). That's the shadow mode the migration plan calls out — safe default until you're ready to flip the switch.

3. Verify

After pip install -r backend/requirements.txt && python manage.py migrate creators (the 0030_creator_casdoor_sub.py migration must have run):

# Backend reports configured:
curl -s http://localhost:8000/api/v1/auth/casdoor/config/
# -> {"configured": true,
#     "endpoint": "https://auth.trance-0.com",
#     "client_id": "...",
#     "organization": "notechondria",
#     "application": "notechondria",
#     "signin_url": "https://auth.trance-0.com/login/oauth/authorize"}

Hit /api/v1/handshake/ and confirm version matches the deployed VERSION file (this surface was fixed in 0.1.95 — if it returns "0.0.0" your container didn't ship VERSION to /home/VERSION).

On the frontend:

  1. Open any of the three apps. Sign-out.
  2. The Account card should now lead with a full-width "Continue with Casdoor SSO" button (since 0.1.99). Legacy email / password sits behind the "Use email / password instead" expander.
  3. Click the SSO button → redirects to https://auth.trance-0.com/login/oauth/authorize?client_id=…&state=casdoor&....
  4. Casdoor authenticates → redirects back with ?code=....
  5. Frontend calls POST /api/v1/auth/casdoor/exchange/ automatically; the SPA ends up signed in the same way as the legacy login flow.
  6. Inspect Settings → Connected accounts — the Casdoor row shows "Linked".

For the bind path (link Casdoor to an existing legacy account when the emails differ): sign in legacy first, then in Settings → Connected accounts click Link Casdoor on the Casdoor row. Casdoor is opened with state=casdoor + intent=bind; the callback hits POST /api/v1/auth/casdoor/bind/ with the existing session token, and the link is recorded on Creator.casdoor_sub. The legacy session keeps working.

4. Failure modes

SymptomLikely causeFix
Frontend SSO button missing/auth/casdoor/config/ returned {configured: false}Backend env vars not populated, or container hasn't been rebuilt since they were added
redirect_uri mismatch on the Casdoor login pageURI not in §1d allow-listAdd the exact origin (scheme + host + port + trailing slash) under Application → Redirect URIs
Cannot sign in: ...JWT verification failedCASDOOR_CERTIFICATE doesn't match the application's signing certRe-download the PEM in §1c, re-escape newlines, redeploy
409 Conflict on bindThe Casdoor sub is already linked to a different Notechondria accountUnlink that side first; or sign in with that account directly via SSO
503 Service Unavailable from /auth/casdoor/exchange/One of the four required env vars is still emptyRe-check CASDOOR_ENDPOINT, CASDOOR_CLIENT_ID, CASDOOR_ORG_NAME, CASDOOR_APP_NAME
Backend version returns "0.0.0"VERSION file isn't shipped to the containerThe Dockerfile copies it to /home/VERSION since 0.1.95; for non-Docker deploys set the BACKEND_VERSION env var

5. What gets stored where

After a successful Casdoor sign-in:

  • Creator.casdoor_sub (TextField on backend/creators/models.py) holds the Casdoor user id / sub claim. Used as the fast-path key on subsequent JWT verifies.
  • creators.Session row is still minted by auth_payload(user, request) so the existing MultiSessionAuthentication keeps working — the SPA uses the same Authorization: Token <session-key> header it always did. Casdoor JWTs are only used at sign-in time; the per- request hot path stays on the legacy session token until the phase-4 cutover.

Once cutover lands, Session becomes a read-only audit table populated from Casdoor session-events webhooks; the per-request hot path moves entirely onto JWT verification by CasdoorJWTAuthentication. That's the next major version.

6. JWT claim mapping + group ACL (since 0.1.110)

Casdoor's Application > Token > Token Format tab lets you emit arbitrary custom JWT claims. The default Notechondria backend reads id / sub, email, name, firstName, lastName — the historical Casdoor shape. To match a Nextcloud-style attribute mapping, configure the backend env to read whichever claim names Casdoor emits. The pattern is identical to the Nextcloud user_oidc plugin's claim mapping.

Recommended Token Format mapping in the Casdoor admin UI (matches the user_oidc convention):

JWT claimCasdoor sourceType
preferred_usernameNameString
nameDisplayNameString
emailEmailString
groupsGroupsArray

Then in the backend env (Northflank service env or linked Secret Group), set:

CASDOOR_CLAIM_USERNAME=preferred_username,name
CASDOOR_CLAIM_DISPLAY_NAME=name,displayName
CASDOOR_CLAIM_EMAIL=email
CASDOOR_CLAIM_GROUPS=groups

Each value is a comma-separated list of claim names tried in order — the first non-empty wins. Defaults preserve historical 0.1.96 behavior, so leaving these unset keeps the existing auto-provision flow working.

Group-based access control

CASDOOR_REQUIRED_GROUPS is a comma-separated list of group names; the JWT's groups claim (read via CASDOOR_CLAIM_GROUPS) must contain at least one match for the JWT to authenticate. Empty (default) disables gating — any verified Casdoor JWT is accepted.

# Only let members of the app-notechondria group sign in.
# Casdoor typically emits org-scoped groups as `<org>/<group>`,
# so list the full path as it appears in the JWT.
CASDOOR_REQUIRED_GROUPS=notechondria/app-notechondria

Match is exact and case-sensitive. When a sign-in is denied, the backend logs a warning at Backend.Creators.CasdoorAuth/authenticate with the reason, and the frontend's auth-failure SnackBar surfaces a precise message ("Cannot sign in: ... — user is not a member of any required group (...)").

This mirrors the Nextcloud user_oidc plugin's "Restrict login to a list of groups" toggle. Group membership itself is managed in Casdoor under Identity > Groups and assigned to users via Identity > Users > <user> > Edit. There is no Notechondria-side group management — group state lives entirely in Casdoor and the backend just gates on it.

Note: deploy freshness

The /api/v1/auth/casdoor/config/ endpoint and the entire CasdoorJWTAuthentication class are gated by the deployed backend image. If curl https://<backend>/api/v1/auth/casdoor/config/ returns 404 even after setting all the env vars, the deployed image predates 0.1.96 — redeploy on Northflank (or whichever host is in use) to pick up the routes. The frontend boot probe writes a warning to Editor.Auth/casdoor.config.probe with the full URL it tried, so the operator can grep the log for the exact backend that's stale.

7. Profile-sync custom JWT field (since 0.1.119)

0.1.119 added a per-login profile refresh: every authenticated request runs _sync_creator_from_claims(creator, claims) which copies the JWT's profile attributes onto the local Creator row. The fields synced are:

JWT claimNotechondria attributeNotes
displayNameCreator.display_namepreferred over username on public surfaces
avatarCreator.avatar_urlremote URL; preferred over the locally-uploaded Creator.image
firstNameUser.first_name
lastNameUser.last_name
emailUser.email

Notechondria deliberately never writes back to User.username. Username is the stable PK reference shape — changing it would break every owner-keyed FK on courses, notes, attachments, plus external links to /api/v1/creators/<username>/. Casdoor's username can be edited freely; Notechondria's local username only gets set once at account creation (either through the bind path against an existing legacy account, or through the create-with-password path off the gitea-style link challenge in 0.1.118).

The sync is throttled to 5-minute granularity via Creator.casdoor_profile_synced_at — a busy SPA fires hundreds of JWT-authenticated requests during a session and we don't want each one writing to the DB. Within 5 minutes of the last sync, the helper is a no-op.

avatar Custom JWT field — Casdoor admin UI walkthrough

Casdoor doesn't emit avatar by default; you have to add it explicitly under the application's Token tab. Steps:

  1. Casdoor admin UI → Identity > Applications > notechondria.
  2. Open the Token sub-tab.
  3. Scroll to Custom JWT fields and click Add.
  4. Fill in:
    • Name: avatar
    • Category: Existing Field
    • Value: Avatar (the user-record's avatar URL field; Casdoor stores avatars at <endpoint>/files/avatar/<org>/<user>.png)
    • Type: String
  5. Save the application.

After this, Casdoor's emitted JWT will include avatar like:

{
  "sub": "<uuid>",
  "displayName": "ncadmin",
  "avatar": "https://auth.trance-0.com/files/avatar/notechondria/Trance-0.png",
  "email": "user@example.com",
  "firstName": "",
  "lastName": "",
  "groups": ["trance-0/app-notechondria", ...]
}

The default Notechondria env var CASDOOR_CLAIM_AVATAR=avatar already points at that claim, so no further config is needed when the Casdoor side names the field avatar exactly. Override via CASDOOR_CLAIM_AVATAR=picture (or any comma-separated list) if your Casdoor instance emits a different name — following the same pattern as the other CASDOOR_CLAIM_* mappings from §6.

For a setup that maps cleanly onto Notechondria's profile refresh, the application's Token > Custom JWT fields should look like (the user-supplied JWT example for auth.trance-0.com matches this shape):

NameCategoryValueType
preferred_usernameExisting FieldNameString
displayNameExisting FieldDisplayNameString
emailExisting FieldEmailString
avatarExisting FieldAvatarString
groupsExisting FieldGroupsArray

firstName / lastName are part of Casdoor's standard JWT shape and don't need a custom-fields entry. sub is Casdoor's internal UUID — never edit it.

What an end-to-end refresh looks like

When a user signs into Casdoor, edits their display name in the Casdoor user portal at <endpoint>/account/<orgname>/<username>, and then opens any Notechondria SPA tab:

  1. The SPA's stored Casdoor JWT triggers CasdoorJWTAuthentication.authenticate on the next authenticated request.
  2. JWT verifies, group ACL passes, user resolves via Creator.casdoor_sub.
  3. _sync_creator_from_claims(creator, claims) runs — throttled to 5 minutes, but the user's edit is fresh enough to push Creator.display_name / Creator.avatar_url / User.first_name etc. to whatever Casdoor now reports.
  4. The next page that reads auth_payload (or /api/v1/settings/) sees the updated display_name and image_url fields and re-renders the avatar / byline.

No frontend code change is required — the SPA already prefers the backend's image_url over the local avatar_url field explicitly when the backend resolves the priority chain server-side (see auth_payload in creators/api.py).