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, 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.
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.
OAuthServiceexists with auto-link logic and test coverage.SocialAccountDocType exists and auth tables are included in startup schema mapping.
What is still missing (critical for generic OIDC)
-
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".
-
Missing repository adapters for OAuthService
OAuthServiceexpectssocial_repo.find_by_provider/create/updateanduser_repo.get/find_by_email/create.- Current app wiring only includes adapters for LocalUser (get/get_by_email/save) and Session.
-
State validation is incomplete
- OAuth callback checks only
statepresence, not whether that state was issued/valid.
- OAuth callback checks only
-
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.
-
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.
-
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_urlis preferred when available.authorization_url+token_url(+ optionaluserinfo_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:
- built-in well-known providers (
google/github/microsoft), - provider
discovery_urlfetch, - explicit endpoint config.
- built-in well-known providers (
- Persist and validate OAuth state:
- issue state token with short TTL,
- verify existence and invalidate on callback.
- Normalize provider user identity extraction from
sub/idand 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.providersinto 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:
Secureon HTTPS,SameSiteandDomainoptions.
- 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/mefor:- 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/meidentity. - 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
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)