Skip to content

Add generic OIDC auth provider with native Microsoft Entra support - #192

Open
antosubash wants to merge 5 commits into
mainfrom
claude/microsoft-entra-native-support-GoxVH
Open

Add generic OIDC auth provider with native Microsoft Entra support#192
antosubash wants to merge 5 commits into
mainfrom
claude/microsoft-entra-native-support-GoxVH

Conversation

@antosubash

Copy link
Copy Markdown
Owner

What & why

Adds native Microsoft Entra ID (Azure AD) support. Rather than clone the keycloak module into a third near-identical provider, this introduces one discovery-driven generic OIDC auth-provider module (modules/oidc/) and ships Entra as a first-class preset — which also covers Auth0, Okta, Zitadel, Authentik, Keycloak, and any OIDC-compliant IdP for free.

The framework already has a pluggable auth-provider slot (_is_auth_provider = True + app.state.auth.auth_provider, enforced single by SM020/SM021), and modules/keycloak/ was already ~90% provider-agnostic. This module generalizes that pattern.

How it works

  • Configured from the provider's discovery document (.well-known/openid-configuration), which supplies the authorize/token/JWKS/end-session endpoints and issuer — no per-provider URL templating.
  • Preset-driven: set SM_OIDC_PROVIDER + a few secrets; the preset fills claim defaults and derives the discovery URL.
  • Supports both browser-interactive login and bearer-token validation (mobile/API).
  • Optional swap-in — not active in host/ by default (like keycloak). Keycloak is left untouched.

Native Entra

SM_OIDC_PROVIDER=entra
SM_OIDC_TENANT_ID=<tenant-guid>
SM_OIDC_CLIENT_ID=<client-id>
SM_OIDC_CLIENT_SECRET=<client-secret>
# redirect URI: https://<host>/api/oidc/auth/callback

Entra specifics baked into the preset:

  • Validate the id_token in the browser callback (Entra's MS Graph access tokens aren't app-validatable).
  • Key the user cache on the stable oid claim (not sub).
  • Default roles_claim_path to roles (Entra app roles).
  • The JWKS validator accepts RSA signing keys that omit alg, as Entra's keys do (keycloak's stricter filter would have dropped them).

Files

New module under modules/oidc/ mirroring the keycloak structure: settings.py (+ presets.py), discovery.py, client.py, jwks.py, provider.py, models.py, module.py, endpoints/{api,views}.py, pages/{Login,LoggedOut}.tsx, locales/en.json, README, and tests. Workspace wiring added to root pyproject.toml (ty paths + pytest testpaths; modules/* glob already covers membership). One existing test (test_app_state_has_sm_services) generalized to keep a single auth provider now that three are installed in the dev workspace.

Verification

  • uv run pytest — full suite green (1319 passed, 1 skipped); new module has 33 tests covering presets, provider claim-mapping, discovery parsing, client URL building, and JWKS validation (incl. the no-alg Entra case).
  • make lint — ruff format/check, ty, biome, per-module tsc, 300-line cap, metadata/readme/hardcoded-string checks all pass.
  • make doctor reports SM020 in the dev workspace only because users + keycloak + oidc are all installed at once; it is non-fatal in dev and unchanged in nature from the pre-existing users + keycloak situation. With oidc not in host deps, the running host's active provider set is unchanged.

Manual end-to-end Entra login was not exercised (requires a real Entra app registration); steps are in the module README.

https://claude.ai/code/session_01SbQuMY1b1tEoKkYTs1eb1C


Generated by Claude Code

Introduce a new optional `oidc` auth-provider module that authenticates
against any OpenID Connect provider via its discovery document, with a
first-class `entra` preset for Microsoft Entra ID (Azure AD).

Configuration is preset-driven: pick SM_OIDC_PROVIDER plus the few required
secrets and the preset fills claim defaults and derives the discovery URL.
The well-known document supplies the authorize/token/JWKS/end-session
endpoints and issuer, so no per-provider URLs are hardcoded.

Entra specifics baked into the preset: validate the id_token in the browser
callback (Entra's Graph access tokens aren't app-validatable), key the user
cache on the stable `oid` claim, and default the roles claim to `roles`. The
JWKS validator also accepts RSA keys that omit `alg`, as Entra's keys do.

Supports both browser-interactive login and bearer-token validation. Shipped
as an optional swap-in (not active in host/ by default) like the keycloak
module; keycloak is left untouched. Generalize test_app_state_has_sm_services
to keep a single auth provider now that three are installed in the workspace.

https://claude.ai/code/session_01SbQuMY1b1tEoKkYTs1eb1C
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 5, 2026

Copy link
Copy Markdown

Deploying simple-module-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: d9fbf04
Status: ✅  Deploy successful!
Preview URL: https://a794ce79.simple-module-python.pages.dev
Branch Preview URL: https://claude-microsoft-entra-nativ.simple-module-python.pages.dev

View logs

@antosubash
antosubash marked this pull request as ready for review June 5, 2026 09:55
…route

Three issues found while testing the OIDC provider end-to-end:

- Security: the login nonce was stored in the session then discarded at
  callback without ever being compared to the id_token `nonce` claim, so a
  separately-obtained id_token could be replayed/injected. Now validated
  (OIDC Core 3.1.3.7 §11).
- Correctness: `_upsert_user_cache` opened its own session and only flushed,
  so the subject->UUID row was rolled back on close and never persisted —
  every login minted a fresh framework id. Now commits explicitly.
- Routing: the `oidc_login`/`oidc_callback` route function names collide with
  keycloak's, so `url_for("oidc_callback")` could resolve to keycloak's
  endpoint and send the IdP the wrong redirect_uri. Routes now carry unique
  names (`oidc_auth_login` / `oidc_auth_callback`).

Adds an end-to-end login->callback flow test (real RS256 id_token validated
against an injected JWKS key) covering the happy path, stable-id reuse across
logins, and rejection of nonce/state/audience mismatches. Also adds the
project's standard inline `unsupported-base` suppression to the new model.

Claude-Session: https://claude.ai/code/session_012kKthZeQPYEUWRL4jygquY
ty 0.0.52 (released 2026-06-23) tightened its diagnostics, turning the
lint gate red on main independently of any single PR:

- It now requires the project's inline `# ty: ignore[unsupported-base]`
  SQLModel suppression (carried by 24 model classes) on keycloak's
  `KeycloakUserCache`, which was missing it.
- It flags four `# ty: ignore[invalid-assignment]` directives as unused,
  because `invalid-assignment` is already globally ignored in pyproject.

Add the missing keycloak suppression and drop the four redundant inline
directives. No behaviour change.

Claude-Session: https://claude.ai/code/session_012kKthZeQPYEUWRL4jygquY
antosubash added a commit that referenced this pull request Jun 25, 2026
…ntial guards & admin UI (#219)

* docs: design for external (SSO) users in the users module

Marks IdP-provisioned users external with a genuinely null password,
guards all password-credential paths, surfaces the marking in the admin
UI, and keeps normal role assignment. Independent of PR #192.

Claude-Session: https://claude.ai/code/session_012kKthZeQPYEUWRL4jygquY

* feat(users): provision external (SSO) users with null password

Users created via OAuth/OIDC login (Microsoft/Entra, Google, GitHub,
generic OIDC) are now first-class rows in users_user, marked external
with a truly NULL password instead of a random one — so they sign in
only through their IdP and admins manage their roles like any other
user.

Backend
- models/user.py: hashed_password is now nullable; add is_external
  (server_default false). Migration 92965b00f105 makes the column
  nullable + adds the marker via batch_alter_table (SQLite rebuild
  recreates the lower(email) functional index). Forks off the users
  head (873ca2015033), not the keycloak branch, keeping the users
  migration line independent of the optional keycloak provider.
- oauth/api.py: flag the request before find-or-create so the manager
  can mark only *newly provisioned* OAuth users. Logins that link to an
  existing password account are untouched (on_after_register won't
  fire) — link-by-email behaviour is preserved.
- manager.py: on_after_register nulls the password + sets is_external
  for OAuth-provisioned users; authenticate() and forgot_password()
  refuse external users (no local password); generate_reset_password_token
  raises ExternalUserNoPasswordError instead of hashing None.
- admin reset-password-link returns 409 for external users.
- expose is_external in UserRead / UserListItem + admin list query.

Frontend
- list + detail surface an "External · SSO" / SSO badge; the detail
  page hides the password-reset action for external users and explains
  why.

Tests
- test_external_users.py: provisioning (new vs linked), credential
  guards (login, reset-link, forgot-password no-op), role assignment,
  and admin-list visibility. Full users suite: 303 passed.

No default role is assigned; scope covers any OAuth/OIDC-provisioned
user. The standalone oidc module (PR #192) remains separate for the
bearer/stateless use case.

Claude-Session: https://claude.ai/code/session_012kKthZeQPYEUWRL4jygquY

* fix(users): block bearer-token login for external (SSO) users

Code-review (round 1) finding: the bearer-token login path
(POST /api/users/auth/token) verified the password directly instead of
going through the guarded UserManager.authenticate, so an external user
(hashed_password is None) hit verify_and_update(pw, None) -> TypeError ->
500, which also leaked account type via timing/error (missing user got a
clean 401 after a dummy hash). Treat null-password users like a missing
user: run the dummy hash and return 401.

Also align forgot_password's no-op guard to (is_external or
hashed_password is None) to match generate_reset_password_token, add a
regression test for the bearer path, and note the downgrade constraint.

Claude-Session: https://claude.ai/code/session_012kKthZeQPYEUWRL4jygquY

* fix: resolve ty 0.0.52 diagnostics (repo-wide lint unblock)

ty 0.0.52 newly flags previously-valid suppressions. Remove four now-unused
'# ty: ignore[invalid-assignment]' directives (framework/core/tests/*,
users/backend.py) and add '# ty: ignore[unsupported-base]' to keycloak's
SQLModel table class, matching the users User model. Comment-only; no behavior
change. Unblocks 'make lint' / CI typecheck, which fails repo-wide otherwise.

Claude-Session: https://claude.ai/code/session_012kKthZeQPYEUWRL4jygquY
Sign up for free to 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.

2 participants