0.1.96 — Casdoor migration phase 2 (JWT auth + exchange endpoint)
Phase 2 of the auth migration plan in
docs/integrations/casdoor-migration.md.
Backend now speaks Casdoor JWTs alongside the existing
MultiSessionAuthentication + ApiKeyAuthentication chain. Shadow
mode is the default — existing flows keep working unchanged until
the operator populates CASDOOR_* env vars.
What landed
Dependencies
backend/requirements.txtandbackend/requirements-render.txtaddcasdoor>=1.41,<2.cryptographyupper bound widened from<46to<48because the Casdoor SDK pullscryptography==46.0.7.- No new Python files in
requirements-render.txtbeyondcasdooritself; the SDK's transitive deps (aiohttp,multidict, etc.) are already on the free tier.
Settings (backend/notechondria/settings.py)
Six new env vars, all empty-by-default:
CASDOOR_ENDPOINT=https://auth.example
CASDOOR_CLIENT_ID=...
CASDOOR_CLIENT_SECRET=...
CASDOOR_ORG_NAME=notechondria
CASDOOR_APP_NAME=notechondria
CASDOOR_CERTIFICATE=<single-line PEM with \n escapes>
CASDOOR_TOKEN_CACHE_TTL=300 # optional, seconds
Until any of the first four are set, every Casdoor surface in this
round is a no-op (auth class returns None, exchange endpoint
returns 503, config endpoint returns {configured: false}).
Authentication (creators.casdoor_auth.CasdoorJWTAuthentication)
New DRF authentication class registered as the third entry in
DEFAULT_AUTHENTICATION_CLASSES. Wire shape:
Authorization: Bearer <casdoor-jwt>— verified RS256 againstCASDOOR_CERTIFICATE, audience must equalCASDOOR_CLIENT_ID.Authorization: Bearer ntc_<key>— explicitly handed off toApiKeyAuthentication; the MCP path is unaffected.- Failure modes:
None(silent fall-through) when env vars are unset, when the header isn'tBearer ..., or when the token has thentc_MCP prefix.AuthenticationFailedonly when a Casdoor JWT is present and Casdoor explicitly rejects it.
User resolution on success:
Creator.casdoor_sub == claims['id' | 'sub']— fast path.User.email iexact claims['email']— links an existing legacy account;Creator.casdoor_subis backfilled in the same request.- Auto-provision a new
User+Creatorand stamp the sub.
Model + migration
Creator.casdoor_sub(CharField, max 128, indexed, blank default). Soft pointer to the Casdoor user record.creators/migrations/0030_creator_casdoor_sub.py. No data migration; the field backfills on first Casdoor sign-in per user.
Public endpoints
GET /api/v1/auth/casdoor/config/— public, returns the Casdoorsignin_url+ client_id when configured, or{configured: false}for shadow-mode SPAs.POST /api/v1/auth/casdoor/exchange/— public, accepts a Casdoor authorization code, exchanges it for an access token via the SDK, verifies the JWT, resolves / auto-provisions the user, mints acreators.Sessionrow, and returns the standardauth_payloadshape so the existing FlutterapplyAuthPayloadmachinery keeps working unchanged. Returns 503 in shadow mode.
Tests (creators.tests.CasdoorAuthTests — 8 cases)
test_config_view_reports_unconfigured_in_shadow_modetest_config_view_returns_oauth_targets_when_configuredtest_exchange_endpoint_returns_503_in_shadow_modetest_jwt_auth_class_is_noop_when_not_configuredtest_jwt_auth_class_skips_mcp_keys—Bearer ntc_<key>must hand off toApiKeyAuthentication.test_resolve_user_links_existing_account_by_email— legacy-account adoption path.test_resolve_user_auto_provisions_when_no_match— new-user branch.test_resolve_user_returns_none_without_sub— defensive guard.
All 8 pass. The pre-existing GithubSyncTests (13) and
CreatorModelTests (3) also still pass after the dependency bump.
Verification
After pip install -r backend/requirements.txt && python manage.py migrate creators:
# Shadow mode (no env vars):
curl -s http://localhost:8000/api/v1/auth/casdoor/config/
# -> {"configured": false}
# After populating CASDOOR_* env vars:
curl -s http://localhost:8000/api/v1/auth/casdoor/config/
# -> {"configured": true, "endpoint": "...", "signin_url": "...", ...}
Shadow mode means zero impact on existing users — they keep
using LoginApiView / RegisterApiView / Google / GitHub OAuth as
before. Phase 3 (Flutter SDK) and Phase 4 (cutover) come later.
Operator action (optional, only if flipping shadow mode now)
-
In the Casdoor admin UI at
https://auth.trance-0.com, create an organization (e.g.notechondria) and an application (e.g.notechondria). Note theClient ID,Client secret, and the application's signing certificate PEM. -
Drop the values into the backend
.env:CASDOOR_ENDPOINT=https://auth.trance-0.com CASDOOR_CLIENT_ID=<from Casdoor app settings> CASDOOR_CLIENT_SECRET=<from Casdoor app settings> CASDOOR_ORG_NAME=notechondria CASDOOR_APP_NAME=notechondria CASDOOR_CERTIFICATE=<single-line PEM with \n escapes> -
Add the per-app redirect URIs (editor / planner / portal) to the application's "Redirect URLs" list in the Casdoor admin UI — the same hosts already pre-registered in the
GOOGLE_AUTHORIZED_REDIRECT_URISenv var. -
Rebuild + redeploy.
/api/v1/handshake/continues to return the deployed version (0.1.96 once 0.1.95's Dockerfile fix propagates);/api/v1/auth/casdoor/config/flips from{configured: false}to the populated payload.
The Flutter side stays on the legacy auth surface until phase 3 ships — there is no user-visible change in this round even with the env vars set.
Carryover
- Phase 3: Flutter Casdoor SDK in
notechondria_shared. - Phase 4: cutover (disable
LoginApiViewetc.;Sessionread-only). - Phase 5: cleanup (delete every legacy endpoint / serializer /
template / helper listed in
casdoor-migration.md). - 0.1.94: push-side conflict resolution + asset rotation/pruning on the GitHub Sync surface.