0.1.118 — gitea-style Casdoor link-challenge flow (bind existing or create new with chosen password)
User directive verbatim:
"Yes, I believe that one is safer for migrating accounts and better than the nextcloud flows. I don't see any additional variables need to configured with that so continue please."
(Confirming the proposal from 0.1.117's deferred-work section.)
The 0.1.96-era Casdoor exchange auto-linked-by-email and
auto-provisioned-by-sub. Both were silent identity decisions — a
brand-new Casdoor identity with no email match would silently
create a fresh account with set_unusable_password(), leaving
the user with no idea which Notechondria account they had landed
on or whether their pre-existing legacy account had been
adopted. For a migration where users own multiple Notechondria
accounts (some on the legacy hasher, some not yet linked), the
silent path is too easy to get wrong.
This round replaces both implicit branches with an explicit two-choice dialog modeled on Gitea's account-linking flow.
Flow
┌─ SPA: redirect to Casdoor → user signs in
│
├─ SPA: POST /auth/casdoor/exchange/ {code}
│ └─ Backend: verify JWT, run group ACL, look up casdoor_sub
│ ├─ existing link → return auth_payload (fast path; same as before)
│ └─ no link → create LinkChallenge, return:
│ {link_challenge, expires_at, casdoor_identity, suggested_username}
│
├─ SPA: detects `link_challenge` → show CasdoorLinkChallengeDialog
│ ├─ User picks "Bind existing":
│ │ └─ POST /auth/casdoor/link/bind/ {nonce, username, password}
│ │ └─ Backend: legacy-auth check, stamp casdoor_sub, return auth_payload
│ └─ User picks "Create new":
│ └─ POST /auth/casdoor/link/create/ {nonce, password}
│ └─ Backend: create User w/ password, stamp casdoor_sub, seed Inbox,
│ return auth_payload
│
└─ SPA: applyAuthPayload as usual
The Casdoor JWT sits server-side on the LinkChallenge row keyed by a 48-char URL-safe nonce; the SPA never sees it before the link decision and never has to round-trip back to Casdoor for a fresh code (Casdoor codes are one-time use). Challenge expires in 10 minutes.
Backend — what's new
backend/creators/models.py
New LinkChallenge model:
| Field | Notes |
|---|---|
nonce | 48-char URL-safe random; unique, indexed |
sub | Casdoor user sub claim — what we stamp onto Creator.casdoor_sub |
casdoor_username, casdoor_email, casdoor_display_name, casdoor_groups | captured from the verified JWT |
access_token | the verified JWT, replayed back in auth_payload after the link completes |
created_at, expires_at (10 min) | one-time-use ticket; is_expired() helper |
Meta.indexes | (sub, expires_at) for cheap garbage-collection sweeps |
backend/creators/migrations/0032_link_challenge.py — auto-
generated by manage.py makemigrations inside the docker
build container; verified clean by manage.py check.
backend/creators/casdoor_auth.py
_resolve_user (the auto-link-by-email + auto-provision-by-sub
implementation) is gone. The fast-path-only replacement is
_resolve_existing_user: returns the matching User when
Creator.casdoor_sub == claims['sub'], otherwise None so
the caller can mint a LinkChallenge.
_resolve_user is kept as a backwards-compat alias so older
callers (e.g. test files imported from outside the Casdoor
flow) still resolve.
backend/creators/api.py
CasdoorExchangeApiView:
- Now imports
_resolve_existing_user,_check_group_access,_claim_groups,_claim_strdirectly so the exchange logic is readable end-to-end. - Group ACL gate moved here from
CasdoorJWTAuthentication. authenticate— it now applies to both the exchange path and the JWT-bearer path, closing a gap where a user who passed the ACL once at exchange time could keep using the same JWT after being removed from the group. (Group check at authenticate-time still runs, this is additive.) - After verifying the JWT and finding no existing link, mints
a LinkChallenge with a 48-char nonce and 10-minute TTL,
garbage-collects expired rows for the same sub, returns a
response with shape:
{ "link_challenge": "<nonce>", "expires_at": "<iso8601>", "casdoor_identity": {"username", "email", "display_name"}, "suggested_username": "<username or email-localpart>" }
Two new views:
CasdoorLinkBindApiView(POST/auth/casdoor/link/bind/): Accepts{nonce, username|email|identifier, password}. Looks up the legacy user case-insensitively (email first, then username), authenticates viadjango.contrib.auth. authenticate(), stampsCreator.casdoor_sub = challenge.sub, deletes the challenge, returns the standardauth_payloadwith the captured Casdoor JWT as the token.CasdoorLinkCreateApiView(POST/auth/casdoor/link/create/): Accepts{nonce, password}. Refuses (409) when a legacy account already exists for the Casdoor email — the SPA should redirect to bind. Otherwise creates a freshUser+Creatorusing username/email/display-name from the captured JWT claims, sets the user-chosen password, stampscasdoor_sub, runsseed_inbox_and_welcome_note, returns the standardauth_payload.
Both completion endpoints delete the LinkChallenge row on success or on 409-conflict resolution; expired challenges are silently swept.
backend/notechondria/api_urls.py
Two new routes wired immediately after casdoor/exchange/:
auth/casdoor/link/bind/ → CasdoorLinkBindApiView
auth/casdoor/link/create/ → CasdoorLinkCreateApiView
Frontend — what's new
notechondria_shared
lib/src/app_shell/auth_client.dart: declared the two new abstract methods onAuthClient—casdoorLinkBind({nonce, identifier, password})andcasdoorLinkCreate({nonce, password}). Each returns the standardauth_payload.lib/src/components/casdoor_link_challenge_dialog.dart(new): the gitea-style choice + form dialog. Three stages:choose(two pill-buttons),bind(legacy username/email + password),create(new password + confirm with ≥8 char enforcement). Returns aCasdoorLinkChallengeDecisionviaNavigator.pop. The dialog never carries the Casdoor JWT — only the nonce travels through the form, so a cancelled / closed dialog leaves no client-side credential trail.lib/notechondria_shared.dart: exported the dialog + decision class.lib/src/app_shell/app_shell_oauth_mixin.dart:handleOAuthCallbacknow branches on the exchange response:link_challengenon-empty → callonCasdoorLinkChallenge(payload)and return its result; otherwise the existingapplyAuthPayloadfast path runs.- New default-implementation
onCasdoorLinkChallengemethod on the mixin: pops the dialog, dispatches the chosen completion endpoint, threads the resulting auth_payload throughapplyAuthPayload. Apps can override on_AppShellStatefor a custom UX, but the default is enough for editor / planner / portal — they all want the same dialog. Logs each transition under source<App>.Auth/casdoor.link_challengeso the user can grep the debug log for the bind/create decision and completion outcome.
Per-app
frontend/editor_app/lib/core/http_client.dart,frontend/portal_app/lib/core/client.dart,frontend/planner_app/lib/core/client.dart: concretecasdoorLinkBind/casdoorLinkCreateimplementations. Each app'sNotechondriaClientalready extendsAuthClient, so the new methods are inherited as abstract and need a concrete impl per app — done.
Behavior matrix
| Casdoor identity state | Exchange returns | SPA shows |
|---|---|---|
Linked (existing casdoor_sub) | auth_payload | nothing extra; signed in immediately |
| Not linked, fresh JWT | link_challenge payload | bind/create dialog |
| Group ACL fails | 403 with consequence/cause | snackbar from existing error path |
expires_at passed before completion | bind/create endpoints 400 | snackbar; user restarts SSO |
Group-ACL-rejected users never reach the dialog — the exchange endpoint 403s before LinkChallenge creation, so no orphan rows build up.
Verification
Per the user's directive ("use docker to test build. Do not upload any unverified scripts"):
docker build -f backend/Dockerfile --target builder— completed all 18 stages with the new model, views, and URL wiring.- In-container
manage.py check—System check identified no issues (0 silenced). - In-container
manage.py makemigrations— produced0032_link_challenge.pycleanly (no manual edits). - In-container Django bootstrap smoke test — confirmed
creators.api.CasdoorLinkBindApiView,CasdoorLinkCreateApiView, andcreators.models.LinkChallengeimport without error and theLinkChallengemodel carries every expected field (nonce,sub,casdoor_username,casdoor_email,casdoor_display_name,casdoor_groups,access_token,created_at,expires_at). flutter analyzeclean across all four Flutter packages — zero new errors. The pre-existing_socialLinkErrorwarning in planner_app is untouched.
No new env vars required — the user explicitly asked for "I don't see any additional variables need to configured with that". The flow uses the existing CASDOOR_CLAIM_* mapping plus the already-configured CASDOOR_REQUIRED_GROUPS gate.
Operator notes for the migration
- After deploy, a returning user signs in via Casdoor.
- If their email matches a legacy Notechondria account → the dialog will offer bind; they enter the legacy password once and the link sticks. Subsequent Casdoor sign-ins land on the same account directly (no dialog).
- If their email is new → the dialog will offer create;
they pick a password (≥8 chars, confirmed) which doubles
as the email/password fallback if Casdoor goes down (the
0.1.111
/auth/login/endpoint validates against this hash). - There's no admin-side intervention needed — the link
record is the same
Creator.casdoor_subfield we've been writing since 0.1.96. - The auto-link-by-email + auto-provision-by-sub branches
that existed since 0.1.96 are gone. Any SPA build
pre-0.1.118 that hits the exchange endpoint will now see
the new
link_challengeshape and 401 on the next request because it doesn't know to route through the bind/create completion. After redeploy, push the new SPA build at the same time so users don't get caught between SPA versions.