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, OIDCoidcAuthProvider,LoginPagecomponent
[!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-tsis not installedThis 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
authProviderusescredentials: "include"expecting HttpOnly cookies. The backend never callsSet-Cookie. The middleware runs withrequire_auth=False, sorequest.state.useris alwaysNone. The/auth/meendpoint returns a guest user, andauthProvider.check()redirects back to/login.
Social Login (Google OAuth2)
[!CAUTION] Not registered. The
oauth_routes_routeris not added tocreate_app()'s route handlers list — it exists as code inoauth_routes.pybut 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 providerGET /api/v1/auth/oauth/{provider}/callback— placeholder, no actual token exchange- Well-known configs defined for Google, GitHub, Microsoft
- No
SocialAccountDocType exists to link provider accounts to local users
Existing Code Map
Backend Layer
| File | Purpose | Code Exists | Actually Works |
|---|---|---|---|
| authentication.py | AuthenticationProtocol — chain-of-responsibility interface | ✅ | N/A (interface) |
| auth_context.py | AuthContextProtocol + UserContext model | ✅ | N/A (interface) |
| identity.py | IdentityProtocol, Token, Credentials models | ✅ | N/A (interface) |
| oauth.py | OAuth2Protocol, OAuth2Token, OAuth2UserInfo | ✅ | N/A (interface) |
| local_identity.py | LocalIdentityAdapter — argon2 hashing, JWT generation | ✅ | ❌ Never instantiated |
| federated_identity.py | FederatedIdentityAdapter — hydrate from gateway headers | ✅ | ❌ Never instantiated |
| strategies.py | BearerTokenAuth, 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.py | ASGI middleware — reads x-user-id headers | ✅ Registered | ❌ require_auth=False |
| app.py | create_app() — wires everything together | ✅ | ❌ Never calls configure_auth_routes() |
Frontend Layer
| File | Purpose | Code Exists | Actually Works |
|---|---|---|---|
| authProvider.ts | Cookie-based auth provider for Refine | ✅ | ❌ Expects cookies, backend returns JSON |
| oidcAuthProvider.ts | OIDC PKCE flow with oidc-client-ts | ✅ | ❌ oidc-client-ts not installed |
| authConfig.ts | Auth strategy config (cookie vs oidc) | ✅ | ❌ Config never injected |
| LoginPage.tsx | Email/password form using useLogin | ✅ | ❌ Submits but auth check fails |
| App.tsx | Main app with auth routing | ✅ | ⚠️ Routes work, auth doesn't |
Detailed Design
Strategy A: BFF Cookie Mode (Recommended for Indie/Self-hosted)
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:
auth_routes.py/login: After_user_manager.authenticate(), create a session entry and returnSet-Cookieheader instead of raw JWTauth_routes.py/logout: Delete session from store, returnSet-CookiewithMax-Age=0auth_middleware.py: AddSessionCookieAuthto the auth chain (already implemented instrategies.py)- Session Store: New
SessionStoreProtocolwithRedisSessionAdapterorDatabaseSessionAdapter
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:
oauth_routes.py/callback: Implement actual code exchange usinghttpxSocialAccountDocType: Link provider user IDs to local users- User upsert logic: Create user on first OAuth login, link on subsequent
- 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
| Alternative | Pros | Cons | Verdict |
|---|---|---|---|
| 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 localStorage | Simplest 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 / WebAuthn | Phishing-resistant. No passwords. Modern UX. | Browser support varies. Complex server implementation. Users unfamiliar. | ⚠️ Future phase |
Recommended Approach
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.tsalready expects cookies (credentials: "include") - The
SessionCookieAuthstrategy already exists instrategies.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,
DatabaseSessionAdapterprovides 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:LocalIdentityAdapterwith argon2 + JWT (237 lines)user_manager.py:UserManagerservice (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)SocialAccountDocType: Provider→user linking (89 lines)LocalUserDocType: 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(): InstantiateLocalIdentityAdapter→UserManager→ callconfigure_auth_routes(user_manager) - Session store selection: Use
RedisSessionAdapterifREDIS_URLis set, elseDatabaseSessionAdapter - Modify
/login: After_user_manager.authenticate(), create session via session store + returnSet-Cookie: session_id=UUID; HttpOnly; Secure; SameSite=Lax - Modify
/logout: Delete session + setSet-CookiewithMax-Age=0 - Wire
AuthChain: Replace header-basedAuthMiddlewarewithSessionCookieAuth→BearerTokenAuth→HeaderAuthchain - Enable auth: Set
require_auth=Truewith excluded paths:/health,/schema,/api/v1/auth/login,/api/v1/auth/logout,/desk - Admin seeding: Add
m create-adminCLI command usingUserManager.create()+hash_password() - Ensure tables: Include
LocalUser+SocialAccountin schema sync (currently onlyapi_resource=TrueDocTypes get tables)
Phase 2: Generic OIDC + Auto-Linking
- Register OAuth routes: Add
oauth_routes_routertocreate_app()route handlers - Implement
/callback: Exchange code for tokens usinghttpx, validate state - Implement
auto_link_user()service:- Match
SocialAccountby(provider, provider_user_id)→ login - If missing +
email_verified=True→ matchLocalUserby email → link + login - If no match → create
LocalUser(random password) +SocialAccount→ login
- Match
- 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.tsto 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.tscomplexity (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 insession_store.py) whenREDIS_URLis 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-configurationauto-discovery. -
Existing Code:
oauth_routes.pyalready hasWELL_KNOWN_PROVIDERS,get_oidc_well_known(), andis_generic_oidc_provider(). The/startendpoint works; the/callbackneeds token exchange implementation.
3. Account Linking: Auto-Link via Verified Email
- Decision: Automatically link OAuth accounts to local users if emails match AND email is verified by the provider.
- Security: Only trust
email_verified=trueOIDC claim. If provider does not returnemail_verifiedor it isfalse, create a newLocalUserinstead of linking. - Flow:
- Login with OIDC provider
- Check for existing
SocialAccountby(provider, provider_user_id)→ login immediately - If not found, check
email_verifiedclaim from provider - If verified, check for
LocalUserwith same email → createSocialAccountlink → login - If no email match, create new
LocalUser(with random password) +SocialAccount→ login - If
email_verifiedisfalseor missing, always create a newLocalUser(never auto-link)
- Existing Code:
SocialAccountDocType is defined atframework_m_core/doctypes/social_account.py.
4. Roles & Permissions: Best Practices
- Decision: Keep roles simple — a
rolesfield (JSON list of strings) onLocalUser. 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-rolesheader 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
Permissionmodel can map(role, doctype, action)→ allow/deny. For now, theMeta.requires_authDocType flag +apply_rlsis 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.comis 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.
- RBAC (Role-Based Access Control): Standard approach. Roles grant permissions. Users get roles. Framework M uses this via the
References
- ARCHITECTURE.md — §3.3 Authorization Strategy, §5.4 Metadata-Driven UI
- migration-from-frappe.md — §2 Getting Current User, §9 Permissions
- auth_routes.py — Current login endpoint
- oauth_routes.py — OAuth routes (placeholder callback)
- strategies.py — All 6 auth strategies including
SessionCookieAuth - authProvider.ts — Frontend cookie auth provider
- OWASP Session Management Cheat Sheet
- OAuth 2.0 for Browser-Based Apps (RFC draft)