Casdoor migration plan (next major)
The existing in-house auth stack (registration, email verification,
password reset, OAuth2 login + bind, multi-device session manager)
will be replaced by Casdoor on the next major
version. App-level user state stays in creators.Creator — only
identity, credentials, and the social-provider plumbing move out.
This is a survey + plan; no code changes ship in this round. The goal is to enumerate every auth surface so the cutover round can be scoped accurately.
What moves to Casdoor
The following endpoints / classes / templates become thin shims (or deletions) that redirect to or proxy Casdoor:
backend/creators/api.py
RegisterApiView+RegisterSerializerValidateInvitationApiView(Casdoor has invitation codes; reuse those)VerifyEmailApiView+VerifyEmailSerializerResendVerificationApiView+ResendVerificationSerializerLoginApiView+LoginSerializer— replaced by Casdoor token exchangePasswordResetRequestApiView+ serializerPasswordResetConfirmApiView+ serializerLogoutApiView— Casdoor revokes sessionsChangePasswordApiView+ChangePasswordSerializerChangeEmailApiView+ Request/Confirm serializersSendIdentityCodeApiView+_consume_identity_codehelper (Casdoor'sverify-codeAPI replaces the 6-digit confirm flow)OAuthConfigApiView— Casdoor's/api/get-app-loginreturns enabled providers + redirect URIs centrallyGoogleOAuthApiView,GitHubOAuthApiView+ their serializersSocialAccountListApiView,SocialAccountUnlinkApiView_BindOAuthMixin,BindGoogleApiView,BindGithubApiView_pick_redirect_uri,_request_originper-app redirect logic — Casdoor handles allow-listing centrally_get_or_create_oauth_userauth_payload()— keep as a translator: input becomes a Casdoor JWT, output stays the same shape so frontends don't break
backend/creators/authentication.py
MultiSessionAuthentication→ replaced by a JWT-validating DRF authentication class that calls Casdoor's JWKS to verify the token. Cached locally with a 5-minute TTL.ApiKeyAuthentication(theBearer ntc_<key>MCP path) — keep. MCP API keys are app-internal credentials, not user auth, and Casdoor is not in the per-request hot path for MCP.
backend/creators/views.py + templates/
login_request,register_request,edit_profile, password reset views (server-rendered Bootstrap forms) — delete. The three Flutter apps are the only consumer of these URLs and they go through DRF endpoints, not the templates. Keep the templates directory only ifbootstrap_platformstill seeds welcome emails through it.
backend/creators/utils.py
issue_registration_code,send_registration_email,send_password_reset_email,_send_code_email— delete; Casdoor sends its own emails through its SMTP config.
backend/creators/models.py
Session— deprecate. Either drop the model (and migrate schema) or keep as a denormalized cache populated from Casdoor session events for the Settings → Active Sessions card.SocialAccount— keep, but re-key to Casdoor'sprovider/providerNameshape. Mostly used for the Settings card; can be populated from Casdoor'suserinfoclaim.VerificationCode— delete; Casdoor owns email-code flows.InvitationCode— delete or migrate behind Casdoor's invite API.
What stays on Creator (unchanged)
The full list of fields that remain app-level state:
motto, social_link, image, editor_mode, theme_preset, theme_mode,
api_base_url, api_key_hash, api_key_prefix, mcp_skill_md,
app_settings_json, app_settings_updated_at, last_login,
date_joined, credit_remains, exp, reputation
Plus the related rows: Note, NoteAttachment, Course,
CourseSubscription, PlannerEvent, CalendarFeed,
GithubIntegration, RotateApiKeyApiView, SettingsApiView,
GithubSync*ApiView. None of these touch auth directly; they all
key off Creator.user_id which becomes a soft pointer to a Casdoor
user identifier (likely a UUID-typed casdoor_sub field replacing
the Django User FK).
Phased migration
The cutover is too big for one round. Suggested phases (each its own version log):
- Survey + design (0.1.95 — DONE). Inventory the auth surface;
ship
docs/integrations/casdoor-migration.md. - Casdoor SDK + JWT auth class (0.1.96 — DONE). Adds the
casdoor>=1.41Python SDK, theCasdoorJWTAuthenticationDRF class (registered LAST inDEFAULT_AUTHENTICATION_CLASSESso it's a no-op until a Bearer JWT shows up that isn't an MCP key),Creator.casdoor_sub, and the public/api/v1/auth/casdoor/{config,exchange}/endpoints. Returns 503 /{configured: false}when env vars aren't populated, so existingMultiSessionAuthentication+LoginApiViewpaths keep working unchanged. - Frontend Casdoor SDK. Add the Flutter Casdoor package to
notechondria_shared; route the existing_AuthDialogandlaunchOAuthpaths through Casdoor's/login/oauth/authorizeinstead of the per-provider Google/GitHub URLs. The frontend client gains acasdoorExchange(code)method that hits the newPOST /api/v1/auth/casdoor/exchange/and reuses the existingapplyAuthPayloadmachinery. Backend endpoints stay backwards-compatible during this phase. - Cutover. Disable the legacy
LoginApiView/RegisterApiViewetc. endpoints; the JWT path is now the only way in. RemoveMultiSessionAuthentication+Sessionwrites; theSessionmodel becomes read-only (populated from Casdoor session-events webhook). - Cleanup. Delete every endpoint / serializer / template /
helper listed above. Remove
VerificationCode/InvitationCodemodels with a final migration.
Each phase is independently shippable. Steps 2 and 3 can land in either order; both should land before step 4.
Phase 2 wire shape (shipped 0.1.96)
Backend authentication
creators.casdoor_auth.CasdoorJWTAuthenticationvalidatesAuthorization: Bearer <jwt>againstsettings.CASDOOR_CERTIFICATE(RS256). Audience must equalCASDOOR_CLIENT_ID. Bearer headers starting withntc_are ignored so MCP API keys keep flowing toApiKeyAuthentication.- The class is no-op when any of
CASDOOR_ENDPOINT,CASDOOR_CLIENT_ID,CASDOOR_ORG_NAME,CASDOOR_APP_NAMEis empty. ReturnsNone(notAuthenticationFailed) so other classes in the chain can still handle the same header. - On first valid JWT, user resolution order is:
Creator.casdoor_sub == claims['id' | 'sub'](fast path after the link is persisted).User.email iexact claims['email']— links an existing legacy account;Creator.casdoor_subis backfilled.- Auto-provision a new
User+Creator, stamp the sub.
- Stamps
User.last_loginso the existing "recently signed in" surfaces stay accurate.
Public endpoints
GET /api/v1/auth/casdoor/config/ (anon):
{"configured": true,
"endpoint": "https://auth.example",
"client_id": "...",
"organization": "...",
"application": "...",
"signin_url": "https://auth.example/login/oauth/authorize"}
When unconfigured: {"configured": false} with no other fields,
so the SPA can keep showing the legacy auth surface.
POST /api/v1/auth/casdoor/exchange/ (anon):
Request: {"code": "<casdoor-authz-code>", "state": "..."}.
Response (200): the standard auth_payload shape used by
LoginApiView — token + session + user + app_settings.
Response (503): {detail: "...shadow mode..."} when the SDK
isn't configured.
Migration
creators/0030_creator_casdoor_sub.pyaddsCreator.casdoor_sub(CharField, indexed, blank default).- No data migration. Legacy users continue without a sub until their first Casdoor sign-in, at which point either the email link or the auto-provision branch records it.
Phase-3-and-a-half — bind / unlink (shipped 0.1.98)
The phase-2 exchange endpoint resolves identity automatically via
Creator.casdoor_sub → email-iexact → auto-provision. The
bind/unlink path covers two cases the auto-resolve can't:
- The Casdoor email differs from the Notechondria email, so the email-iexact branch can't find the legacy account.
- The user wants to deliberately disconnect a previously linked Casdoor identity without losing access (the legacy session keeps working).
Endpoints
POST /api/v1/auth/casdoor/bind/ (auth required):
Request: {"code": "<casdoor-authz-code>"}. Backend exchanges via
get_oauth_token, verifies the JWT, takes the sub, and:
- Returns 409 if the same
subis already on a different Creator (the user must unlink that side first). - Otherwise persists
Creator.casdoor_subfor the current user and returns the standardauth_payload.
DELETE /api/v1/auth/casdoor/unlink/ (auth required):
Idempotent. Clears Creator.casdoor_sub. Returns
{"casdoor_linked": false, "was_linked": <bool>}. Does NOT log
the user out — the existing legacy Session keeps working.
Settings surface
Settings GET response now includes casdoor_linked: bool so the
Connected Accounts UI can render the right state without an extra
round-trip.
Frontend
AuthClientgainscasdoorBind(token, code)+casdoorUnlink(token). Each app's client implements them.AppShellOAuthMixin.handleOAuthCallbackdispatches thestate=casdoorbranch onintent:'login'→ exchange,'bind'→ bind. The bind branch refuses to fall through to the legacy provider login when the session token is missing.- All three apps'
_ConnectedAccountsSectionwidgets gain a Casdoor SSO row with Link / Unlink controls;onBindCasdoorandonUnlinkCasdoorare constructed in each app shell when_casdoorConfigured && _token != null.
Open questions
- Username migration. Casdoor users are keyed by an opaque
name(Casdoor) plus anid(UUID). The mapping table from existingauth_user.usernameto Casdoornamemust be pre-populated before cutover or first-login users will end up duplicated. Resolved (cutover round): the management commandpython manage.py migrate_users_to_casdoorwalksauth_user.is_active=True, callsCasdoorSDK.get_user_by_emailto deduplicate, thenadd_user/update_userfor each row, and finally stampsCreator.casdoor_subso the next JWT login takes the fast path. Idempotent; supports--dry-run,--retry-existing,--strict, and--limit Nfor staged rollout. LinkedSocialAccountrows are pushed into Casdoor's per-provider fields (user.google,user.github) so prior OAuth identities resolve to the same Casdoor row. - MCP API keys. The
ntc_<32-hex>Bearer scheme stays app-internal; Casdoor is not used for the MCP per-request hot path. The/api/v1/auth/rotate-api-key/endpoint stays. - OAuth redirect-allow-list. Casdoor handles per-app
redirect_uri allow-lists centrally. The
GOOGLE_AUTHORIZED_REDIRECT_URIS/GITHUB_AUTHORIZED_REDIRECT_URISenv vars added in 0.1.90 become unused once cutover lands. - Email verification copy. Casdoor's templated email is generic; if we want the Notechondria-branded email body, we need to ship a Casdoor email template via its admin API as part of the migration.
Required env vars (target state)
CASDOOR_ENDPOINT=https://login.notechondria.example
CASDOOR_CLIENT_ID=...
CASDOOR_CLIENT_SECRET=...
CASDOOR_ORG_NAME=notechondria
CASDOOR_APP_NAME=notechondria
CASDOOR_CERTIFICATE=<single-line PEM with \n escapes>
The five existing OAuth env vars
(GOOGLE_OAUTH_CLIENT_ID, etc.) become optional after cutover —
Casdoor stores them centrally instead.