Skip to main content

RFC-0003: 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.


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