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.txt and backend/requirements-render.txt add casdoor>=1.41,<2. cryptography upper bound widened from <46 to <48 because the Casdoor SDK pulls cryptography==46.0.7.
  • No new Python files in requirements-render.txt beyond casdoor itself; 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 against CASDOOR_CERTIFICATE, audience must equal CASDOOR_CLIENT_ID.
  • Authorization: Bearer ntc_<key> — explicitly handed off to ApiKeyAuthentication; the MCP path is unaffected.
  • Failure modes: None (silent fall-through) when env vars are unset, when the header isn't Bearer ..., or when the token has the ntc_ MCP prefix. AuthenticationFailed only when a Casdoor JWT is present and Casdoor explicitly rejects it.

User resolution on success:

  1. Creator.casdoor_sub == claims['id' | 'sub'] — fast path.
  2. User.email iexact claims['email'] — links an existing legacy account; Creator.casdoor_sub is backfilled in the same request.
  3. Auto-provision a new User + Creator and 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 Casdoor signin_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 a creators.Session row, and returns the standard auth_payload shape so the existing Flutter applyAuthPayload machinery keeps working unchanged. Returns 503 in shadow mode.

Tests (creators.tests.CasdoorAuthTests — 8 cases)

  • test_config_view_reports_unconfigured_in_shadow_mode
  • test_config_view_returns_oauth_targets_when_configured
  • test_exchange_endpoint_returns_503_in_shadow_mode
  • test_jwt_auth_class_is_noop_when_not_configured
  • test_jwt_auth_class_skips_mcp_keysBearer ntc_<key> must hand off to ApiKeyAuthentication.
  • 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)

  1. In the Casdoor admin UI at https://auth.trance-0.com, create an organization (e.g. notechondria) and an application (e.g. notechondria). Note the Client ID, Client secret, and the application's signing certificate PEM.

  2. 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>
    
  3. 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_URIS env var.

  4. 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 LoginApiView etc.; Session read-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.