Skip to main content

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

  • Status: Accepted
  • 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

[!CAUTION] 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:

sequenceDiagram
participant Browser
participant Frontend as Frontend (Vite + Refine)
participant Backend as Backend (Litestar)

Browser->>Frontend: User enters email + password
Frontend->>Backend: POST /api/v1/auth/login {email, password}
Note over Backend: _user_manager is None (never configured)
Note over Backend: FRAMEWORK_M_DEV_MODE defaults to "true"
Backend-->>Frontend: {access_token: "dev-token-user-at-example", user: {id: "dev-user-1", mock data}}
Note over Frontend: authProvider expects Set-Cookie but gets JSON body
Frontend->>Frontend: Login "succeeds" but no cookie is set
Frontend->>Backend: GET /api/v1/auth/me (no cookie sent)
Note over Backend: request.state.user is None (require_auth=False)
Backend-->>Frontend: {authenticated: false, id: "guest", name: "Guest User"}
Note over Frontend: authProvider.check() sees unauthenticated → redirects to /login

[!CAUTION] 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)

[!CAUTION] 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 interfaceN/A (interface)
auth_context.pyAuthContextProtocol + UserContext modelN/A (interface)
identity.pyIdentityProtocol, Token, Credentials modelsN/A (interface)
oauth.pyOAuth2Protocol, OAuth2Token, OAuth2UserInfoN/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✅ Registeredrequire_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-tsoidc-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

sequenceDiagram
participant Browser
participant Frontend
participant Backend
participant DB

Browser->>Frontend: Submit email + password
Frontend->>Backend: POST /api/v1/auth/login {email, password}
Backend->>DB: LocalUser lookup + argon2 verify
Backend->>Backend: Create session (UUID → Redis/DB)
Backend-->>Frontend: 200 OK + Set-Cookie: session_id=UUID; HttpOnly; Secure; SameSite=Lax
Frontend->>Backend: GET /api/v1/auth/me (cookie auto-sent)
Backend->>Backend: SessionCookieAuth validates session_id
Backend-->>Frontend: {id, email, name, roles}
Frontend->>Browser: Redirect to /

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

sequenceDiagram
participant Browser
participant Frontend
participant Backend
participant Google
participant DB

Browser->>Frontend: Click "Login with Google"
Frontend->>Backend: GET /api/v1/auth/oauth/google/start
Backend->>Backend: Generate state + PKCE verifier, store in session
Backend-->>Browser: 302 → accounts.google.com/o/oauth2/auth
Browser->>Google: User consents
Google-->>Browser: 302 → /api/v1/auth/oauth/google/callback?code=X&state=Y
Browser->>Backend: GET /callback?code=X&state=Y
Backend->>Backend: Validate state (CSRF check)
Backend->>Google: POST token endpoint (exchange code)
Google-->>Backend: {access_token, id_token}
Backend->>Google: GET /userinfo
Google-->>Backend: {sub, email, name, picture}
Backend->>DB: Find or create user (upsert SocialAccount)
Backend->>Backend: Create session
Backend-->>Browser: 302 → / + Set-Cookie: session_id=UUID

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.

sequenceDiagram
participant Browser
participant Frontend
participant IdP as Keycloak / Auth0
participant Backend

Browser->>Frontend: Click "Login"
Frontend->>IdP: PKCE Authorization Request
IdP-->>Browser: Login page
Browser->>IdP: Credentials
IdP-->>Frontend: Authorization code
Frontend->>IdP: Exchange code (with PKCE verifier)
IdP-->>Frontend: {access_token (JWT), id_token}
Frontend->>Frontend: Store tokens in memory
Frontend->>Backend: API calls with Authorization: Bearer <token>
Backend->>Backend: BearerTokenAuth validates JWT (public key)
Backend-->>Frontend: Response

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 LocalIdentityAdapterUserManager → 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 SessionCookieAuthBearerTokenAuthHeaderAuth chain
  • Enable auth: Set require_auth=True with excluded paths: /health, /schema, /api/v1/auth/login, /api/v1/auth/logout, /desk
  • Admin seeding: Add m create-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 create-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 create-admin --email admin@example.com 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