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 viadocker-composefrom a separate Gitea repo; seeauth.trance-0.com.conf+docker-compose.ymlnext toinit_data.jsonfor 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 landedCasdoorJWTAuthenticationand 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 sharedlaunchOAuth('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 → Organizations → Add.
| Field | Value |
|---|---|
| Name | notechondria |
| Display name | Notechondria |
| 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 → Applications → Add.
| Field | Value |
|---|---|
| Organization | notechondria (the one you just created) |
| Name | notechondria |
| Display name | Notechondria |
| Logo URL | (optional) |
| Login URL | https://auth.trance-0.com/login/oauth/authorize (Casdoor sets this automatically) |
| Redirect URIs | one entry per Flutter app — see §1d |
| Token format | JWT |
| Token signing algorithm | RS256 |
| Token expire | 2 hours (the Notechondria SDK only needs ~9 minutes; longer is fine) |
| Refresh token expire | 7 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 → Certs → Add.
| Field | Value |
|---|---|
| Name | notechondria-cert |
| Display name | Notechondria signing cert |
| Type | x509 |
| Crypto algorithm | RS256 |
| Bit size | 4096 |
| Expire in years | 5 (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:
| App | Redirect URI |
|---|---|
| Editor | https://trance-0.github.io/Notechondria/editor/ |
| Planner | https://trance-0.github.io/Notechondria/planner/ |
| Portal | https://trance-0.github.io/Notechondria/portal/ |
For local dev add the localhost equivalents too:
| App | Redirect URI |
|---|---|
| Editor | http://localhost:8001/ |
| Planner | http://localhost:8002/ |
| Portal | http://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 → Providers → Add → category
Email, typeSMTP. The instance ships with a sampleprovider_email_smtprow preloaded byinit_data.json; reuse or replace. - Application → notechondria → Email provider = the SMTP provider above.
If you want sign-up gated by an invitation code (matches the
existing InvitationCode table on Notechondria):
- Application → notechondria → Enable signup = off.
- Top nav → Invitations → Add → assign to the
notechondriaorg.
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:
- Open any of the three apps. Sign-out.
- 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.
- Click the SSO button → redirects to
https://auth.trance-0.com/login/oauth/authorize?client_id=…&state=casdoor&.... - Casdoor authenticates → redirects back with
?code=.... - Frontend calls
POST /api/v1/auth/casdoor/exchange/automatically; the SPA ends up signed in the same way as the legacy login flow. - 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
| Symptom | Likely cause | Fix |
|---|---|---|
| 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 page | URI not in §1d allow-list | Add the exact origin (scheme + host + port + trailing slash) under Application → Redirect URIs |
Cannot sign in: ...JWT verification failed | CASDOOR_CERTIFICATE doesn't match the application's signing cert | Re-download the PEM in §1c, re-escape newlines, redeploy |
409 Conflict on bind | The Casdoor sub is already linked to a different Notechondria account | Unlink 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 empty | Re-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 container | The 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 onbackend/creators/models.py) holds the Casdoor user id /subclaim. Used as the fast-path key on subsequent JWT verifies.creators.Sessionrow is still minted byauth_payload(user, request)so the existingMultiSessionAuthenticationkeeps working — the SPA uses the sameAuthorization: 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 claim | Casdoor source | Type |
|---|---|---|
preferred_username | Name | String |
name | DisplayName | String |
email | Email | String |
groups | Groups | Array |
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 claim | Notechondria attribute | Notes |
|---|---|---|
displayName | Creator.display_name | preferred over username on public surfaces |
avatar | Creator.avatar_url | remote URL; preferred over the locally-uploaded Creator.image |
firstName | User.first_name | |
lastName | User.last_name | |
email | User.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:
- Casdoor admin UI → Identity > Applications > notechondria.
- Open the Token sub-tab.
- Scroll to Custom JWT fields and click Add.
- 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
- Name:
- 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.
Recommended Custom JWT fields, full set
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):
| Name | Category | Value | Type |
|---|---|---|---|
preferred_username | Existing Field | Name | String |
displayName | Existing Field | DisplayName | String |
email | Existing Field | Email | String |
avatar | Existing Field | Avatar | String |
groups | Existing Field | Groups | Array |
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:
- The SPA's stored Casdoor JWT triggers
CasdoorJWTAuthentication.authenticateon the next authenticated request. - JWT verifies, group ACL passes, user resolves via
Creator.casdoor_sub. _sync_creator_from_claims(creator, claims)runs — throttled to 5 minutes, but the user's edit is fresh enough to pushCreator.display_name/Creator.avatar_url/User.first_nameetc. to whatever Casdoor now reports.- The next page that reads
auth_payload(or/api/v1/settings/) sees the updateddisplay_nameandimage_urlfields 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).