Skip to main content

RFC-0006: Authentication — Simple Login & Social OAuth for Framework M

  • Status: Implemented
  • Author(s): @anshpansuriya14
  • Created: 2026-02-11
  • Updated: 2026-02-12
  • TSC Decision: Approved

Summary​

This RFC documents how authentication works (and should work) in Framework M, covering simple email/password login and Login with Google/GitHub (OAuth2). It maps the current state of backend and frontend implementations, identifies critical gaps that prevent a working end-to-end login flow, analyzes alternative approaches, and proposes a concrete implementation plan.

Motivation​

Framework M's architecture mandates stateless, JWT-based auth with pluggable identity providers. The codebase has significant scaffolding code across four layers:

  • Core Interfaces: AuthenticationProtocol, IdentityProtocol, OAuth2Protocol, AuthContextProtocol
  • Backend Adapters: LocalIdentityAdapter (argon2 + JWT), FederatedIdentityAdapter, 6 auth strategies
  • Auth Routes: /api/v1/auth/login, /logout, /me, /oauth/{provider}/start|callback
  • Frontend: Cookie-based authProvider, OIDC oidcAuthProvider, LoginPage component
danger

Nothing currently works end-to-end. All of the above is scaffolding/mock code:

  • Backend login returns a hardcoded dev-mode mock token (UserManager is never wired)
  • Auth middleware runs with require_auth=False, so all requests pass through unauthenticated
  • OAuth routes are not even registered in the running application
  • Frontend authProvider expects cookies but backend never sets any
  • oidc-client-ts is not installed

This RFC documents the scaffolding that exists, identifies what needs to be implemented to make login actually work, and proposes the implementation plan.


Addendum (2026-02-25): Current Reality + Generic OIDC Execution Plan​

This addendum supersedes parts of the earlier "all mock" assessment. The codebase now has substantial auth wiring in place, and the remaining work is mostly OAuth runtime wiring + generic provider UX/config consistency.

What is already implemented​

  • Cookie-session login/logout/me is wired and active in backend auth routes.
  • Session store selection (Redis / DB fallback) is wired in app lifespan.
  • Auth chain includes SessionCookieAuth -> BearerTokenAuth -> HeaderAuth.
  • OAuth routes are registered in the app and callback performs token exchange + userinfo fetch.
  • OAuthService exists with auto-link logic and test coverage.
  • SocialAccount DocType exists and auth tables are included in startup schema mapping.

What is still missing (critical for generic OIDC)​

  1. OAuth runtime wiring gap in app startup

    • configure_oauth_routes(session_store, oauth_service) is not called from app lifecycle.
    • Result: callback can fail with "OAuth service not configured".
  2. Missing repository adapters for OAuthService

    • OAuthService expects social_repo.find_by_provider/create/update and user_repo.get/find_by_email/create.
    • Current app wiring only includes adapters for LocalUser (get/get_by_email/save) and Session.
  3. State validation is incomplete

    • OAuth callback checks only state presence, not whether that state was issued/valid.
  4. Generic provider config schema mismatch

    • Frontend accepts provider list/object; backend currently expects list + top-level provider blocks.
    • Need one canonical schema for all providers.
  5. Frontend provider visibility is not generic in template app

    • Template login filters to Google/GitHub/Microsoft labels only.
    • Auth0/Azure/custom providers should be shown dynamically.
  6. Runtime config injection does not expose OAuth provider metadata

    • window.__FRAMEWORK_CONFIG__ currently includes API/Meta/WS endpoints, but not auth.oauth provider list.

Canonical generic OIDC config (proposed)​

[auth.oauth]
enabled = true
providers = ["google", "github", "microsoft", "azure", "auth0", "company-sso"]

[auth.oauth.google]
client_id = "${GOOGLE_CLIENT_ID}"
client_secret = "${GOOGLE_CLIENT_SECRET}"
redirect_uri = "${APP_BASE_URL}/api/v1/auth/oauth/google/callback"

[auth.oauth.azure]
client_id = "${AZURE_CLIENT_ID}"
client_secret = "${AZURE_CLIENT_SECRET}"
discovery_url = "https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0/.well-known/openid-configuration"
scope = "openid profile email"
redirect_uri = "${APP_BASE_URL}/api/v1/auth/oauth/azure/callback"

[auth.oauth.auth0]
client_id = "${AUTH0_CLIENT_ID}"
client_secret = "${AUTH0_CLIENT_SECRET}"
discovery_url = "https://${AUTH0_DOMAIN}/.well-known/openid-configuration"
scope = "openid profile email"
redirect_uri = "${APP_BASE_URL}/api/v1/auth/oauth/auth0/callback"

[auth.oauth.company-sso]
client_id = "${SSO_CLIENT_ID}"
client_secret = "${SSO_CLIENT_SECRET}"
authorization_url = "https://sso.example.com/oauth2/authorize"
token_url = "https://sso.example.com/oauth2/token"
userinfo_url = "https://sso.example.com/oauth2/userinfo"
scope = "openid profile email"
redirect_uri = "${APP_BASE_URL}/api/v1/auth/oauth/company-sso/callback"

Notes:

  • discovery_url is preferred when available.
  • authorization_url + token_url (+ optional userinfo_url) remains supported fallback.

Execution plan (phased)​

Phase A — Finish backend OAuth runtime wiring (highest priority)​

  • Add OAuth route wiring in app lifespan:
    • instantiate OAuth-specific repository adapters
    • instantiate OAuthService
    • call configure_oauth_routes(runtime_session_store, oauth_service)
  • Extend LocalUser adapter with OAuth-required methods (find_by_email, create) or add dedicated OAuth user adapter.
  • Add SocialAccount adapter with methods used by OAuthService:
    • find_by_provider(provider, provider_user_id)
    • create(...)
    • update(...)

Phase B — Make generic OIDC robust (Azure/Auth0/custom)​

  • Add provider endpoint resolution priority:
    1. built-in well-known providers (google/github/microsoft),
    2. provider discovery_url fetch,
    3. explicit endpoint config.
  • Persist and validate OAuth state:
    • issue state token with short TTL,
    • verify existence and invalidate on callback.
  • Normalize provider user identity extraction from sub/id and preserve provider-specific fallback logic.
  • Keep email auto-linking only when email_verified=true (or equivalent trusted provider claim).

Phase C — Frontend/provider UX and config injection​

  • Inject auth.oauth.providers into runtime config script for both packaged Desk and template frontend.
  • Remove provider allowlist in template login page; render all configured providers with generic label fallback.
  • Keep special styling for known providers, use default style for unknown/custom providers.

Phase D — Security hardening and operability​

  • Make cookie flags configurable by environment:
    • Secure on HTTPS,
    • SameSite and Domain options.
  • Add structured logs for OAuth start/callback failures (without leaking secrets).
  • Add provider-health diagnostics endpoint (optional, admin-only) to test discovery/token/userinfo endpoints.

Phase E — Tests and release gates​

  • Unit tests for discovery-based provider resolution and explicit endpoint fallback.
  • Integration tests: start -> callback -> session cookie -> /auth/me for:
    • Google-style OIDC,
    • Azure-style tenant discovery,
    • Auth0 discovery,
    • fully custom OIDC provider.
  • Negative tests:
    • invalid/missing state,
    • token exchange errors,
    • missing sub/id,
    • unverified-email linking behavior.

Definition of done for "generic OIDC"​

  • A new provider can be enabled by config only (no code changes).
  • Azure AD / Entra ID works via discovery_url.
  • Auth0 works via discovery_url.
  • Custom OIDC works via explicit endpoints.
  • Social login results in local session cookie + correct /auth/me identity.
  • Existing local email/password login remains unaffected.

Current State Analysis​

How Login Currently "Works" (All Mock)​

Simple Login (Email + Password)​

The create_app() factory in app.py registers auth_routes_router but never calls configure_auth_routes(user_manager). This means _user_manager is always None, so the login endpoint always hits the dev-mode mock path:

Current Mock Login Flow

danger

Nothing works: The backend returns a mock JWT in the response body, but the frontend authProvider uses credentials: "include" expecting HttpOnly cookies. The backend never calls Set-Cookie. The middleware runs with require_auth=False, so request.state.user is always None. The /auth/me endpoint returns a guest user, and authProvider.check() redirects back to /login.

Social Login (Google OAuth2)​

danger

Not registered. The oauth_routes_router is not added to create_app()'s route handlers list — it exists as code in oauth_routes.py but is completely unreachable. Even if it were registered, the callback handler is a placeholder that returns {"note": "Full implementation requires authlib integration"}.

The OAuth routes exist as scaffolding:

  • GET /api/v1/auth/oauth/{provider}/start — generates a state token and redirects to the provider
  • GET /api/v1/auth/oauth/{provider}/callback — placeholder, no actual token exchange
  • Well-known configs defined for Google, GitHub, Microsoft
  • No SocialAccount DocType exists to link provider accounts to local users

Existing Code Map​

Backend Layer​

FilePurposeCode ExistsActually Works
authentication.pyAuthenticationProtocol — chain-of-responsibility interface✅N/A (interface)
auth_context.pyAuthContextProtocol + UserContext model✅N/A (interface)
identity.pyIdentityProtocol, Token, Credentials models✅N/A (interface)
oauth.pyOAuth2Protocol, OAuth2Token, OAuth2UserInfo✅N/A (interface)
local_identity.pyLocalIdentityAdapter — argon2 hashing, JWT generation✅❌ Never instantiated
federated_identity.pyFederatedIdentityAdapter — hydrate from gateway headers✅❌ Never instantiated
strategies.pyBearerTokenAuth, ApiKeyAuth, BasicAuth, HeaderAuth, SessionCookieAuth, AuthChain✅❌ Never configured
auth_routes.py/login, /logout, /me endpoints✅ Registered❌ Always returns mock
oauth_routes.py/oauth/{provider}/start, /callback✅❌ Not registered in app
auth_middleware.pyASGI middleware — reads x-user-id headers✅ Registered❌ require_auth=False
app.pycreate_app() — wires everything together✅❌ Never calls configure_auth_routes()

Frontend Layer​

FilePurposeCode ExistsActually Works
authProvider.tsCookie-based auth provider for Refine✅❌ Expects cookies, backend returns JSON
oidcAuthProvider.tsOIDC PKCE flow with oidc-client-ts✅❌ oidc-client-ts not installed
authConfig.tsAuth strategy config (cookie vs oidc)✅❌ Config never injected
LoginPage.tsxEmail/password form using useLogin✅❌ Submits but auth check fails
App.tsxMain app with auth routing✅⚠️ Routes work, auth doesn't

Detailed Design​

This is the recommended default for self-hosted deployments. The backend acts as a Backend-for-Frontend (BFF), setting HttpOnly cookies.

Simple Login Flow​

Simple Login Flow

Key Changes Required:

  1. auth_routes.py /login: After _user_manager.authenticate(), create a session entry and return Set-Cookie header instead of raw JWT
  2. auth_routes.py /logout: Delete session from store, return Set-Cookie with Max-Age=0
  3. auth_middleware.py: Add SessionCookieAuth to the auth chain (already implemented in strategies.py)
  4. Session Store: New SessionStoreProtocol with RedisSessionAdapter or DatabaseSessionAdapter

Google OAuth Flow​

Google OAuth Flow

Key Changes Required:

  1. oauth_routes.py /callback: Implement actual code exchange using httpx
  2. SocialAccount DocType: Link provider user IDs to local users
  3. User upsert logic: Create user on first OAuth login, link on subsequent
  4. Frontend LoginPage.tsx: Add "Login with Google" / "Login with GitHub" buttons

Strategy B: Client-Side OIDC (For Enterprise/External IdP)​

For enterprise deployments where an external Identity Provider (Keycloak, Auth0, Okta) handles all authentication.

Client-Side OIDC Flow

Existing Support: The oidcAuthProvider.ts and BearerTokenAuth strategy already handle this. Requires pnpm add oidc-client-ts and window.__FRAMEWORK_CONFIG__ configuration.


Alternatives​

AlternativeProsConsVerdict
A. BFF Cookie Mode (HttpOnly cookie, server-side session)Most secure for web apps. No XSS token theft. Simple frontend. CSRF protection via SameSite=Lax.Requires session store (Redis/DB). Slightly more backend complexity.✅ Recommended default
B. Client-Side OIDC (PKCE + in-memory tokens)Works with any OIDC provider. No backend session state. Enterprise-ready.Requires oidc-client-ts. Tokens in JS memory (lost on refresh). Complex silent-renew.✅ Good for enterprise
C. JWT in localStorageSimplest to implement. Survives page refresh.XSS vulnerability — any script can steal tokens. Not recommended by OWASP.❌ Rejected (security)
D. JWT in HttpOnly cookie (no session store)Stateless. No session store needed. Cookie-based security.Can't revoke tokens. Large cookies (JWT ≥ 500B). Token rotation complexity.⚠️ Acceptable tradeoff
E. Refresh token rotation (access JWT in memory + refresh in HttpOnly cookie)Best security. Short-lived access tokens. Revocable refresh.Most complex. Requires refresh endpoint. Token rotation logic.⚠️ Future enhancement
F. Passkeys / WebAuthnPhishing-resistant. No passwords. Modern UX.Browser support varies. Complex server implementation. Users unfamiliar.⚠️ Future phase

Strategy A (BFF Cookie Mode) as default with Strategy B available via config toggle.

Rationale:

  • Framework M targets indie/self-hosted deployments primarily — cookie mode is simplest and most secure
  • The existing authProvider.ts already expects cookies (credentials: "include")
  • The SessionCookieAuth strategy already exists in strategies.py
  • Enterprise users can switch to Strategy B via window.__FRAMEWORK_CONFIG__.auth.authStrategy = "oidc"

Drawbacks​

  • Session store dependency: BFF mode requires Redis or a sessions DB table. This adds infrastructure, but Redis is already required for cache/events. For dev/indie without Redis, DatabaseSessionAdapter provides a zero-dependency fallback.
  • CORS complexity: Cookie-based auth requires correct SameSite, Secure, and CORS configuration. Misconfiguration leads to silent failures.
  • OAuth provider registration: Each OIDC provider requires manual app registration and client secret management. Mitigated by the generic OIDC approach — users only need to provide well-known URLs.

Implementation Plan​

note

The codebase already has extensive scaffolding. The work is primarily wiring existing code and filling specific gaps. Existing code:

  • session_store.py: DatabaseSessionAdapter + RedisSessionAdapter (438 lines, fully implemented)
  • strategies.py: SessionCookieAuth + AuthChain + 4 other strategies (555 lines)
  • local_identity.py: LocalIdentityAdapter with argon2 + JWT (237 lines)
  • user_manager.py: UserManager service (188 lines)
  • auth_routes.py: Login/logout/me endpoints (252 lines, needs session cookie wiring)
  • oauth_routes.py: OAuth start/callback with generic OIDC support (279 lines, callback needs implementation)
  • SocialAccount DocType: Provider→user linking (89 lines)
  • LocalUser DocType: Email/password user (116 lines)

Phase 1: Wire Login + Session Cookies (Make Login Work)​

Connect the existing scaffolding so email/password login produces a working session.

  • Wire create_app(): Instantiate LocalIdentityAdapter → UserManager → call configure_auth_routes(user_manager)
  • Session store selection: Use RedisSessionAdapter if REDIS_URL is set, else DatabaseSessionAdapter
  • Modify /login: After _user_manager.authenticate(), create session via session store + return Set-Cookie: session_id=UUID; HttpOnly; Secure; SameSite=Lax
  • Modify /logout: Delete session + set Set-Cookie with Max-Age=0
  • Wire AuthChain: Replace header-based AuthMiddleware with SessionCookieAuth → BearerTokenAuth → HeaderAuth chain
  • Enable auth: Set require_auth=True with excluded paths: /health, /schema, /api/v1/auth/login, /api/v1/auth/logout, /desk
  • Admin seeding: Add a m user create --role Admin CLI command using UserManager.create() + hash_password()
  • Ensure tables: Include LocalUser + SocialAccount in schema sync (currently only api_resource=True DocTypes get tables)

Phase 2: Generic OIDC + Auto-Linking​

  • Register OAuth routes: Add oauth_routes_router to create_app() route handlers
  • Implement /callback: Exchange code for tokens using httpx, validate state
  • Implement auto_link_user() service:
    1. Match SocialAccount by (provider, provider_user_id) → login
    2. If missing + email_verified=True → match LocalUser by email → link + login
    3. If no match → create LocalUser (random password) + SocialAccount → login
  • CSRF state storage: Store OAuth state in session store with short TTL
  • Provider config: Load OIDC provider configs from framework_config.toml

Phase 3: Frontend Integration​

  • Update authProvider.ts to use cookie-based flow (remove JWT body parsing)
  • Add social login buttons to LoginPage.tsx (redirect to /api/v1/auth/oauth/{provider}/start)
  • Handle OAuth callback redirect (cookie already set by backend, just redirect to /)
  • Remove unused oidcAuthProvider.ts complexity (Strategy B deferred)

Phase 4: Tests + Docs​

  • Unit tests for session store adapters (Redis + DB)
  • Integration tests for login → session → /me flow
  • Integration tests for OAuth start → callback → session flow
  • Documentation updates

Architecture Decisions​

1. Session Storage: Redis (with DB fallback)​

  • Decision: Use Redis for session storage. Redis is already a dependency for cache/events.
  • Implementation: RedisSessionAdapter (already implemented in session_store.py) when REDIS_URL is set. DatabaseSessionAdapter (also implemented) as fallback for development/indie deployments without Redis.
  • Data: Store minimal session info (user_id, created_at, ip_address, user_agent, csrf_token).
  • TTL: 14 days (rolling), configurable via SessionConfig.
  • Existing Code: framework_m_standard/adapters/auth/session_store.py — both adapters are fully implemented.

2. OAuth Strategy: Generic OIDC​

  • Decision: Use a single Generic OIDC-compliant implementation for ALL providers (Google, GitHub, Microsoft, and any custom OIDC provider like Keycloak, Auth0, Okta).

  • Configuration (in framework_config.toml):

    [auth.oauth]
    enabled = true

    [auth.oauth.providers.google]
    client_id = "..."
    client_secret = "..."
    # Well-known URLs auto-resolved for google/github/microsoft

    [auth.oauth.providers.my-keycloak]
    client_id = "..."
    client_secret = "..."
    authorization_url = "https://keycloak.example.com/auth/realms/master/protocol/openid-connect/auth"
    token_url = "https://keycloak.example.com/auth/realms/master/protocol/openid-connect/token"
    userinfo_url = "https://keycloak.example.com/auth/realms/master/protocol/openid-connect/userinfo"
    scope = "openid email profile"
  • Well-Known Discovery: For custom providers, optionally support .well-known/openid-configuration auto-discovery.

  • Existing Code: oauth_routes.py already has WELL_KNOWN_PROVIDERS, get_oidc_well_known(), and is_generic_oidc_provider(). The /start endpoint works; the /callback needs token exchange implementation.

  • Decision: Automatically link OAuth accounts to local users if emails match AND email is verified by the provider.
  • Security: Only trust email_verified=true OIDC claim. If provider does not return email_verified or it is false, create a new LocalUser instead of linking.
  • Flow:
    1. Login with OIDC provider
    2. Check for existing SocialAccount by (provider, provider_user_id) → login immediately
    3. If not found, check email_verified claim from provider
    4. If verified, check for LocalUser with same email → create SocialAccount link → login
    5. If no email match, create new LocalUser (with random password) + SocialAccount → login
    6. If email_verified is false or missing, always create a new LocalUser (never auto-link)
  • Existing Code: SocialAccount DocType is defined at framework_m_core/doctypes/social_account.py.

4. Roles & Permissions: Best Practices​

  • Decision: Keep roles simple — a roles field (JSON list of strings) on LocalUser. No separate Role DocType for now.
  • Storage: ["Admin", "Manager", "User"] — simple JSON array.
  • Initial Admin: Created via CLI command m user create --role Admin. This is the easiest and most secure approach for bootstrapping.
  • Best Practices (noted for future reference):
    • RBAC (Role-Based Access Control): Standard approach. Roles grant permissions. Users get roles. Framework M uses this via the x-roles header already.
    • ABAC (Attribute-Based Access Control): LocalIdentityAdapter.get_attributes() already supports this. Can be extended for fine-grained policies.
    • Permission resolution: In the future, a Permission model can map (role, doctype, action) → allow/deny. For now, the Meta.requires_auth DocType flag + apply_rls is sufficient.
    • Principle of Least Privilege: New users default to ["User"] role. Admin must be explicitly granted via CLI.
    • CLI-first admin management: m user create --email admin@example.com --role Admin is the simplest, most secure bootstrapping method. No need for admin UI in Phase 1.
    • Future enhancements: Role management UI, permission matrices, team-based access can be layered on top of this simple foundation.

References​