Skip to content

feat: pluggable auth — AuthProvider contract + Keycloak OIDC module + bearer tokens - #184

Merged
antosubash merged 35 commits into
mainfrom
worktree-pluggable-auth-keycloak-design
May 28, 2026
Merged

feat: pluggable auth — AuthProvider contract + Keycloak OIDC module + bearer tokens#184
antosubash merged 35 commits into
mainfrom
worktree-pluggable-auth-keycloak-design

Conversation

@antosubash

@antosubashantosubash commented May 27, 2026

Copy link
Copy Markdown
Owner

Summary

Make the framework's authentication layer swappable. Framework users install either simple-module-users (local credentials + OAuth) or a new simple-module-keycloak (Keycloak OIDC) — both implement the same AuthProvider protocol so every other module works unchanged.

  • AuthProvider protocol in auth/contracts/provider.py — the contract both providers implement
  • Provider-agnostic AuthMiddleware moved from users/ to auth/ — delegates to the active provider, returns 401 JSON for API paths and 302 redirect for browser views
  • UsersAuthProvider with session-cookie + bearer token resolution
  • Bearer token endpointsPOST /api/users/auth/token (login), POST .../refresh (rotation), DELETE .../token (revoke) for mobile/API clients
  • New keycloak module — OIDC Authorization Code flow, JWKS JWT validation with key-rotation retry, role mapping, lightweight user cache table
  • SM020/SM021 diagnostics — boot fails if multiple auth providers installed, warns if none

Screenshots

Users Module (Login + Dashboard)

Login PageAuthenticated Dashboard
logindashboard

Keycloak Module (OIDC Flow)

Keycloak LoginAuthenticated (Keycloak)
kc-loginkc-auth
Session Survives Token ExpiryAfter Logout
sessionlogout

QA Summary

  • 51 QA scenarios tested across 3 parallel agents (happy path, form validation, error states/edge cases)
  • 13 Keycloak E2E scenarios tested against live Keycloak 26.2.5 (Docker) with 30-second token lifetime
  • 15 token rotation scenarios verified (chained refresh, revocation, concurrent sessions, edge cases)
  • 1277 Python + 16 JS tests pass, zero regressions
  • 4 bugs found and fixed during QA: missing await on inertia.render, API logout not clearing session, timing side-channel on token login, bearer tokens not resolved by provider

Key Design Decisions

  • UserContext remains the single identity type — every downstream consumer (menus, permissions, audit, Inertia props) is unaffected by the provider swap
  • Browser sessions cache UserContext in the Starlette session cookie — independent of Keycloak token lifetime
  • Bearer tokens (mobile) are validated on every request — expired JWTs are rejected with 401
  • Refresh token rotation revokes the old token on each refresh (prevents replay)
  • Constant-time token login prevents timing-based email enumeration

Test Plan

  • make lint passes (ruff, ty, biome, tsc, file-size, metadata)
  • make test passes (1277 Python + 16 JS)
  • Start app with users module → login/logout/bearer tokens work
  • Start app with keycloak module + running Keycloak → OIDC login/logout works
  • Bearer token expiry returns 401, refresh works
  • SM020 fires if both providers installed simultaneously

Introduces the AuthProvider contract that allows swapping the users
module for a Keycloak OIDC module. Covers bearer token support for
mobile clients, JWT validation via JWKS, role mapping, and the
SM020/SM021 conflict diagnostics. Builds on the principal-resolver
chain from the #163 spec.
16-task TDD plan covering AuthProvider protocol, provider-agnostic
middleware, UsersAuthProvider, Keycloak OIDC module (JWKS, token
exchange, user cache), bearer/refresh token endpoints, SM020/SM021
diagnostics, and integration tests.
Add POST /api/users/auth/token (email+password login),
POST /api/users/auth/token/refresh (rotate tokens), and
DELETE /api/users/auth/token (revoke) for non-browser clients.
New model: RefreshToken with expiry and revocation tracking.
New settings: bearer_token_lifetime_seconds, refresh_token_lifetime_seconds.
Also fix keycloak module depends_on to include Settings (was failing tests).
Migration not generated — needs full app context.
Add 6 integration tests verifying both auth providers satisfy the
AuthProvider protocol, SM020/SM021 diagnostics fire correctly, and
module metadata is consistent.
Fix 4 regressions surfaced by the full suite:
- keycloak depends_on assertion (["Auth"] -> ["Auth", "Settings"])
- SM020 fires in test_app_state_has_sm_services (exclude Keycloak)
- typer 0.26 vendored click breaks Exit catch in CLI test
- PydanticUndefined default_factory fields crash settings serializer
- Remove keycloak/tests/__init__.py that shadowed root tests package
JWKSCache now requires issuer and audience at construction time and
always verifies them. Previously these were optional with empty-string
defaults, which silently disabled the checks — a token from any issuer
or audience would be accepted.
UsersAuthProvider.resolve_user() now checks for Authorization: Bearer
headers and validates the access token against the users_access_token
table before falling back to session-cookie resolution. This enables
mobile/API clients to authenticate using tokens from POST /api/users/auth/token.
BUG-001: POST /api/users/auth/logout now clears the Starlette session
in addition to deleting the sm_auth cookie. The stock fastapi-users
logout at /auth-inner/logout only cleared the cookie, leaving the
session user_id intact so the user remained authenticated.
BUG-002: token_login now runs a dummy bcrypt verification when the
user is not found, preventing timing-based email enumeration (existing
user took ~50ms for bcrypt, missing user was instant).
The login_page view returned a coroutine instead of a response,
causing a 500 error. inertia.render() is async and must be awaited.
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented May 27, 2026

Copy link
Copy Markdown

Deploying simple-module-python with Cloudflare Pages Cloudflare Pages

Latest commit:806996e
Status: ✅ Deploy successful!
Preview URL:https://599a5dad.simple-module-python.pages.dev
Branch Preview URL:https://worktree-pluggable-auth-keyc.simple-module-python.pages.dev

View logs

…gDict false positive
- package-lock.json: add @simple-module/keycloak workspace entry
- test_jwks.py: mark refetch test to allow unconsumed mock responses
(pytest-httpx strict mode in CI)
- pyproject.toml: ignore ty invalid-assignment rule (SQLModel ConfigDict
vs SQLModelConfig false positive across all modules)
- Regenerate i18n keys with all module locales (dashboard, bg_tasks, etc.
were missing from the previous gen-pages run)
- Rewrite JWKS refetch test to use unittest.mock.patch instead of
pytest-httpx (the sequential mock response ordering was unreliable
across pytest-httpx versions)
- test_jwks.py: remove unused httpx and AsyncMock imports
- pr.yml: add SM_MODULES_ENABLED to E2E smoke job to exclude Keycloak
(SM020 prevents both users and keycloak from running simultaneously;
the E2E smoke tests use the users module)
@antosubash
antosubash merged commit 66bb303 into mainMay 28, 2026
12 checks passed
antosubash added a commit that referenced this pull request May 28, 2026
PR #184 introduced SM_MODULES_ENABLED to exclude Keycloak from the
E2E smoke job. The allowlist must now also include AuditLog so the
module's routes (/audit_log, /api/audit_log) are mounted — otherwise
tests/e2e/test_audit_log_ui.py hits 404.
antosubash added a commit that referenced this pull request May 28, 2026
* docs: add audit log module design spec
Covers data model, capture mechanism (SQLAlchemy before_flush callback),
module structure, REST API, admin Browse page, and testing strategy.
* docs: add audit log module implementation plan
13-task TDD plan covering framework changes (AuditRecord, callback wiring),
module scaffold, model, service, API, Inertia Browse page, tests, and migration.
* feat(db): add AuditRecord dataclass and collect_audit_records diff logic
* feat(db): wire audit callback into DatabaseState and before_flush listener
* feat(audit_log): scaffold module package with constants and host wiring
* feat(audit_log): add AuditEntry model and DTO schemas
* feat(audit_log): add capture callback
* feat(audit_log): add service layer and deps
* feat(audit_log): add API and Inertia view endpoints
* feat(audit_log): add AuditLogModule with lifecycle hooks
* feat(audit_log): add i18n locale strings
* feat(audit_log): add Browse.tsx page with filters, pagination, and change diffs
* test(audit_log): add integration tests for capture, API filtering, and recursion guard
Also fix AuditEntryRead.id type from str to uuid.UUID to match the
AuditEntry model (surfaced by the new tests).
* migration(audit_log): add audit_log_audit_entry table
* chore: regenerate i18n and package-lock with audit_log module
* fix(audit_log): use datetime-local inputs and preserve page_size in navigation
* fix(test): ensure test_filter_by_action is non-vacuous
* fix(audit_log): deterministic pagination, error-resilient capture, entity_id filter, non-null created_at
* fix(db): correctly classify soft-deleted entities and exclude SoftDeleteMixin fields from audit diffs
* fix(audit_log): gracefully handle invalid query params in view endpoint
View endpoint now accepts page/page_size as raw strings and sanitizes
them (clamp to valid range, fall back to defaults on parse failure)
instead of relying on FastAPI Query(ge=, le=) constraints that produce
raw JSON 422 errors unfriendly for Inertia page visits.
API endpoint retains strict validation — callers get proper 422s.
* fix(db): two-phase audit capture resolves DB-assigned integer PKs
BUG-002: Entities with DB-assigned integer PKs (e.g. id: int | None =
Field(default=None, primary_key=True)) were recorded in the audit log
with entity_id="" because _entity_pk_str() ran in before_flush while
the PK was still None. UUID PKs were unaffected because default_factory
populates them Python-side.
The fix splits audit capture into two phases:
Phase 1 (before_flush): snapshot_changes() reads attribute history
(which is wiped after flush) and stores per-entity diffs alongside
the live object reference in session.info — not yet resolved
entity_ids.
Phase 2 (after_flush_postexec): _after_flush_audit pops the pending
snapshots, calls finalize_records() to resolve entity_id from the
now-populated PK, and dispatches to the audit_callback. The added
AuditEntry rows land in session.new and are flushed when commit
runs autoflush.
collect_audit_records remains a public single-phase wrapper for tests
and any caller whose PKs are already populated.
* chore(audit_log): format files, fix frozenset typing, add e2e specs
- ruff format applied (migration, service.py, e2e spec)
- ruff check fixed unused __init__.py imports
- _excluded_fields return type matches actual frozenset usage
- Add 3 e2e tests for audit_log UI (renders, integer-PK regression, filter)
* chore: add QA report and verification screenshots
* style(audit_log): align Browse page layout with other module pages
* fix(audit_log): satisfy CI checks for the layout pass
- Extract FilterBar into its own component so Browse.tsx stays under
the 300-line cap
- Wire htmlFor/id on every filter label so biome's
noLabelWithoutControl is satisfied
- Split test fixtures into _audit_models.py so test_audit.py drops
back under 300 lines
* fix(audit_log): inline useT in ChangesList to satisfy TS strict typing
ChangesList previously received the t function via props with a loose
TFn alias. react-i18next's useT returns a strictly-typed t that wasn't
assignable to the alias. Switching to a local useT() call inside the
component drops the TFn alias entirely.
* fix(ci): unblock PR checks
- Ignore ty's invalid-assignment rule globally — every SQLModel
contracts/schemas.py with ``model_config = ConfigDict(...)`` trips it
because ty cannot see that SQLModelConfig is compatible with
pydantic's ConfigDict. Same pattern already used for
invalid-argument-type. Run on main locally surfaces the same noise.
- Catch ``typer.Exit`` (not ``click.exceptions.Exit``) in
test_missing_pyproject_exits_nonzero — modern typer (>=0.20)
vendors click under ``typer._click`` so the two classes diverged.
- Extract ``_MODULE_USERS`` constant in audit_log/module.py — the
hardcoded-strings check rejects module-name literals in
depends_on. Matches the pattern in dashboard/module.py.
- Fill in audit_log/README.md with Install + Usage sections — the
READMEs check requires both.
- Drop unused ``# ty: ignore[invalid-assignment]`` comment now that
the rule is globally ignored.
* ci(e2e): include AuditLog in SM_MODULES_ENABLED allowlist
PR #184 introduced SM_MODULES_ENABLED to exclude Keycloak from the
E2E smoke job. The allowlist must now also include AuditLog so the
module's routes (/audit_log, /api/audit_log) are mounted — otherwise
tests/e2e/test_audit_log_ui.py hits 404.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@antosubash