diff --git a/auth/.gitignore b/auth/.gitignore new file mode 100644 index 0000000..10977d4 --- /dev/null +++ b/auth/.gitignore @@ -0,0 +1,2 @@ +*.pat +dev-app-config.json diff --git a/auth/identity-provider-evaluation.md b/auth/identity-provider-evaluation.md new file mode 100644 index 0000000..0617220 --- /dev/null +++ b/auth/identity-provider-evaluation.md @@ -0,0 +1,664 @@ +# Identity Provider Evaluation + +**Date:** February 2026 +**Goal:** Choose a free, open-source identity provider to hand off authentication from an open-source web app. + +Disclaimer: This analysis was completely done by AI with some human guidelines it must look for. + +## Requirements Summary + +| # | Requirement | Priority | +|---|------------|----------| +| 1 | OIDC social logins (Google, Microsoft, Apple, GitHub, ...) | Must | +| 2 | Basic email/password authentication | Must | +| 3 | User management (reset password, admin dashboard) | Must | +| 4 | Provides login UI (SDK or redirect-based) | Must | +| 5 | Fully open source, no resource-based limits in OSS version | Must | +| 6 | Free to self-host without per-user or per-resource restrictions | Must | +| 7 | Multi-tenant support | Nice | +| 8 | Paid support only (all features in OSS) | Preferred | +| 9 | Extensible (header-based auth, custom auth flows) | Nice | + +--- + +## Candidates Overview + +| Provider | License | Language | GitHub Stars | Self-Host | Login UI | OIDC Provider | Acts as IdP | +|----------|---------|----------|-------------|-----------|----------|--------------|-------------| +| **Keycloak** | Apache 2.0 | Java | 26k+ | Yes | Yes (redirect) | Yes | Yes | +| **ZITADEL** | AGPL-3.0 | Go | 12.9k | Yes | Yes (redirect) | Yes (certified) | Yes | +| **Logto** | MPL-2.0 | TypeScript | 11.6k | Yes | Yes (redirect) | Yes | Yes | +| **authentik** | Source-available* | Python | 15k+ | Yes | Yes (redirect) | Yes | Yes | +| **Ory (Kratos+Hydra)** | Apache 2.0 | Go | 12k+ (combined) | Yes | Headless (BYO UI) | Yes | Yes | +| **Hanko** | AGPL-3.0 | Go | 8.8k | Yes | Yes (web components) | Limited | No | +| **SuperTokens** | Apache 2.0 | Java (core) | 12k+ | Yes | Yes (SDK embeds) | No (not an IdP) | No | +| **Casdoor** | Apache 2.0 | Go | 10k+ | Yes | Yes (redirect) | Yes | Yes | +| **FusionAuth** | Proprietary (free tier) | Java | N/A | Yes | Yes (redirect) | Yes | Yes | +| **Better Auth** | MIT | TypeScript | 8k+ | N/A (library) | No (BYO UI) | No | No | +| **Authelia** | Apache 2.0 | Go | 23k+ | Yes | Yes (portal) | Yes (certified) | Partial | +| **Dex** | Apache 2.0 | Go | 10.6k | Yes | Yes (redirect, basic) | Yes | Yes (federation only) | +| **Rauthy** | Apache 2.0 | Rust | 942 | Yes | Yes (redirect) | Yes | Yes | +| **FerrisKey** | Apache 2.0 | Rust | 524 | Yes | Yes (redirect) | Yes | Yes | + +\* authentik open-source edition is "source available" (not a standard OSS license); enterprise features require paid license. + +--- + +## Detailed Evaluation + +### 1. Keycloak + +**Website:** https://www.keycloak.org/ +**License:** Apache 2.0 +**Backed by:** Red Hat / CNCF (incubation project) + +| Criterion | Rating | Notes | +|-----------|--------|-------| +| OIDC Social Login | Excellent | Google, Microsoft, Apple, GitHub, Facebook, and any custom OIDC/SAML provider | +| Email/Password | Yes | Built-in with configurable password policies | +| User Management | Excellent | Full admin console, user federation, password reset, account self-service | +| Login UI | Yes | Hosted login pages via redirect; fully themeable | +| OSS Limits | **None** | Fully open source, no user/resource limits whatsoever | +| Multi-Tenant | Yes | Via "realms" - each realm is an isolated tenant | +| Extensibility | Excellent | Custom authenticators, SPI extensions, user federation SPIs, protocol mappers | +| Paid-only Features | **None** | Everything is in the open source version | +| Maturity | Very High | Battle-tested for 10+ years, massive community, CNCF incubation | + +**Pros:** +- The most mature and feature-complete open-source IdP +- Truly zero restrictions - Apache 2.0 license, no gated features +- Enormous community and ecosystem +- OIDC Certified, supports SAML 2.0, LDAP, Kerberos +- Extensible via Java SPIs (custom authenticators, user storage, etc.) +- Excellent multi-tenancy via realms + +**Cons:** +- Java-based, heavier resource footprint (~512MB+ RAM minimum) +- Admin UI and theming can feel dated +- Steeper learning curve for initial setup +- No embedded SDK/web component approach; always redirect-based +- Requires more DevOps effort to operate in production + +**Verdict:** Best choice if you want maximum features, zero restrictions, and proven stability. The gold standard for self-hosted open-source IdPs. + +--- + +### 2. ZITADEL + +**Website:** https://zitadel.com/ +**License:** AGPL-3.0 (commercial license available) + +| Criterion | Rating | Notes | +|-----------|--------|-------| +| OIDC Social Login | Excellent | All major providers, custom OIDC/SAML | +| Email/Password | Yes | Built-in | +| User Management | Excellent | Console UI, self-service portal, admin APIs | +| Login UI | Yes | Hosted login (redirect), new V2 login in beta | +| OSS Limits | **None** | All features available in OSS, unlimited users | +| Multi-Tenant | Excellent | Native multi-tenancy with organizations, best-in-class B2B support | +| Extensibility | Good | Actions (custom code on events), webhooks, GRPC/REST APIs | +| Paid-only Features | **None** (cloud extras are hosting/support only) | +| Maturity | High | OIDC Certified, ISO 27001, event-sourced architecture | + +**Pros:** +- Purpose-built for multi-tenancy (organizations, role delegation, domain discovery) +- Single Go binary, lightweight and fast to deploy +- Event-sourced architecture provides unlimited audit trail +- All features available in self-hosted version +- OIDC Certified, SAML 2.0, LDAP, SCIM 2.0 support +- Cloud free tier includes 25K daily active users + +**Cons:** +- AGPL-3.0 license may be a concern if embedding in proprietary code (but fine for using as a service) +- Younger project than Keycloak, smaller ecosystem +- Custom extensibility more limited than Keycloak's SPI system +- Login UI customization is good but not as mature as Keycloak's theming + +**Verdict:** Excellent choice, especially for B2B/multi-tenant scenarios. True open-source with no limits. The Go-based architecture makes it lighter than Keycloak. + +--- + +### 3. Logto + +**Website:** https://logto.io/ +**License:** MPL-2.0 + +| Criterion | Rating | Notes | +|-----------|--------|-------| +| OIDC Social Login | Excellent | 30+ connectors, custom OIDC/SAML | +| Email/Password | Yes | Built-in | +| User Management | Good | Admin console, user management, audit logs | +| Login UI | Yes | Beautiful pre-built sign-in experience (redirect), customizable | +| OSS Limits | **Minor** | OSS is fully functional but some enterprise features (SSO connectors) may have limits in cloud | +| Multi-Tenant | Yes | Organizations with RBAC, JIT provisioning | +| Extensibility | Moderate | Custom token claims, webhooks, custom CSS/UI | +| Paid-only Features | Some cloud-only features (Enterprise SSO at $48/connector, SAML apps at $96/app) | +| Maturity | Medium | Modern, actively developed, growing community | + +**Pros:** +- Modern, developer-friendly with excellent documentation +- Beautiful default login UI with customization options +- SDKs for 30+ frameworks +- Good multi-tenancy via organizations +- MPL-2.0 is a permissive copyleft license +- Token-based pricing model (cloud) is cost-effective + +**Cons:** +- Self-hosted version may lack some enterprise features available in cloud +- Enterprise SSO and SAML apps are priced add-ons in cloud +- TypeScript/Node.js stack may not suit all environments +- Requires PostgreSQL +- Younger project, smaller community than Keycloak/ZITADEL + +**Verdict:** Great developer experience and modern architecture. Good for projects that value ease of integration. Check if the self-hosted version includes all features you need. + +--- + +### 4. authentik + +**Website:** https://goauthentik.io/ +**License:** Source-available (not a traditional OSS license); enterprise features require paid subscription + +| Criterion | Rating | Notes | +|-----------|--------|-------| +| OIDC Social Login | Good | Standard social providers | +| Email/Password | Yes | Built-in | +| User Management | Good | Web UI, user management, audit logs | +| Login UI | Yes | Full web portal (redirect) | +| OSS Limits | **Moderate** | Core is source-available; enterprise features (Google Workspace, Entra ID integrations, mTLS, etc.) require $5/user/month | +| Multi-Tenant | Partial | Via tenants, but less sophisticated than ZITADEL | +| Extensibility | Good | Flows & stages system, customizable policies, proxy provider | +| Paid-only Features | Yes - enterprise integrations, enhanced audit logging, device trust, compliance features | +| Maturity | Medium-High | Over 1M installations, used by Cloudflare, CoreWeave | + +**Pros:** +- Very flexible flow system for customizing authentication +- Proxy provider for legacy apps without OIDC/SAML support +- Application proxy with RDP/SSH/VNC support (unique feature) +- Supports OIDC, SAML, LDAP, RADIUS, SCIM, Kerberos +- Strong presence in homelab/self-hosted community + +**Cons:** +- **Not truly open source** - source-available license with enterprise gating +- Enterprise features cost $5/internal user/month +- No hosted service available (self-host only) +- Python-based, can be heavier than Go alternatives +- Guarantee that OSS features won't become enterprise-only, but enterprise features stay paid + +**Verdict:** Powerful and flexible, but the source-available licensing and paid enterprise features make it a **partial fit** for the stated requirements. The gated features may matter for your use case. + +--- + +### 5. Ory (Kratos + Hydra + Oathkeeper + Keto) + +**Website:** https://www.ory.sh/ +**License:** Apache 2.0 (open source components) + +| Criterion | Rating | Notes | +|-----------|--------|-------| +| OIDC Social Login | Good | Via Kratos identity providers | +| Email/Password | Yes | Via Kratos | +| User Management | Moderate | API-driven, no built-in admin UI (Ory Network has console) | +| Login UI | **No** | Headless - you must build your own UI (Ory Elements available as reference) | +| OSS Limits | **None** | Apache 2.0, no restrictions | +| Multi-Tenant | Via Ory Network | Multi-tenancy is a cloud feature | +| Extensibility | Excellent | Modular architecture, webhooks, Jsonnet data mappers | +| Paid-only Features | Cloud features (console, managed hosting, enterprise support) | +| Maturity | High | Used by OpenAI, Mistral AI; billions of API requests | + +**Pros:** +- Truly modular: use only what you need (Kratos for identity, Hydra for OAuth2, Keto for permissions) +- Apache 2.0 license for all core components +- Used by major companies (OpenAI, Axel Springer) +- Cloud-native, stateless, horizontally scalable +- Excellent for microservices architectures + +**Cons:** +- **No built-in login UI** - you must build your own +- Requires deploying and managing multiple services +- Steep learning curve +- Admin console only in Ory Network (paid) +- Multi-tenancy primarily a cloud feature +- Higher operational complexity + +**Verdict:** Technically excellent and truly open source, but the headless approach (no login UI) and operational complexity make it a harder sell if you want to "hand off" auth quickly. Best for teams with strong DevOps capabilities. + +--- + +### 6. Hanko + +**Website:** https://www.hanko.io/ +**License:** AGPL-3.0 (backend), MIT (frontend elements) + +| Criterion | Rating | Notes | +|-----------|--------|-------| +| OIDC Social Login | Good | Google, Apple, GitHub, custom OIDC providers | +| Email/Password | Yes | Plus passkeys, passcodes | +| User Management | Basic | API-based, limited admin dashboard | +| Login UI | Yes | Hanko Elements web components (embed in your app) | +| OSS Limits | **None** | No user limits | +| Multi-Tenant | **No** | Not available, on roadmap | +| Extensibility | Moderate | Webhooks, API-first | +| Paid-only Features | Hanko Cloud adds hosting | +| Maturity | Medium | Focused on passkeys/passwordless | + +**Pros:** +- Modern passkey-first approach +- Web components that embed directly in your app (no redirect needed) +- Lightweight Go backend +- Good for passwordless-first applications + +**Cons:** +- **Not an OIDC/OAuth2 provider** - doesn't act as an IdP for other apps +- No multi-tenant support yet +- Smaller feature set compared to Keycloak/ZITADEL +- Organizations, roles, and permissions still in progress +- AGPL-3.0 license for backend + +**Verdict:** Good for passkey/passwordless-focused apps, but lacks the IdP capabilities (OIDC provider, multi-tenant) needed for a full identity provider solution. Not suitable if you need to protect multiple apps via standard protocols. + +--- + +### 7. SuperTokens + +**Website:** https://supertokens.com/ +**License:** Apache 2.0 (core) + +| Criterion | Rating | Notes | +|-----------|--------|-------| +| OIDC Social Login | Good | Google, GitHub, Facebook, Apple, custom providers | +| Email/Password | Yes | Built-in | +| User Management | Good | Dashboard with user management | +| Login UI | Yes | Pre-built UI components (embed in app, no redirect) | +| OSS Limits | **None for core** | Self-hosted core features are free and unlimited | +| Multi-Tenant | Yes (paid) | Multi-tenancy is a paid add-on ($100/month minimum) | +| Extensibility | Good | Override hooks, custom actions | +| Paid-only Features | Yes - MFA ($0.01/MAU), account linking ($0.005/MAU), multi-tenancy (paid) | +| Maturity | Medium | YC-backed, used in production | + +**Pros:** +- Core authentication is truly free and unlimited when self-hosted +- Pre-built UI that embeds in your app (no redirect needed) +- Session management with cookie-based approach (unique) +- Good SDK support (React, Node, Python, Go, etc.) +- Apache 2.0 license + +**Cons:** +- **Not an OIDC/OAuth2 provider** - it's an auth library, not an IdP +- MFA, account linking, and multi-tenancy are paid features +- Paid features have minimum billing of $100/month +- Self-hosted paid features still cost money +- Smaller scope than full IdP solutions + +**Verdict:** Good authentication library but **not an identity provider**. Cannot act as an OIDC provider for multiple apps. Paid add-ons for MFA and multi-tenancy violate the "no paid feature gates" requirement. + +--- + +### 8. Casdoor + +**Website:** https://casdoor.org/ +**License:** Apache 2.0 + +| Criterion | Rating | Notes | +|-----------|--------|-------| +| OIDC Social Login | Excellent | 100+ identity providers supported | +| Email/Password | Yes | Built-in with verification | +| User Management | Good | Web UI with user management, password reset | +| Login UI | Yes | Built-in login page (redirect), SDK integration | +| OSS Limits | **None** | Apache 2.0, no limits | +| Multi-Tenant | Yes | Via organizations | +| Extensibility | Moderate | Custom authentication plugins, webhooks | +| Paid-only Features | Enterprise support/hosting only | +| Maturity | Medium | Backed by Casbin project, used by Intel, VMware | + +**Pros:** +- Apache 2.0 license, truly free and open source +- Supports 100+ identity providers out of the box +- Full OIDC/OAuth2/SAML/CAS/LDAP/SCIM support +- Built-in SaaS management capabilities +- Multi-tenant with organizations +- WebAuthn/TOTP/MFA/RADIUS support + +**Cons:** +- Smaller community compared to Keycloak/ZITADEL +- Documentation quality is inconsistent (auto-translated from Chinese) +- Primarily developed by Chinese open-source community - international community is smaller +- UI/UX feels less polished than competitors +- Less mature enterprise deployment documentation + +**Verdict:** Feature-rich and truly open source with a permissive license. Good option if you need broad identity provider support. Documentation and community size may be concerns. + +--- + +### 9. FusionAuth + +**Website:** https://fusionauth.io/ +**License:** Proprietary (free "Community" tier) + +| Criterion | Rating | Notes | +|-----------|--------|-------| +| OIDC Social Login | Excellent | Unlimited identity providers | +| Email/Password | Yes | Built-in | +| User Management | Excellent | Full admin UI, self-service portal | +| Login UI | Yes | Themed login pages (redirect) | +| OSS Limits | **Significant** | Community edition is free but NOT open source. Premium features (MFA SMS/email, LDAP, SCIM, threat detection) require paid plans starting at $125/month | +| Multi-Tenant | Yes | Built-in | +| Extensibility | Good | Lambdas, webhooks, connectors | +| Paid-only Features | **Many** - breached password detection, advanced MFA, LDAP, SCIM, custom scopes, threat detection all require paid plans | +| Maturity | High | Well-established commercial product | + +**Pros:** +- Very feature-rich and well-documented +- Unlimited users and IdPs even on free tier +- Self-hostable +- Excellent SDK support + +**Cons:** +- **Not open source** - proprietary license +- Many important features are behind paid tiers ($125-$3,300/month) +- Advanced MFA, LDAP, SCIM, custom scopes, threat detection all paid +- Community edition feels like a "freemium" product, not true OSS +- AGPL-3.0 would be preferable to proprietary + +**Verdict:** **Disqualified** - not open source and has significant feature gating behind paid plans. Does not meet the core requirements. + +--- + +### 10. Better Auth + +**Website:** https://www.better-auth.com/ +**License:** MIT + +| Criterion | Rating | Notes | +|-----------|--------|-------| +| OIDC Social Login | Good | Google, GitHub, Discord, Twitter, etc. via plugins | +| Email/Password | Yes | Built-in | +| User Management | Basic | No admin dashboard | +| Login UI | **No** | You build your own UI | +| OSS Limits | **None** | MIT license | +| Multi-Tenant | Yes | Via organization plugin | +| Extensibility | Good | Plugin ecosystem | +| Paid-only Features | None (enterprise plan exists for support) | +| Maturity | Low | Very new project (2024-2025) | + +**Pros:** +- MIT license, truly open source +- TypeScript-first, very developer-friendly +- Plugin-based architecture (2FA, organizations, etc.) +- Framework-agnostic + +**Cons:** +- **Not an identity provider** - it's an authentication library/framework +- No admin UI or user management dashboard +- No login UI provided - you build everything +- Cannot act as an OIDC provider for other applications +- TypeScript only - not usable with Python/Go backends +- Very young project + +**Verdict:** **Not suitable** - it's an authentication library for TypeScript apps, not a standalone identity provider. Cannot be used to protect non-TypeScript applications or act as an IdP. + +--- + +### 11. Authelia + +**Website:** https://www.authelia.com/ +**License:** Apache 2.0 + +| Criterion | Rating | Notes | +|-----------|--------|-------| +| OIDC Social Login | **No** | Does not support upstream social login. It IS an OIDC provider but doesn't consume external IdPs for social login | +| Email/Password | Yes | Internal user database or LDAP | +| User Management | Basic | YAML-based or LDAP backend, no admin UI | +| Login UI | Yes | Built-in web portal | +| OSS Limits | **None** | Apache 2.0, no limits | +| Multi-Tenant | **No** | Single-tenant design | +| Extensibility | Limited | Configuration-driven, not much custom code extensibility | +| Paid-only Features | None | +| Maturity | High | Lightweight, well-suited for reverse proxy scenarios | + +**Pros:** +- Extremely lightweight (<20MB container, <30MB RAM) +- OpenID Certified +- Perfect companion for reverse proxies (Traefik, Nginx, HAProxy) +- Apache 2.0 license +- Built-in 2FA support (TOTP, WebAuthn, push) + +**Cons:** +- **No social login support** - cannot federate with Google, GitHub, etc. +- No admin UI for user management (YAML files or LDAP) +- Not designed as a full IdP - it's an auth portal for reverse proxy setups +- No multi-tenant support +- Limited extensibility + +**Verdict:** **Not suitable** for this use case. Authelia is an excellent reverse proxy auth companion but lacks social login federation and user management capabilities needed here. + +--- + +### 12. Dex + +**Website:** https://dexidp.io/ +**License:** Apache 2.0 +**Backed by:** CNCF (sandbox project, originated at CoreOS) + +| Criterion | Rating | Notes | +|-----------|--------|-------| +| OIDC Social Login | Good | GitHub, Google, Microsoft, GitLab, LinkedIn, Bitbucket, SAML, LDAP, and any generic OIDC/OAuth2 provider via connectors (~16 built-in connectors) | +| Email/Password | **Limited** | Built-in "local" connector with static passwords defined in config file (no dynamic registration, no password reset, no self-service) | +| User Management | **No** | No admin UI, no user management dashboard. Users are managed in upstream identity providers or via static config files | +| Login UI | Basic | Minimal login page (redirect-based) with Go HTML templates. Customizable but very basic compared to alternatives | +| OSS Limits | **None** | Apache 2.0, no feature gating whatsoever | +| Multi-Tenant | **No** | Single-tenant design. No concept of organizations, realms, or tenants | +| Extensibility | Good | Pluggable connector architecture, gRPC API for managing clients/passwords programmatically | +| Paid-only Features | **None** | Everything is open source | +| Maturity | High | CNCF sandbox, 10.6k GitHub stars, 297 contributors, used primarily in Kubernetes ecosystems | + +**Pros:** +- Apache 2.0 license, truly free and open source with zero restrictions +- Lightweight single Go binary, very low resource footprint +- Excellent Kubernetes-native support (CRD storage, Helm chart, API server auth integration) +- Strong connector ecosystem for federating upstream identity providers (LDAP, SAML, GitHub, Google, Microsoft, GitLab, etc.) +- OIDC Certified provider +- CNCF project with established community (10.6k stars, 3,680 commits) +- gRPC API for programmatic management +- Multiple storage backends: etcd, Kubernetes CRDs, SQLite3, Postgres, MySQL + +**Cons:** +- **Not a full identity provider** - it is a federation/proxy layer. Dex does not manage users itself; it delegates authentication to upstream providers +- **No built-in user management** - no admin UI, no user dashboard, no self-service password reset +- **Static local passwords only** - the built-in "local" connector uses passwords defined in YAML config files, not a dynamic user database +- **No multi-tenant support** - no organizations, realms, or tenant isolation +- **Minimal login UI** - basic Go HTML template pages, no modern UI, limited branding options +- **No MFA support** - relies entirely on upstream providers for MFA +- **No SCIM, RBAC, or user lifecycle management** +- Primarily designed for Kubernetes authentication, not general-purpose web app SSO +- SAML connector is unmaintained and potentially vulnerable (per project's own warning) + +**Verdict:** **Not suitable** for this use case. Dex is an excellent OIDC federation layer for Kubernetes environments, but it is not a standalone identity provider. It lacks user management, admin UI, dynamic user registration, password reset, and multi-tenancy. It is designed to proxy authentication to other IdPs, not to be one itself. If you already have an upstream IdP and need to unify access for Kubernetes, Dex excels - but it cannot "hand off" authentication from a web app as a complete solution. + +--- + +### 13. Rauthy + +**Website:** https://sebadob.github.io/rauthy/ +**License:** Apache 2.0 +**Backed by:** Independent developer (sebadob), funded by NLnet / NGI Zero Core (EU) + +| Criterion | Rating | Notes | +|-----------|--------|-------| +| OIDC Social Login | Good | Upstream authentication providers ("Login with ...") via generic OIDC. Individual connectors for specific providers like Google/GitHub are not pre-built but configurable via standard OIDC | +| Email/Password | Yes | Built-in with configurable password policies, Argon2ID hashing with config helper | +| User Management | Good | Dedicated Admin UI, user account self-service dashboard, password reset, custom roles/groups/attributes | +| Login UI | Yes | Built-in login page (redirect), per-client branding with custom themes and logos, i18n support | +| OSS Limits | **None** | Apache 2.0, no feature gating, no per-user limits | +| Multi-Tenant | **No** | No built-in multi-tenant/organizations concept. Single-tenant design | +| Extensibility | Moderate | Admin API keys with fine-grained access, events/webhooks, forward_auth endpoint, custom scopes/attributes, SCIM v2 | +| Paid-only Features | **None** | Everything is open source, paid support available | +| Maturity | Low-Medium | Independent security audit completed (Radically Open Security), 942 GitHub stars, 30 contributors, single primary developer, funded by EU (NLnet) | + +**Pros:** +- Apache 2.0 license, truly free and open source with zero restrictions +- Exceptionally lightweight - written in Rust, runs on a Raspberry Pi, <50-100MB RAM +- No external database dependency by default (embedded Hiqlite/SQLite with Raft HA), Postgres optional +- Strong security defaults (ed25519 token signing, S256 PKCE by default, Argon2ID) +- Independent security audit by Radically Open Security (findings addressed in v0.32.1) +- Excellent passkey/FIDO2 support including passwordless-only accounts +- Built-in HA mode without external dependencies (Raft consensus via Hiqlite) +- Admin UI and user self-service dashboard +- Per-client branding and i18n for login pages +- Events and alerting system (E-Mail, Matrix, Slack) +- Brute-force protection and IP blacklisting +- SCIM v2 support for downstream clients +- PAM/NSS module for Linux SSH/workstation logins (unique feature) +- OAuth Device Authorization Grant for IoT devices +- DPoP token support, JWKS auto-rotation +- Automatic database backups with S3 support +- Scales to millions of users +- Prometheus metrics endpoint +- OpenID Connect Dynamic Client Registration, RP Initiated Logout, Backchannel Logout + +**Cons:** +- **No multi-tenant support** - no organizations, realms, or B2B tenant isolation +- **Small community** - 942 GitHub stars, 30 contributors, primarily a single-developer project +- **Limited upstream social providers** - no pre-built connectors for Apple, Facebook, etc. Relies on generic OIDC upstream; providers that don't support standard OIDC (like Apple's non-standard implementation) may require workarounds +- **No SAML support** - neither as consumer nor provider +- **No LDAP integration** - cannot federate with existing LDAP/AD directories +- Young project (v0.34.3, not yet 1.0) - API and configuration may still change +- Single primary developer creates bus factor risk +- Smaller ecosystem and community support compared to Keycloak/ZITADEL +- Documentation is good but less comprehensive than mature alternatives +- No commercial entity backing the project (EU grant-funded) + +**Verdict:** A compelling lightweight alternative with excellent security defaults and impressive resource efficiency. Rauthy is a genuine full-featured OIDC provider with admin UI, user management, and modern passkey support. However, the lack of multi-tenancy, SAML/LDAP support, and the small community/single-developer risk make it less suitable for enterprise or B2B use cases. Best suited for projects that prioritize minimal resource footprint, strong security defaults, and don't need multi-tenancy or SAML/LDAP federation. The Apache 2.0 license and security audit are strong positives. + +--- + +### 14. FerrisKey + +**Website:** https://ferriskey.rs / https://docs.ferriskey.rs +**License:** Apache 2.0 +**Backed by:** Community / Sponsored by Cloud IAM + +| Criterion | Rating | Notes | +|-----------|--------|-------| +| OIDC Social Login | **Early** | Social auth API endpoints added in v0.3.0; LDAP federation also in progress. Documentation lists Google, GitHub, Facebook but implementation appears to be in early stages | +| Email/Password | Yes | Built-in username/password and email/password authentication | +| User Management | Good | Admin dashboard (React), user CRUD, profile management, account status (active/inactive/locked) | +| Login UI | Yes | Built-in login page (redirect-based), separate web console for admin | +| OSS Limits | **None** | Apache 2.0, no feature gating, community-first | +| Multi-Tenant | Yes (Realms) | Keycloak-style "Realms" with complete tenant isolation, independent config, separate user bases | +| Extensibility | Moderate | Webhooks for lifecycle events, REST APIs, modular architecture (Trident for MFA, SeaWatch for audit) | +| Paid-only Features | **None** | Everything is open source | +| Maturity | **Very Low** | v0.3.0, 524 GitHub stars, 33 contributors, 411 commits, 11 releases. Project is in early alpha/development stage | + +**Pros:** +- Apache 2.0 license, truly free and open source with zero restrictions +- Written in Rust (API/core) with React/TypeScript frontend - aims for high performance and low resource usage +- Multi-tenant Realms (Keycloak-inspired) with complete user/role/client isolation per realm +- Modern hexagonal architecture (Ports & Adapters) for maintainability +- Built-in admin dashboard with React, Tailwind, and shadcn/ui +- MFA support via TOTP (Trident module) +- RBAC with fine-grained role mapping +- Audit logging and event system (SeaWatch module) +- Webhook support for lifecycle events +- Kubernetes-native with Helm chart and Kubernetes operator (CRDs) +- Prometheus metrics endpoint +- LDAP federation being actively developed (v0.3.0) +- Social auth endpoints being actively developed (v0.3.0) +- Docker Compose setup for quick local testing +- Well-structured codebase with clean architecture patterns + +**Cons:** +- **Extremely early stage** - v0.3.0 with only 411 commits and 11 releases. Not production-ready +- **Many claimed features are aspirational** - the documentation describes planned capabilities (social login with Google/GitHub/Facebook, SAML, LDAP) that are only partially implemented or in active development as of v0.3.0 +- **No security audit** - unlike Rauthy, no independent security assessment has been conducted +- **Requires PostgreSQL** - no embedded database option; external Postgres 15+ is mandatory +- **Very small community** - 524 GitHub stars, primarily driven by a small group of contributors +- **No OIDC certification** - not certified by the OpenID Foundation +- **No SAML support** yet (neither as consumer nor provider) +- **No passkey/WebAuthn support** yet (TOTP only for MFA) +- **No SCIM support** for user provisioning +- **No self-service password reset** documented +- **Limited social login** - SSO endpoints and social auth API were just added in v0.3.0; unclear how many providers actually work end-to-end +- **Separate API and frontend containers** - requires deploying multiple services (API, webapp, Postgres, migrations) +- Feature parity with Keycloak (its stated goal) is far from achieved +- Documentation describes features in a "future tense" manner, making it difficult to distinguish implemented vs. planned functionality + +**Verdict:** **Not suitable at this time.** FerrisKey is an ambitious project with a sound architectural vision (Keycloak-like realms, Rust performance, hexagonal architecture) and a permissive Apache 2.0 license. However, at v0.3.0 it is far too early for production use. Many documented features (social login, LDAP, SAML) are either partially implemented or still in development. The project lacks a security audit, OIDC certification, and the battle-testing that comes with maturity. Worth watching for the future, but cannot be recommended for a project that needs to "hand off" authentication today. Re-evaluate when the project reaches v1.0. + +--- + +## Comparison Matrix + +| Feature | Keycloak | ZITADEL | Logto | authentik | Ory | Hanko | SuperTokens | Casdoor | FusionAuth | Better Auth | Authelia | Dex | Rauthy | FerrisKey | +|---------|----------|---------|-------|-----------|-----|-------|-------------|---------|------------|-------------|----------|-----|--------|-----------| +| **Truly OSS** | Yes | Yes (AGPL) | Yes (MPL) | Partial | Yes | Yes (AGPL) | Yes | Yes | **No** | Yes | Yes | Yes | Yes | Yes | +| **No Feature Limits** | Yes | Yes | Mostly | **No** | Yes | Yes | **No** | Yes | **No** | Yes | Yes | Yes | Yes | Yes | +| **OIDC Provider** | Yes | Yes | Yes | Yes | Yes | No | No | Yes | Yes | No | Yes | Yes | Yes | Yes | +| **Social Login** | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | **No** | Yes (via connectors) | Partial (generic OIDC only) | Early (in dev) | +| **Email/Password** | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Static only | Yes | Yes | +| **Login UI** | Yes | Yes | Yes | Yes | **No** | Yes | Yes | Yes | Yes | **No** | Yes | Basic | Yes | Yes | +| **User Management UI** | Yes | Yes | Yes | Yes | **No** | Basic | Yes | Yes | Yes | **No** | **No** | **No** | Yes | Yes | +| **Multi-Tenant** | Yes | Excellent | Yes | Partial | Cloud | No | Paid | Yes | Yes | Plugin | No | No | No | Yes (Realms) | +| **Extensible** | Excellent | Good | Moderate | Good | Excellent | Moderate | Good | Moderate | Good | Good | Limited | Good (connectors) | Moderate | Moderate | +| **Self-Host Easy** | Medium | Easy | Easy | Easy | Hard | Easy | Easy | Easy | Easy | N/A | Easy | Easy | Very Easy | Medium | + +--- + +## Disqualified Candidates + +| Provider | Reason | +|----------|--------| +| **FusionAuth** | Not open source; many features behind paid plans ($125-$3,300/mo) | +| **Better Auth** | Auth library, not an IdP; no login UI; TypeScript-only | +| **Authelia** | No social login support; no admin UI; reverse proxy companion only | +| **Hanko** | Not an OIDC provider; can't protect multiple apps via standard protocols | +| **SuperTokens** | Not an IdP; MFA and multi-tenancy are paid add-ons | +| **Dex** | Federation layer, not a standalone IdP; no user management, no admin UI, static passwords only | +| **FerrisKey** | Too early (v0.3.0); many features aspirational/in-development; no security audit; no OIDC certification | + +--- + +## Top 3 Recommendations + +### 1. Keycloak (Best Overall) + +**Why:** The most mature, feature-complete, and truly unrestricted open-source identity provider. Apache 2.0 license means zero concerns for embedding in an open-source project. All features are in the OSS version - there is no commercial edition with gated features. The ecosystem is massive with extensive documentation, community support, and third-party integrations. + +**Best for:** Projects that need maximum features, proven stability, and the broadest protocol support. Willing to invest in initial setup and ops. + +**Trade-off:** Higher resource usage (Java), steeper learning curve, more operational complexity. + +### 2. ZITADEL (Best Modern Alternative) + +**Why:** Purpose-built for multi-tenancy with a modern architecture. Single Go binary makes deployment simple. All features available in the open-source version. OIDC Certified with excellent B2B support. Event-sourced architecture provides great audit trails. + +**Best for:** Projects that prioritize multi-tenancy, modern architecture, and ease of deployment. The AGPL-3.0 license is fine for using ZITADEL as a service (not embedded). + +**Trade-off:** AGPL-3.0 license (fine for service usage), younger ecosystem than Keycloak. + +### 3. Casdoor (Best Lightweight Alternative) + +**Why:** Apache 2.0 license, 100+ identity providers, full OIDC/SAML/LDAP support, and multi-tenancy - all free and unrestricted. Lightweight Go-based architecture. The broadest built-in social login connector support of any option. + +**Best for:** Projects that need many social login providers out of the box and want a permissive license with no restrictions. + +**Trade-off:** Smaller international community, documentation quality varies, less mature than Keycloak. + +--- + +## Final Recommendation + +For an **open-source web app** that needs to completely hand off authentication: + +> **ZITADEL** is the recommended choice. It strikes the best balance of: +> - All features available for free (no gating) +> - Excellent multi-tenant support +> - Modern, lightweight architecture (single Go binary) +> - Built-in login UI with hosted login pages +> - OIDC Certified with broad protocol support +> - Easy self-hosting with Docker/Kubernetes +> - Active development and growing community +> +> If multi-tenancy is not critical and you want maximum maturity and ecosystem, go with **Keycloak** instead. +> +> If you need the most permissive license (Apache 2.0) and broad social login support, consider **Casdoor**. diff --git a/auth/setup-zitadel-dev.sh b/auth/setup-zitadel-dev.sh new file mode 100755 index 0000000..2fe04b5 --- /dev/null +++ b/auth/setup-zitadel-dev.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# Setup script for Zitadel development environment +# Creates an OIDC application for modAI-chat and outputs the Client ID +# +# Prerequisites: +# - Zitadel is running (docker compose -f docker-compose-zitadel-develop.yaml up --detach --wait) +# - admin.pat exists in auth/ directory (auto-created by Zitadel init) +# +# Usage: +# ./auth/setup-zitadel-dev.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ZITADEL_URL="http://localhost:8080" +PAT_FILE="$SCRIPT_DIR/admin.pat" +OUTPUT_FILE="$SCRIPT_DIR/dev-app-config.json" + +PROJECT_NAME="modAI-chat" +APP_NAME="modAI-chat-frontend" + +# Frontend dev server ports +REDIRECT_URIS='["http://localhost:5173/auth/callback","http://localhost:4173/auth/callback"]' +POST_LOGOUT_URIS='["http://localhost:5173/","http://localhost:4173/"]' + +echo "=== Zitadel Dev Setup ===" + +# Wait for PAT file +if [ ! -f "$PAT_FILE" ]; then + echo "Waiting for admin PAT to be generated..." + for i in $(seq 1 30); do + if [ -f "$PAT_FILE" ]; then + break + fi + sleep 2 + done +fi + +if [ ! -f "$PAT_FILE" ]; then + echo "ERROR: admin.pat not found at $PAT_FILE" + echo "Make sure Zitadel is running and healthy." + exit 1 +fi + +PAT=$(cat "$PAT_FILE" | tr -d '[:space:]') +echo "Using admin PAT from $PAT_FILE" + +# Helper function for API calls +zitadel_api() { + local method="$1" + local endpoint="$2" + local data="${3:-}" + + local args=( + --silent + --show-error + --fail + -X "$method" + -H "Authorization: Bearer $PAT" + -H "Content-Type: application/json" + ) + + if [ -n "$data" ]; then + args+=(-d "$data") + fi + + curl "${args[@]}" "${ZITADEL_URL}${endpoint}" 2>&1 +} + +# 1. Check if project already exists +echo "Checking for existing project..." +EXISTING_PROJECTS=$(zitadel_api POST "/management/v1/projects/_search" '{"queries":[{"nameQuery":{"name":"'"$PROJECT_NAME"'","method":"TEXT_QUERY_METHOD_EQUALS"}}]}') +EXISTING_PROJECT_COUNT=$(echo "$EXISTING_PROJECTS" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('totalResult','0'))" 2>/dev/null || echo "0") + +if [ "$EXISTING_PROJECT_COUNT" != "0" ]; then + PROJECT_ID=$(echo "$EXISTING_PROJECTS" | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])") + echo "Project '$PROJECT_NAME' already exists (ID: $PROJECT_ID)" +else + # Create project + echo "Creating project '$PROJECT_NAME'..." + PROJECT_RESPONSE=$(zitadel_api POST "/management/v1/projects" '{"name":"'"$PROJECT_NAME"'","projectRoleAssertion":true}') + PROJECT_ID=$(echo "$PROJECT_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") + echo "Created project (ID: $PROJECT_ID)" +fi + +# 2. Check if app already exists +echo "Checking for existing app..." +EXISTING_APPS=$(zitadel_api POST "/management/v1/projects/$PROJECT_ID/apps/_search" '{"queries":[{"nameQuery":{"name":"'"$APP_NAME"'","method":"TEXT_QUERY_METHOD_EQUALS"}}]}') +EXISTING_APP_COUNT=$(echo "$EXISTING_APPS" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('totalResult','0'))" 2>/dev/null || echo "0") + +if [ "$EXISTING_APP_COUNT" != "0" ]; then + APP_ID=$(echo "$EXISTING_APPS" | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])") + CLIENT_ID=$(echo "$EXISTING_APPS" | python3 -c "import sys,json; r=json.load(sys.stdin)['result'][0]; print(r.get('oidcConfig',{}).get('clientId',''))") + echo "App '$APP_NAME' already exists (ID: $APP_ID, Client ID: $CLIENT_ID)" +else + # Create OIDC User Agent (SPA) application with PKCE + echo "Creating OIDC SPA application '$APP_NAME'..." + APP_RESPONSE=$(zitadel_api POST "/management/v1/projects/$PROJECT_ID/apps/oidc" '{ + "name": "'"$APP_NAME"'", + "redirectUris": '"$REDIRECT_URIS"', + "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"], + "grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE"], + "appType": "OIDC_APP_TYPE_USER_AGENT", + "authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE", + "postLogoutRedirectUris": '"$POST_LOGOUT_URIS"', + "devMode": true, + "accessTokenType": "OIDC_TOKEN_TYPE_JWT", + "idTokenRoleAssertion": true, + "idTokenUserinfoAssertion": true + }') + + APP_ID=$(echo "$APP_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['appId'])") + CLIENT_ID=$(echo "$APP_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['clientId'])") + echo "Created app (ID: $APP_ID, Client ID: $CLIENT_ID)" +fi + +# 3. Create API application for backend introspection (if not exists) +API_APP_NAME="modAI-chat-backend" +echo "Checking for existing API app..." +EXISTING_API_APPS=$(zitadel_api POST "/management/v1/projects/$PROJECT_ID/apps/_search" '{"queries":[{"nameQuery":{"name":"'"$API_APP_NAME"'","method":"TEXT_QUERY_METHOD_EQUALS"}}]}') +EXISTING_API_COUNT=$(echo "$EXISTING_API_APPS" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('totalResult','0'))" 2>/dev/null || echo "0") + +if [ "$EXISTING_API_COUNT" != "0" ]; then + API_APP_ID=$(echo "$EXISTING_API_APPS" | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])") + API_CLIENT_ID=$(echo "$EXISTING_API_APPS" | python3 -c "import sys,json; r=json.load(sys.stdin)['result'][0]; print(r.get('apiConfig',{}).get('clientId', r.get('oidcConfig',{}).get('clientId','')))") + echo "API app '$API_APP_NAME' already exists (ID: $API_APP_ID)" + echo "NOTE: Cannot retrieve client secret for existing app. If you need it, delete and recreate." + API_CLIENT_SECRET="" +else + echo "Creating API application '$API_APP_NAME' for backend introspection..." + API_APP_RESPONSE=$(zitadel_api POST "/management/v1/projects/$PROJECT_ID/apps/api" '{ + "name": "'"$API_APP_NAME"'", + "authMethodType": "API_AUTH_METHOD_TYPE_BASIC" + }') + + API_APP_ID=$(echo "$API_APP_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['appId'])") + API_CLIENT_ID=$(echo "$API_APP_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['clientId'])") + API_CLIENT_SECRET=$(echo "$API_APP_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['clientSecret'])") + echo "Created API app (ID: $API_APP_ID, Client ID: $API_CLIENT_ID)" +fi + +# 4. Write config output +cat > "$OUTPUT_FILE" <|1 login| B[Authentication Module] - B --> D[User Store Module] - B -->|create or delete| C[Session Module] + A[Browser/SPA] -->|1. redirect to IDP| IDP[Zitadel IDP] + IDP -->|2. OIDC callback with code| A + A -->|3. PKCE code exchange| IDP + IDP -->|4. access_token + id_token| A + A -->|5. Bearer token| B[Backend API] + B -->|6. validate JWT via JWKS| IDP + B -->|7. extract user from token| C[Session Module] H[Some Web Module] -->|check has session| C - A[HTTP Client] -->|2 access| H + A -->|API request with Bearer token| H H -->|check has permission| E[Authorization Module] E --> C H --> F[Protected Resource] ``` -## 3. Module Architecture +## 3. Authentication Flow -### 3.1 Authentication Module -**Purpose**: Handles user login/logout and credential validation. +### 3.1 OIDC PKCE Flow (Frontend) +The frontend uses `oidc-client-ts` to handle the OIDC Authorization Code flow with PKCE: + +1. User navigates to app → no session found → redirect to IDP login page +2. User authenticates at Zitadel's hosted login UI +3. Zitadel redirects back to `/auth/callback` with an authorization code +4. Frontend exchanges code for tokens (access_token, id_token) via PKCE +5. Tokens stored in localStorage by `oidc-client-ts` +6. Access token sent as `Authorization: Bearer ` on all API requests + +### 3.2 JWT Validation (Backend) +The backend validates Bearer tokens using the IDP's JWKS (JSON Web Key Set): + +1. Extract Bearer token from `Authorization` header +2. Fetch signing keys from `{issuer}/oauth/v2/keys` (JWKS endpoint) +3. Verify token signature, expiration, and issuer +4. Extract user claims (`sub`, `email`, `name`) from the validated JWT +5. Return a `Session` object with the user's identity + +### 3.3 JIT User Provisioning +When a user authenticates for the first time, they are automatically provisioned in the local user store: + +1. Session is validated from the Bearer token +2. User looked up in local store by OIDC `sub` claim +3. If not found, looked up by email +4. If still not found, created from OIDC claims (email, name) + +## 4. Module Architecture + +### 4.1 Authentication Module (`OIDCAuthenticationModule`) +**Purpose**: Provides OIDC-related endpoints for the frontend SPA. **Key Responsibilities**: -- User credential validation -- Login/logout endpoint management -- Integration with session management +- Server-side logout cleanup +- User info endpoint with JIT provisioning **API Endpoints**: -- `POST /api/auth/login` - User authentication (200 OK / 401 Unauthorized / 422 Unprocessable Entity) -- `POST /api/auth/logout` - Session termination (200 OK / 401 Unauthorized) +- `POST /api/auth/logout` - Server-side logout cleanup (200 OK) +- `GET /api/auth/userinfo` - Returns user info from validated session, performs JIT provisioning (200 OK / 401 Unauthorized / 404 Not Found) **Dependencies**: -- Session Module (for session creation/destruction) -- User Store Module (for credential verification) +- Session Module (for session validation) +- User Store Module (for JIT user provisioning) **Data Models**: -Login: +Userinfo: ```json -// Login Request +// Userinfo Response (200 OK) { + "id": "user-id-from-oidc-sub", "email": "user@example.com", - "password": "password123" -} - -// Login Response (200 OK) -{ - "message": "Successfully logged in" -} - -// Login Error Response (401 Unauthorized) -{ - "message": "Invalid email or password" -} - -// Login Error Response (422 Unprocessable Entity) -{ - "message": "Missing email or password" + "full_name": "John Doe" } ``` @@ -77,84 +96,57 @@ Logout: { "message": "Successfully logged out" } - -// Logout Error Response (401 Unauthorized) -{ - "message": "Invalid token" -} ``` -### 3.2 Session Module -**Purpose**: Manages user sessions and session validation. +### 4.2 Session Module (`OIDCSessionModule`) +**Purpose**: Validates Bearer access tokens from OIDC providers using JWKS verification. The session module has no web endpoints but is used by other modules. +**Configuration**: +```yaml +session: + class: modai.modules.session.oidc_session_module.OIDCSessionModule + config: + issuer: ${OIDC_ISSUER} # e.g., http://localhost:8080 + user_id_claim: "sub" # JWT claim to use as user ID + # Optional: + # jwks_uri: + # audience: + # algorithms: ["RS256"] +``` + **Key Functions**: ```python -async def start_new_session( - self, - request: Request, - response: Response, - user_id: str, - **kwargs, -): - """ - Creates a session for the given user and applies it to the response. - - Args: - user_id: Unique identifier for the user - **kwargs: Additional data to include in the session - """ - -async def validate_session( - self, - request: Request, -) -> Session: +def validate_session(self, request: Request) -> Session: """ - Validates and decodes a session. + Validates a Bearer access token from the Authorization header. + Extracts the token, verifies its signature against the OIDC provider's + JWKS, and returns a Session with user information. Returns: - The active valid session + Session with user_id (from sub claim) and additional claims (email, name) Raises: - If session is invalid or expired + ValueError if token is missing, expired, or invalid """ -async def end_session( - self, - request: Request, - response: Response, -): - """ - Ends the session by invalidating the session. - """ +def start_new_session(...): + """No-op. Sessions are managed by the identity provider.""" + +def end_session(...): + """No-op. Logout is handled by frontend redirect to IDP's end_session_endpoint.""" ``` **Key Responsibilities**: -- Session creation and destruction -- Session validation - -**Implementation Variations**: - -There is no one-and-only session management type. Different implementations -of this module can use different session management techniques. Here some -points the implementations can work with: +- JWT signature verification via JWKS +- Token expiration and issuer validation +- User identity extraction from token claims -- JWT-based -- HTTP Header based -- OIDC -- Cookie management -Each implementation should support at least the `validate_session` function. -Some implementation might not support `start_new_session` and `end_session` -(e.g. if the session is not managed by the module but externally) - -Because of the variety of options, the session module interface is rather generic. - - -### 3.3 Authorization Module +### 4.3 Authorization Module **Purpose**: Determines user permissions for accessing specific protected resources, manages permission registry, and provides permission discovery API. **Key Responsibilities**: @@ -171,14 +163,11 @@ Because of the variety of options, the session module interface is rather generi **Key Functions**: ```python async def register_permission(self, permission_def: PermissionDefinition) -> None: - """ - Register a permission definition for discovery purposes. - """ + """Register a permission definition for discovery purposes.""" async def validate_permission(self, user_id: str, resource: str, action: str) -> None: """ Validates if user has permission for specific resource and action. - Raises: HTTPException: If access is not permitted """ @@ -187,73 +176,9 @@ async def validate_permission(self, user_id: str, resource: str, action: str) -> **Dependencies**: - Session Module (for validating requests to permission endpoints) -**Permission Model**: -```python -@dataclass -class PermissionDefinition: - """Definition of a permission for registration purposes""" - resource: str # e.g., "/api/documents", "/api/user/*" - actions: list[str] # e.g., ["read", "write", "delete"] - resource_name: str # Human-readable name, e.g., "Document Library", "User Management" - description: str | None = None # Optional detailed description -``` -**Data Models**: -```json -// List Permissions Response (200 OK) -{ - "permissions": [ - { - "resource": "/api/provider/*/models", - "actions": ["read"], - "resource_name": "Large Language Models", - "description": "Models available through the AI provider" - }, - { - "resource": "/api/file/*", - "actions": ["read", "write", "delete"], - "resource_name": "User Files", - "description": "Access individual uploated files outside a document library" - } - ] -} - -// List Permissions Error Response (401 Unauthorized) - if authentication required -{ - "message": "Not authenticated" -} -``` - -**Pseudo-Endpoint-based Resource Identifier**: - -The `PermissionDefinition` has a field `resource` which should be unique across -the application to not conflict with permissions of other modules. - -The resource identifier is a string, so arbitrary content can be put in, but it is -advisable to use a **pseudo endpoint notation** which reflects the endpoints -of the module exactly or at least to a certain extent. - -If a module e.g. has an endpoint `/api/files` then this is also a good candidate -for the resource identifier. - -If a module has several endpoints like `/api/file/{id}/title`, -`/api/file/{id}/name`, ... then it is not advisable to create permissions for -each single endpoint, but instead use a more generic one like `/api/file/*` as -resource name. - -In some cases it can even be interesting to share resource identifiers across modules. -E.g. if there are several LLM Provider modules which should follow the endpoint pattern -`/api/provider`, then usually we don't want to have permissions for each provider -individually. Here a resource identifier of `/api/provider/` could be shared amongst -all provider modules. - -Benefits of Pseudo-Endpoint-based Permissions: -- Clear relationship between API endpoints and permissions -- Easier for clients to understand permission structure - - -### 3.4 User Store Module -**Purpose**: Manages user and group data, credentials storage. +### 4.4 User Store Module +**Purpose**: Manages user and group data. Supports JIT provisioning from OIDC claims. **Module Type**: Plain, (Persistence)* @@ -262,143 +187,115 @@ order to perform data migration of persisted data **Key Responsibilities**: - User CRUD operations -- Credential storage and retrieval +- JIT user creation from OIDC claims (id, email, name) -## 4. Integration Patterns +## 5. Integration Patterns -### 4.1 Permission Registration Pattern -Web modules should register their permissions during initialization to enable permission discovery: - -```python -class SomeWebModule(ModaiModule, ABC): - def __init__(self, dependencies: ModuleDependencies, config: dict[str, Any]): - super().__init__(dependencies, config) - - # Get required dependencies - ... - self.authorization_module: AuthorizationModule = - dependencies.modules.get("authorization") - - # Register permissions used by this module - self._register_permissions() - - # Add routes - ... - - def _register_permissions(self): - """Register all permissions used by this module""" - self.authorization_module.register_permission( - PermissionDefinition( - resource="/api/some", - actions=["read", "write", "delete"], - resource_name="Some Resources" - ) - ) - ... -``` - -### 4.2 Web Module Auth Pattern -Most web modules will follow this pattern for protected endpoints: +### 5.1 Web Module Auth Pattern +Most web modules follow this pattern for protected endpoints: ```python class SomeWebModule(ModaiModule): def __init__(self, dependencies: ModuleDependencies, config: dict[str, Any]): super().__init__(dependencies, config) - - # Get required dependencies self.session_module: SessionModule = dependencies.modules.get("session") - self.authorization_module: AuthorizationModule = - dependencies.modules.get("authorization") - - # Register permissions used by this module - ... - - # Add routes self.router.add_api_route("/api/some", self.get_some, methods=["GET"]) - ... async def get_some(self, request: Request): - # 1. Validate session (raises a 401 if session invalid) - session = await self.session_module.validate_session_for_http(request) + # 1. Validate session (raises 401 if Bearer token is missing/invalid) + session = self.session_module.validate_session_for_http(request) - # 2. Validate endpoint permissions - await self.authorization_module.validate_permission( - session.user_id, "/api/some", "read" - ) - - # 3. Process request - return {"data": "protected content"} - ... + # 2. Process request (session.user_id contains the OIDC sub claim) + return {"data": "protected content", "user": session.user_id} ``` -### 4.3 Session-based Authentication Flow +### 5.2 OIDC Authentication Flow ```mermaid sequenceDiagram - participant Client - - box Backend - participant Auth - participant Session - participant UserStore - participant SomeWebModule - participant Authorization - end - - Client->>Auth: POST /auth/login - Auth->>UserStore: get_user_by_email() - UserStore-->>Auth: User data - Auth->>UserStore: get_user_credentials() - UserStore-->>Auth: Credentials - Auth->>Auth: validate_password() - Auth->>Session: start_new_session() - Session-->>Auth: Session created - Auth-->>Client: 200 OK (session cookie) - Client->>SomeWebModule: GET /api/file/1 - SomeWebModule->>Session: validate_session() - Session-->>SomeWebModule: Session data - SomeWebModule->>Authorization: validate_permission() - Authorization-->>SomeWebModule: Permission result - SomeWebModule-->>Client: 200 OK (Resource data) + participant Browser + participant Zitadel + participant Frontend + participant Backend + + Browser->>Frontend: Navigate to app + Frontend->>Frontend: No OIDC user in storage + Frontend->>Zitadel: Redirect to /authorize (PKCE) + Zitadel->>Browser: Show login page + Browser->>Zitadel: Enter credentials + Zitadel->>Frontend: Redirect to /auth/callback?code=... + Frontend->>Zitadel: Exchange code for tokens (PKCE) + Zitadel->>Frontend: access_token + id_token + Frontend->>Frontend: Store tokens, extract user from id_token + Frontend->>Backend: GET /api/user (Bearer token) + Backend->>Zitadel: Fetch JWKS (cached) + Backend->>Backend: Validate JWT signature + claims + Backend->>Backend: JIT provision user if new + Backend->>Frontend: User data ``` -In order to keep the diagram slim and better readable, the error scenarios -are not fully contained. Auth errors can happen after each of the `validate_*` functions. -If the validation fails, a `401` or `403` HTTP error is returned to the client. - -## 5. Security Considerations +## 6. Security Considerations -### 5.1 Authentication Security -- Password hashing using secure algorithms (SHA-256 in demo, bcrypt recommended for production) -- JWT tokens with proper expiration and secret management -- HttpOnly cookies to prevent XSS attacks -- Secure cookie flags for HTTPS environments +### 6.1 Authentication Security +- PKCE (Proof Key for Code Exchange) prevents authorization code interception +- JWT tokens validated via JWKS (asymmetric RSA signatures) +- Tokens have proper expiration (managed by IDP) +- `oidc-client-ts` handles automatic silent token renewal +- No credentials stored in the application — all authentication delegated to IDP -### 5.2 Session Security -- JWT token validation with proper signature verification -- Session expiration and renewal mechanisms -- Secure cookie attributes (HttpOnly, Secure, SameSite) -- Session invalidation on logout +### 6.2 Session Security +- Bearer tokens validated on every request +- JWKS keys cached but refreshable (key rotation support) +- Token expiration enforced server-side +- No server-side session state (stateless JWT validation) -### 5.3 Authorization Security +### 6.3 Authorization Security - Principle of least privilege - Permission validation on every protected resource access - Proper error handling to prevent information leakage -- Permission caching with appropriate TTL to balance security and performance + +## 7. Development Setup + +### 7.1 Zitadel (Identity Provider) +```bash +# Start Zitadel + Postgres + Login UI +docker compose -f docker-compose-zitadel-develop.yaml up --detach --wait + +# Provision OIDC application and get Client ID +./auth/setup-zitadel-dev.sh + +# The script outputs VITE_OIDC_CLIENT_ID and OIDC_ISSUER values +``` + +### 7.2 Environment Variables +Backend (`backend/.env`): +``` +OIDC_ISSUER=http://localhost:8080 +``` + +Frontend (`frontend_omni/.env`): +``` +VITE_OIDC_AUTHORITY=http://localhost:8080 +VITE_OIDC_CLIENT_ID= +``` + +### 7.3 Zitadel Console +- URL: http://localhost:8080/ui/console +- Admin login: `zitadel-admin@zitadel.localhost` / `Password1!` +- Create/manage users via the console or the Zitadel login page -## 6. Future Enhancements +## 8. Future Enhancements -### 6.1 Additional Authentication Methods -- OAuth 2.0 / OpenID Connect integration -- Multi-factor authentication (MFA) -- API key authentication -- Certificate-based authentication +### 8.1 Additional Authentication Methods +- Multi-factor authentication (MFA) via Zitadel +- Social login providers (Google, GitHub) via Zitadel federation +- API key authentication for machine-to-machine communication -### 6.2 Advanced Authorization Features +### 8.2 Advanced Authorization Features - Resource-level permissions with inheritance +- Role-based access control (RBAC) using Zitadel roles -### 6.3 Audit and Monitoring -- Authentication attempt logging +### 8.3 Audit and Monitoring +- Authentication attempt logging (available via Zitadel) - Permission check auditing - Failed access attempt monitoring -- Security event alerting diff --git a/backend/omni/docs/architecture/core.md b/backend/omni/docs/architecture/core.md index 2771f54..f7e65d7 100644 --- a/backend/omni/docs/architecture/core.md +++ b/backend/omni/docs/architecture/core.md @@ -168,12 +168,11 @@ modules: health: class: modai.modules.health.simple_health_module.SimpleHealthModule enabled: false - jwt_session: + session: class: modai.modules.session.jwt_session_module.JwtSessionModule config: - jwt_secret: ${JWT_SECRET} - jwt_algorithm: "HS256" - jwt_expiration_hours: 24 + issuer: ${OIDC_ISSUER} + user_id_claim: "sub" authentication: class: modai.modules.authentication.password_authentication_module.PasswordAuthenticationModule module_dependencies: diff --git a/backend/omni/pyproject.toml b/backend/omni/pyproject.toml index 69edd18..4cab2f7 100644 --- a/backend/omni/pyproject.toml +++ b/backend/omni/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ [dependency-groups] dev = [ + "cryptography>=46.0.5", "datamodel-code-generator[ruff]", "pytest", "pytest-asyncio", diff --git a/backend/omni/src/modai/default_config.yaml b/backend/omni/src/modai/default_config.yaml index 70ab966..de91229 100644 --- a/backend/omni/src/modai/default_config.yaml +++ b/backend/omni/src/modai/default_config.yaml @@ -37,14 +37,12 @@ modules: database_url: "sqlite:///./user_settings.db" echo: false session: - class: modai.modules.session.jwt_session_module.JwtSessionModule + class: modai.modules.session.oidc_session_module.OIDCSessionModule config: - jwt_secret: ${JWT_SECRET} - jwt_algorithm: "HS256" - jwt_expiration_hours: 24 - cookie_secure: true + issuer: ${OIDC_ISSUER} + user_id_claim: "sub" authentication: - class: modai.modules.authentication.password_authentication_module.PasswordAuthenticationModule + class: modai.modules.authentication.oidc_authentication_module.OIDCAuthenticationModule module_dependencies: session: "session" user_store: "user_store" diff --git a/backend/omni/src/modai/modules/authentication/__tests__/test_authentication.py b/backend/omni/src/modai/modules/authentication/__tests__/test_authentication.py deleted file mode 100644 index a85a59f..0000000 --- a/backend/omni/src/modai/modules/authentication/__tests__/test_authentication.py +++ /dev/null @@ -1,250 +0,0 @@ -import pytest -from unittest.mock import Mock, MagicMock, AsyncMock -from fastapi.testclient import TestClient -from fastapi import FastAPI -from modai.module import ModuleDependencies -from modai.modules.authentication.password_authentication_module import ( - PasswordAuthenticationModule, -) -from modai.modules.session.module import SessionModule -from modai.modules.user_store.module import UserStore, User, UserCredentials - - -@pytest.fixture -def client(): - app = FastAPI() - - # Create a mock session module - session_module = Mock(spec=SessionModule) - session_module.start_new_session = MagicMock() - session_module.end_session = MagicMock() - session_module.validate_session = MagicMock() - - # Create a mock user store module - user_store = Mock(spec=UserStore) - user_store.get_user_by_email = AsyncMock() - user_store.get_user_credentials = AsyncMock() - user_store.create_user = AsyncMock() - user_store.set_user_password = AsyncMock() - user_store.delete_user = AsyncMock() - - # Create authentication module - auth_module = PasswordAuthenticationModule( - dependencies=ModuleDependencies( - {"session": session_module, "user_store": user_store} - ), - config={}, - ) - app.include_router(auth_module.router) - return TestClient(app), auth_module, session_module, user_store - - -def test_login_success(client): - test_client, auth_module, session_module, user_store = client - - # Create test user and credentials - test_user = User(id="1", email="admin@example.com", full_name="Administrator") - # Hash for password "admin" - password_hash = "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918" - test_credentials = UserCredentials(user_id="1", password_hash=password_hash) - - # Mock user store responses - user_store.get_user_by_email.return_value = test_user - user_store.get_user_credentials.return_value = test_credentials - - # Mock successful session creation - session_module.start_new_session.return_value = None - - payload = {"email": "admin@example.com", "password": "admin"} - response = test_client.post("/api/auth/login", json=payload) - assert response.status_code == 200 - - response_data = response.json() - assert response_data["message"] == "Successfully logged in" - - # Verify that start_new_session was called with correct parameters - session_module.start_new_session.assert_called_once() - call_args = session_module.start_new_session.call_args - assert call_args[0][2] == "1" # user_id - assert call_args[1]["email"] == "admin@example.com" # email passed as kwarg - - -def test_login_invalid_credentials(client): - test_client, auth_module, session_module, user_store = client - - # Create test user and credentials - test_user = User(id="1", email="admin@example.com", full_name="Administrator") - # Hash for password "admin" - password_hash = "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918" - test_credentials = UserCredentials(user_id="1", password_hash=password_hash) - - # Mock user store responses - user_store.get_user_by_email.return_value = test_user - user_store.get_user_credentials.return_value = test_credentials - - payload = {"email": "admin@example.com", "password": "wrong-password"} - response = test_client.post("/api/auth/login", json=payload) - assert response.status_code == 401 - assert response.json()["detail"] == "Invalid email or password" - - # Verify that start_new_session was NOT called due to invalid credentials - session_module.start_new_session.assert_not_called() - - -def test_login_nonexistent_user(client): - test_client, auth_module, session_module, user_store = client - - # Mock user store to return None (user not found) - user_store.get_user_by_email.return_value = None - - payload = {"email": "nonexistent@example.com", "password": "password"} - response = test_client.post("/api/auth/login", json=payload) - assert response.status_code == 401 - assert response.json()["detail"] == "Invalid email or password" - - # Verify that start_new_session was NOT called due to invalid user - session_module.start_new_session.assert_not_called() - - -def test_logout_with_valid_session_cookie(client): - """Test logout calls session module's end_session method.""" - test_client, auth_module, session_module, user_store = client - - # Mock that session module doesn't raise any exception - session_module.end_session.return_value = None - - # Test logout - logout_response = test_client.post("/api/auth/logout") - assert logout_response.status_code == 200 - assert logout_response.json()["message"] == "Successfully logged out" - - # Verify that end_session was called - session_module.end_session.assert_called_once() - - -def test_logout_with_invalid_session_cookie(client): - """Test logout when session module raises an exception.""" - test_client, auth_module, session_module, user_store = client - - # Mock that session module doesn't raise any exception - session_module.end_session.return_value = None - - response = test_client.post("/api/auth/logout") - assert response.status_code == 200 - assert response.json()["message"] == "Successfully logged out" - - # Verify that end_session was called - session_module.end_session.assert_called_once() - - -def test_logout_without_session_cookie(client): - """Test logout without any session cookie.""" - test_client, auth_module, session_module, user_store = client - - # Mock that session module doesn't raise any exception - session_module.end_session.return_value = None - - response = test_client.post("/api/auth/logout") - assert response.status_code == 200 - assert response.json()["message"] == "Successfully logged out" - - # Verify that end_session was called - session_module.end_session.assert_called_once() - - -def test_login_user_without_credentials(client): - """Test login when user exists but has no credentials.""" - test_client, auth_module, session_module, user_store = client - - # Create test user but no credentials - test_user = User(id="1", email="admin@example.com", full_name="Administrator") - - # Mock user store responses - user_store.get_user_by_email.return_value = test_user - user_store.get_user_credentials.return_value = None # No credentials - - payload = {"email": "admin@example.com", "password": "admin"} - response = test_client.post("/api/auth/login", json=payload) - assert response.status_code == 401 - assert response.json()["detail"] == "Invalid email or password" - - # Verify that start_new_session was NOT called - session_module.start_new_session.assert_not_called() - - -def test_signup_success(client): - """Test successful user signup.""" - test_client, auth_module, session_module, user_store = client - - # Mock user store responses for new user - user_store.get_user_by_email.return_value = None # No existing user - new_user = User(id="2", email="newuser@example.com", full_name="New User") - user_store.create_user.return_value = new_user - user_store.set_user_password.return_value = None # No exception = success - - payload = { - "email": "newuser@example.com", - "password": "password123", - "full_name": "New User", - } - response = test_client.post("/api/auth/signup", json=payload) - assert response.status_code == 200 - - response_data = response.json() - assert response_data["message"] == "User registered successfully" - assert response_data["user_id"] == "2" - - # Verify that create_user and set_user_password were called - user_store.create_user.assert_called_once_with( - email="newuser@example.com", full_name="New User" - ) - user_store.set_user_password.assert_called_once() - - -def test_signup_existing_user(client): - """Test signup when user already exists.""" - test_client, auth_module, session_module, user_store = client - - # Mock that user already exists - existing_user = User( - id="1", email="existing@example.com", full_name="Existing User" - ) - user_store.get_user_by_email.return_value = existing_user - - payload = { - "email": "existing@example.com", - "password": "password123", - "full_name": "New User", - } - response = test_client.post("/api/auth/signup", json=payload) - assert response.status_code == 400 - assert response.json()["detail"] == "User with this email already exists" - - # Verify that create_user was NOT called - user_store.create_user.assert_not_called() - - -def test_signup_password_creation_failure(client): - """Test signup when password setting fails.""" - test_client, auth_module, session_module, user_store = client - - # Mock user store responses - user_store.get_user_by_email.return_value = None # No existing user - new_user = User(id="3", email="testuser@example.com", full_name="Test User") - user_store.create_user.return_value = new_user - user_store.set_user_password.side_effect = ValueError( - "User not found" - ) # Password setting fails - user_store.delete_user.return_value = None - - payload = { - "email": "testuser@example.com", - "password": "password123", - "full_name": "Test User", - } - response = test_client.post("/api/auth/signup", json=payload) - assert response.status_code == 500 - assert response.json()["detail"] == "Failed to create user account" - - # Verify cleanup occurred - user_store.delete_user.assert_called_once_with("3") diff --git a/backend/omni/src/modai/modules/authentication/module.py b/backend/omni/src/modai/modules/authentication/module.py index d828b33..b49a449 100644 --- a/backend/omni/src/modai/modules/authentication/module.py +++ b/backend/omni/src/modai/modules/authentication/module.py @@ -4,68 +4,34 @@ This web module handles user authentication flows. It integrates with session management modules to maintain user state across requests. -Features: -- Login endpoint with credential validation and session token generation -- Logout endpoint for secure session termination -- Integration with session modules for token management -- Abstract interface allowing multiple authentication strategies (password, OIDC, etc.) +The OIDC implementation delegates login/signup to an external identity provider +and provides endpoints for logout and user information. -The module follows the modular architecture pattern, allowing different authentication implementations -to be plugged in based on configuration requirements. +The module follows the modular architecture pattern, allowing different authentication +implementations to be plugged in based on configuration requirements. """ from abc import ABC, abstractmethod -from fastapi import APIRouter, Request, Response, Body -from fastapi.security import HTTPBearer +from fastapi import APIRouter, Request, Response from typing import Any -from pydantic import BaseModel from modai.module import ModaiModule, ModuleDependencies -class LoginRequest(BaseModel): - email: str - password: str - - -class User(BaseModel): - id: str - email: str - full_name: str | None = None - - class AuthenticationModule(ModaiModule, ABC): """ Module Declaration for: Authentication (Web Module) + + Abstract base for authentication modules. Implementations may use + OIDC, SAML, or other authentication strategies. """ def __init__(self, dependencies: ModuleDependencies, config: dict[str, Any]): super().__init__(dependencies, config) self.router = APIRouter() - self.security = HTTPBearer() # Add authentication routes - self.router.add_api_route("/api/auth/login", self.login, methods=["POST"]) self.router.add_api_route("/api/auth/logout", self.logout, methods=["POST"]) - @abstractmethod - async def login( - self, - request: Request, - response: Response, - login_data: LoginRequest = Body(...), - ) -> dict[str, str]: - """ - Authenticates user and returns session token. - - Args: - request: FastAPI request object - login_data: User credentials - - Returns: - 200 if successful, 401 if invalid credentials - """ - pass - @abstractmethod async def logout( self, @@ -73,11 +39,7 @@ async def logout( response: Response, ) -> dict[str, str]: """ - Logs out user by invalidating token. - - Args: - request: FastAPI request object - credentials: Bearer token from header + Logs out user by ending the session. Returns: Success message diff --git a/backend/omni/src/modai/modules/authentication/oidc_authentication_module.py b/backend/omni/src/modai/modules/authentication/oidc_authentication_module.py new file mode 100644 index 0000000..14eb7ca --- /dev/null +++ b/backend/omni/src/modai/modules/authentication/oidc_authentication_module.py @@ -0,0 +1,130 @@ +""" +OIDC Authentication Module. + +Provides OIDC-related endpoints for the frontend SPA: +- GET /api/auth/config - returns OIDC configuration for the frontend +- POST /api/auth/logout - handles server-side logout cleanup +- GET /api/auth/userinfo - returns user info from the validated session + +The actual OIDC login flow (redirect to IDP, PKCE code exchange) is +handled entirely by the frontend using oidc-client-ts. +""" + +import logging +from typing import Any + +from fastapi import APIRouter, Request, Response, HTTPException +from pydantic import BaseModel + +from modai.module import ModaiModule, ModuleDependencies +from modai.modules.session.module import SessionModule +from modai.modules.user_store.module import UserStore + + +class OIDCConfigResponse(BaseModel): + issuer: str + client_id: str + redirect_uri: str | None = None + post_logout_redirect_uri: str | None = None + + +class UserInfoResponse(BaseModel): + id: str + email: str + full_name: str | None = None + + +class OIDCAuthenticationModule(ModaiModule): + """ + OIDC Authentication Module. + + Provides endpoints that support the OIDC flow without managing + credentials directly. Login/signup is delegated to the OIDC provider. + """ + + def __init__(self, dependencies: ModuleDependencies, config: dict[str, Any]): + super().__init__(dependencies, config) + self.logger = logging.getLogger(__name__) + self.router = APIRouter() + + session = dependencies.modules.get("session") + user_store = dependencies.modules.get("user_store") + + if not session: + raise ValueError("Missing module dependency: 'session' module is required") + if not user_store: + raise ValueError( + "Missing module dependency: 'user_store' module is required" + ) + + self.session_module: SessionModule = session # type: ignore[assignment] + self.user_store: UserStore = user_store # type: ignore[assignment] + + # Routes + self.router.add_api_route("/api/auth/logout", self.logout, methods=["POST"]) + self.router.add_api_route("/api/auth/userinfo", self.userinfo, methods=["GET"]) + + async def logout(self, request: Request, response: Response) -> dict[str, str]: + """ + Server-side logout cleanup. + + In the OIDC flow, the frontend handles the redirect to the IDP's + end_session_endpoint. This endpoint is called to clean up any + server-side state (currently a no-op since we use stateless JWT validation). + """ + try: + self.session_module.end_session(request, response) + except Exception as e: + self.logger.debug(f"Logout cleanup: {e}") + + return {"message": "Successfully logged out"} + + async def userinfo(self, request: Request) -> UserInfoResponse: + """ + Returns user info from the validated OIDC session. + + Also performs JIT (Just-In-Time) user provisioning: if the user + doesn't exist in the local store yet, creates them from OIDC claims. + """ + session = self.session_module.validate_session_for_http(request) + + # JIT provisioning: ensure user exists in local store + user = await self.user_store.get_user_by_id(session.user_id) + + if not user: + # Try to find by email first (in case ID mapping changed) + email = session.additional.get("email") + if email: + user = await self.user_store.get_user_by_email(email) + + if not user and email: + # Create user from OIDC claims + try: + user = await self.user_store.create_user( + email=email, + full_name=session.additional.get("name"), + id=session.user_id, + ) + self.logger.info( + f"JIT provisioned user {session.user_id} ({email})" + ) + except Exception as e: + self.logger.error( + f"Failed to JIT provision user {session.user_id}: {e}" + ) + raise HTTPException( + status_code=500, + detail="Failed to provision user account", + ) from e + + if not user: + raise HTTPException( + status_code=404, + detail="User not found and could not be provisioned", + ) + + return UserInfoResponse( + id=user.id, + email=user.email, + full_name=user.full_name, + ) diff --git a/backend/omni/src/modai/modules/authentication/password_authentication_module.py b/backend/omni/src/modai/modules/authentication/password_authentication_module.py deleted file mode 100644 index a8eeace..0000000 --- a/backend/omni/src/modai/modules/authentication/password_authentication_module.py +++ /dev/null @@ -1,194 +0,0 @@ -""" -Default implementation of the Authentication module. -Provides session-based authentication with user store integration. -""" - -from typing import Any -import hashlib -import logging -from fastapi import Request, Body, HTTPException, Response, status -from pydantic import BaseModel - -from modai.module import ModuleDependencies -from modai.modules.authentication.module import ( - AuthenticationModule, - LoginRequest, -) -from modai.modules.session.module import SessionModule -from modai.modules.user_store.module import UserStore - - -class SignupRequest(BaseModel): - email: str - password: str - full_name: str | None = None - - -class PasswordAuthenticationModule(AuthenticationModule): - """Default implementation of the Authentication module.""" - - def __init__(self, dependencies: ModuleDependencies, config: dict[str, Any]): - super().__init__(dependencies, config) - - # Set up logger - self.logger = logging.getLogger(__name__) - - if not dependencies.modules.get("session"): - raise ValueError( - "Missing module dependency: 'session' module is required for authentication" - ) - - if not dependencies.modules.get("user_store"): - raise ValueError( - "Missing module dependency: 'user_store' module is required for authentication" - ) - - self.session_module: SessionModule = dependencies.modules.get("session") - self.user_store: UserStore = dependencies.modules.get("user_store") - - # Add password authentication specific routes - self.router.add_api_route("/api/auth/signup", self.signup, methods=["POST"]) - - async def login( - self, - request: Request, - response: Response, - login_data: LoginRequest = Body(...), - ) -> dict[str, str]: - """ - Authenticates user and returns session token. - Validates email/password against user store. - """ - - # Get user by email from user store - user = await self.user_store.get_user_by_email(login_data.email) - if not user: - self.logger.info(f"Login attempt with unknown email: {login_data.email}") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid email or password", - headers={"WWW-Authenticate": "Bearer"}, - ) - - # Get user credentials - credentials = await self.user_store.get_user_credentials(user.id) - if not credentials: - self.logger.info( - f"Credentials for user id {user.id} ({login_data.email}) not found" - ) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid email or password", - headers={"WWW-Authenticate": "Bearer"}, - ) - - # Verify password - password_hash = self._hash_password(login_data.password) - if credentials.password_hash != password_hash: - self.logger.info( - f"Invalid password attempt for user id {user.id} ({login_data.email})" - ) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid email or password", - headers={"WWW-Authenticate": "Bearer"}, - ) - - # Create session token using session module - self.session_module.start_new_session( - request, response, user.id, email=user.email - ) - - return {"message": "Successfully logged in"} - - async def signup( - self, - request: Request, - response: Response, - signup_data: SignupRequest = Body(...), - ) -> dict[str, str]: - """ - Registers a new user with email and password. - Creates user in user store and sets password credentials. - """ - - # Check if user already exists - existing_user = await self.user_store.get_user_by_email(signup_data.email) - if existing_user: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="User with this email already exists", - ) - - # Create new user - try: - user = await self.user_store.create_user( - email=signup_data.email, - full_name=signup_data.full_name, - ) - except ValueError as e: - # Log the actual error for debugging but don't expose it to client - self.logger.error(f"Failed to create user {signup_data.email}: {str(e)}") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid user data provided", - ) - except Exception as e: - # Log unexpected errors - self.logger.error( - f"Unexpected error creating user {signup_data.email}: {str(e)}" - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create user account", - ) - - # Set user password - password_hash = self._hash_password(signup_data.password) - try: - await self.user_store.set_user_password(user.id, password_hash) - except Exception as e: - # Log unexpected errors - self.logger.error( - f"Unexpected error setting password for user {user.id} ({signup_data.email}): {str(e)}" - ) - # Clean up - delete the user if password setting failed - try: - await self.user_store.delete_user(user.id) - except Exception as cleanup_error: - self.logger.error( - f"Failed to cleanup user {user.id} after password creation failure: {str(cleanup_error)}" - ) - - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create user account", - ) - - return {"message": "User registered successfully", "user_id": user.id} - - async def logout( - self, - request: Request, - response: Response, - ) -> dict[str, str]: - """ - Logs out user by ending the session. - """ - try: - # Simply forward to session module - let it handle the implementation details - self.session_module.end_session(request, response) - return {"message": "Successfully logged out"} - except Exception as e: - # Log the actual error for debugging but don't expose it to client - self.logger.error(f"Error during logout: {str(e)}") - # For any other exception (like JWT errors), treat as invalid token - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token", - headers={"WWW-Authenticate": "Bearer"}, - ) - - def _hash_password(self, password: str) -> str: - """Simple password hashing using SHA-256. In production, use proper password hashing like bcrypt.""" - return hashlib.sha256(password.encode()).hexdigest() diff --git a/backend/omni/src/modai/modules/model_provider_store/__tests__/abstract_model_provider_store_test.py b/backend/omni/src/modai/modules/model_provider_store/__tests__/abstract_model_provider_store_test.py index 17dbd47..98ff994 100644 --- a/backend/omni/src/modai/modules/model_provider_store/__tests__/abstract_model_provider_store_test.py +++ b/backend/omni/src/modai/modules/model_provider_store/__tests__/abstract_model_provider_store_test.py @@ -66,7 +66,7 @@ async def test_get_providers_returns_all_added_providers( name="Anthropic", url="https://api.anthropic.com/v1", properties={} ) await model_provider_store.add_provider( - name="Local Model", url="http://localhost:8080/v1", properties={} + name="Local Model", url="http://localhost:8080/", properties={} ) all_providers = await model_provider_store.get_providers() diff --git a/backend/omni/src/modai/modules/session/__tests__/test_session.py b/backend/omni/src/modai/modules/session/__tests__/test_session.py deleted file mode 100644 index b151faa..0000000 --- a/backend/omni/src/modai/modules/session/__tests__/test_session.py +++ /dev/null @@ -1,245 +0,0 @@ -import pytest -from unittest.mock import MagicMock -import jwt -from datetime import datetime, timedelta, timezone -from modai.module import ModuleDependencies -from modai.modules.session.jwt_session_module import JwtSessionModule -from modai.modules.session.module import Session - - -@pytest.fixture -def mock_request_response(): - """Create mock request and response objects for testing.""" - mock_request = MagicMock() - mock_response = MagicMock() - return mock_request, mock_response - - -def test_create_session_token(mock_request_response): - """Test session token creation.""" - config = { - "jwt_secret": "test-secret", - "jwt_algorithm": "HS256", - "jwt_expiration_hours": 1, - } - session_module = JwtSessionModule(dependencies=ModuleDependencies(), config=config) - mock_request, mock_response = mock_request_response - - session_module.start_new_session( - mock_request, mock_response, "user123", email="testuser@example.com" - ) - - # Verify set_cookie was called - mock_response.set_cookie.assert_called_once() - call_args = mock_response.set_cookie.call_args - - # Extract the token from the call - token = call_args.kwargs["value"] - - # Verify token can be decoded - payload = jwt.decode(token, "test-secret", algorithms=["HS256"]) - assert payload["user_id"] == "user123" - assert payload["email"] == "testuser@example.com" - assert "exp" in payload - assert "iat" in payload - - -def test_create_session_token_with_kwargs(mock_request_response): - """Test session token creation with additional data.""" - config = {"jwt_secret": "test-secret"} - session_module = JwtSessionModule(dependencies=ModuleDependencies(), config=config) - mock_request, mock_response = mock_request_response - - session_module.start_new_session( - mock_request, - mock_response, - "user123", - email="testuser@example.com", - role="admin", - department="IT", - ) - - # Extract the token from the set_cookie call - call_args = mock_response.set_cookie.call_args - token = call_args.kwargs["value"] - - payload = jwt.decode(token, "test-secret", algorithms=["HS256"]) - assert payload["user_id"] == "user123" - assert payload["email"] == "testuser@example.com" - assert payload["role"] == "admin" - assert payload["department"] == "IT" - - -def test_validate_session_with_kwargs(mock_request_response): - """Test validating session with additional data returns correct Session object.""" - config = {"jwt_secret": "test-secret"} - session_module = JwtSessionModule(dependencies=ModuleDependencies(), config=config) - mock_request, mock_response = mock_request_response - - session_module.start_new_session( - mock_request, - mock_response, - "user123", - email="testuser@example.com", - role="admin", - department="IT", - ) - - # Extract the token from the set_cookie call - call_args = mock_response.set_cookie.call_args - token = call_args.kwargs["value"] - - # Create a new mock request with the token in cookies - mock_request_with_cookie = MagicMock() - mock_request_with_cookie.cookies = {"session_token": token} - - session = session_module.validate_session(mock_request_with_cookie) - - assert isinstance(session, Session) - assert session.user_id == "user123" - assert session.additional["email"] == "testuser@example.com" - assert session.additional["role"] == "admin" - assert session.additional["department"] == "IT" - # JWT standard fields should not be in additional - assert "exp" not in session.additional - assert "iat" not in session.additional - - -def test_validate_session_token_success(mock_request_response): - """Test successful token validation.""" - config = {"jwt_secret": "test-secret"} - session_module = JwtSessionModule(dependencies=ModuleDependencies(), config=config) - mock_request, mock_response = mock_request_response - - session_module.start_new_session( - mock_request, mock_response, "user123", email="testuser@example.com" - ) - - # Extract the token from the set_cookie call - call_args = mock_response.set_cookie.call_args - token = call_args.kwargs["value"] - - # Create a new mock request with the token in cookies - mock_request_with_cookie = MagicMock() - mock_request_with_cookie.cookies = {"session_token": token} - - session = session_module.validate_session(mock_request_with_cookie) - - assert isinstance(session, Session) - assert session.user_id == "user123" - assert session.additional["email"] == "testuser@example.com" - - -def test_validate_session_token_invalid(): - """Test validation of invalid token.""" - config = {"jwt_secret": "test-secret"} - session_module = JwtSessionModule(dependencies=ModuleDependencies(), config=config) - - # Create mock request with invalid token - mock_request = MagicMock() - mock_request.cookies = {"session_token": "invalid-token"} - - with pytest.raises(jwt.InvalidTokenError): - session_module.validate_session(mock_request) - - -def test_validate_session_token_no_cookie(): - """Test validation when no session token cookie is present.""" - config = {"jwt_secret": "test-secret"} - session_module = JwtSessionModule(dependencies=ModuleDependencies(), config=config) - - # Create mock request with no session token cookie - mock_request = MagicMock() - mock_request.cookies = {} - - with pytest.raises(ValueError, match="No session token found in cookies"): - session_module.validate_session(mock_request) - - -def test_end_session(mock_request_response): - """Test ending a session by clearing the session cookie.""" - config = {"jwt_secret": "test-secret"} - session_module = JwtSessionModule(dependencies=ModuleDependencies(), config=config) - mock_request, mock_response = mock_request_response - - # End the session - session_module.end_session(mock_request, mock_response) - - # Verify delete_cookie was called with the correct key - mock_response.delete_cookie.assert_called_once_with(key="session_token") - - -def test_end_session_no_existing_session(mock_request_response): - """Test ending a session when no session exists (should still work).""" - config = {"jwt_secret": "test-secret"} - session_module = JwtSessionModule(dependencies=ModuleDependencies(), config=config) - mock_request, mock_response = mock_request_response - - # Create request with no session cookie - mock_request.cookies = {} - - # End the session (should not raise an error) - session_module.end_session(mock_request, mock_response) - - # Verify delete_cookie was still called - mock_response.delete_cookie.assert_called_once_with(key="session_token") - - -def test_end_session_after_session_creation(mock_request_response): - """Test the complete flow: create session, validate, then end session.""" - config = {"jwt_secret": "test-secret"} - session_module = JwtSessionModule(dependencies=ModuleDependencies(), config=config) - mock_request, mock_response = mock_request_response - - # Start a session - session_module.start_new_session( - mock_request, mock_response, "user123", email="testuser@example.com" - ) - - # Extract the token from the set_cookie call - call_args = mock_response.set_cookie.call_args - token = call_args.kwargs["value"] - - # Create a new mock request with the token in cookies for validation - mock_request_with_cookie = MagicMock() - mock_request_with_cookie.cookies = {"session_token": token} - - # Validate the session - session = session_module.validate_session(mock_request_with_cookie) - assert isinstance(session, Session) - assert session.user_id == "user123" - assert session.additional["email"] == "testuser@example.com" - - # Reset the mock to clear previous calls - mock_response.reset_mock() - - # End the session - session_module.end_session(mock_request_with_cookie, mock_response) - - # Verify delete_cookie was called - mock_response.delete_cookie.assert_called_once_with(key="session_token") - - -def test_validate_session_token_expired(): - """Test validation of expired token raises ExpiredSignatureError.""" - config = {"jwt_secret": "test-secret", "jwt_algorithm": "HS256"} - session_module = JwtSessionModule(dependencies=ModuleDependencies(), config=config) - - # Create an expired token manually - expired_time = datetime.now(timezone.utc) - timedelta(hours=1) # 1 hour ago - payload = { - "user_id": "user123", - "email": "testuser@example.com", - "exp": expired_time, - "iat": datetime.now(timezone.utc) - timedelta(hours=2), # 2 hours ago - } - - expired_token = jwt.encode(payload, "test-secret", algorithm="HS256") - - # Create mock request with expired token - mock_request = MagicMock() - mock_request.cookies = {"session_token": expired_token} - - # Verify that ExpiredSignatureError is raised - with pytest.raises(jwt.ExpiredSignatureError): - session_module.validate_session(mock_request) diff --git a/backend/omni/src/modai/modules/session/jwt_session_module.py b/backend/omni/src/modai/modules/session/jwt_session_module.py deleted file mode 100644 index cb1d22e..0000000 --- a/backend/omni/src/modai/modules/session/jwt_session_module.py +++ /dev/null @@ -1,111 +0,0 @@ -""" -Default JWT-based implementation of the Session module. -Provides JWT token creation, validation, and session management. -""" - -from fastapi import Request, Response -import jwt -from datetime import datetime, timedelta, timezone -from typing import Any -from modai.module import ModuleDependencies -from modai.modules.session.module import SessionModule, Session - - -class JwtSessionModule(SessionModule): - """Default JWT-based implementation of the Session module.""" - - def __init__(self, dependencies: ModuleDependencies, config: dict[str, Any]): - super().__init__(dependencies, config) - # JWT configuration - self.jwt_secret = self.config.get("jwt_secret") - self.jwt_algorithm = self.config.get("jwt_algorithm", "HS256") - self.jwt_expiration_hours = self.config.get("jwt_expiration_hours", 24) - self.cookie_secure = self.config.get("cookie_secure", True) - - def start_new_session( - self, request: Request, response: Response, user_id: str, **kwargs - ): - """ - Creates a session token for the given user and applies it to the response. - - Args: - request: FastAPI request object - response: FastAPI response object - user_id: Unique identifier for the user - **kwargs: Additional data to include in the session (e.g., email) - - Does not return anything - operates on response object by setting cookie. - """ - expiration = datetime.now(timezone.utc) + timedelta( - hours=self.jwt_expiration_hours - ) - payload = { - "user_id": user_id, - "exp": expiration, - "iat": datetime.now(timezone.utc), - **kwargs, # Include any additional session data - } - - token = jwt.encode(payload, self.jwt_secret, algorithm=self.jwt_algorithm) - - # Apply token to cookie - response.set_cookie( - key="session_token", - value=token, - max_age=self.jwt_expiration_hours * 3600, # Convert hours to seconds - httponly=True, # Prevent access via JavaScript for security - secure=self.cookie_secure, - samesite="lax", # CSRF protection - ) - - def validate_session( - self, - request: Request, - ) -> Session: - """ - Validates and decodes JWT token. - - Args: - request: FastAPI request object - - Returns: - The active valid session - - Raises: - If session is invalid or expired - """ - # Get token from cookies - token = request.cookies.get("session_token") - - if not token: - raise ValueError("No session token found in cookies") - - # Decode and validate JWT - payload = jwt.decode( - token, - self.jwt_secret, - algorithms=[self.jwt_algorithm], - ) - - # Extract user_id and all other data as additional - user_id = payload.pop("user_id") - # Remove JWT standard fields from additional data - payload.pop("exp", None) - payload.pop("iat", None) - - return Session(user_id=user_id, additional=payload) - - def end_session( - self, - request: Request, - response: Response, - ): - """ - Ends the session by invalidating the session token. - - Args: - request: FastAPI request object - response: FastAPI response object - """ - # Clear the session cookie - response.delete_cookie(key="session_token") diff --git a/backend/omni/src/modai/modules/session/oidc_session_module.py b/backend/omni/src/modai/modules/session/oidc_session_module.py new file mode 100644 index 0000000..7f68254 --- /dev/null +++ b/backend/omni/src/modai/modules/session/oidc_session_module.py @@ -0,0 +1,130 @@ +""" +OIDC-based implementation of the Session module. +Validates Bearer tokens from OIDC providers using JWKS (JSON Web Key Set) verification. +This module is provider-agnostic and works with any OIDC-compliant identity provider. +""" + +import logging +from typing import Any + +import jwt +from jwt import PyJWKClient +from fastapi import Request, Response + +from modai.module import ModuleDependencies +from modai.modules.session.module import SessionModule, Session + + +class OIDCSessionModule(SessionModule): + """ + OIDC-based session module that validates Bearer access tokens + using the OIDC provider's JWKS endpoint. + + The frontend sends the access_token as a Bearer token in the Authorization header. + This module validates the token signature and claims, then extracts user info. + """ + + def __init__(self, dependencies: ModuleDependencies, config: dict[str, Any]): + super().__init__(dependencies, config) + self.logger = logging.getLogger(__name__) + + self.issuer = self.config.get("issuer") + if not self.issuer: + raise ValueError("OIDC session module requires 'issuer' config") + + self.jwks_uri = self.config.get( + "jwks_uri", f"{self.issuer.rstrip('/')}/oauth/v2/keys" + ) + self.audience = self.config.get("audience") + self.algorithms = self.config.get("algorithms", ["RS256"]) + + # User ID claim - defaults to "sub" (standard OIDC) + self.user_id_claim = self.config.get("user_id_claim", "sub") + + # Initialize JWKS client for token verification + self._jwks_client = PyJWKClient(self.jwks_uri, cache_keys=True) + + def start_new_session( + self, + request: Request, + response: Response, + user_id: str, + **kwargs, + ): + """ + Not used in OIDC flow. Sessions are managed by the identity provider. + The frontend handles the OIDC flow and sends access tokens directly. + """ + pass + + def validate_session(self, request: Request) -> Session: + """ + Validates a Bearer access token from the Authorization header. + + Extracts the token, verifies its signature against the OIDC provider's + JWKS, and returns a Session with user information. + """ + token = self._extract_bearer_token(request) + claims = self._verify_token(token) + + user_id = claims.get(self.user_id_claim) + if not user_id: + raise ValueError(f"Token missing required claim: {self.user_id_claim}") + + additional = { + "email": claims.get("email"), + "name": claims.get("name"), + "email_verified": claims.get("email_verified"), + } + # Remove None values + additional = {k: v for k, v in additional.items() if v is not None} + + return Session(user_id=str(user_id), additional=additional) + + def end_session(self, request: Request, response: Response): + """ + Not used in OIDC flow. Logout is handled by the frontend + redirecting to the OIDC provider's end_session_endpoint. + """ + pass + + def _extract_bearer_token(self, request: Request) -> str: + """Extracts Bearer token from Authorization header.""" + auth_header = request.headers.get("Authorization") + if not auth_header: + raise ValueError("No Authorization header found") + + parts = auth_header.split(" ") + if len(parts) != 2 or parts[0].lower() != "bearer": + raise ValueError( + "Invalid Authorization header format, expected 'Bearer '" + ) + + return parts[1] + + def _verify_token(self, token: str) -> dict[str, Any]: + """Verifies JWT token against OIDC provider's JWKS.""" + try: + signing_key = self._jwks_client.get_signing_key_from_jwt(token) + + decode_options: dict[str, Any] = {} + if not self.audience: + decode_options["verify_aud"] = False + + claims = jwt.decode( + token, + signing_key.key, + algorithms=self.algorithms, + issuer=self.issuer, + audience=self.audience, + options=decode_options, + ) + return claims + except jwt.ExpiredSignatureError as e: + raise ValueError("Token has expired") from e + except jwt.InvalidIssuerError as e: + raise ValueError("Invalid token issuer") from e + except jwt.InvalidAudienceError as e: + raise ValueError("Invalid token audience") from e + except jwt.InvalidTokenError as e: + raise ValueError(f"Invalid token: {e}") from e diff --git a/backend/omni/src/modai/modules/tools/__tests__/test_tool_registry_openapi.py b/backend/omni/src/modai/modules/tools/__tests__/test_tool_registry_openapi.py index ba0fa29..b1df4f6 100644 --- a/backend/omni/src/modai/modules/tools/__tests__/test_tool_registry_openapi.py +++ b/backend/omni/src/modai/modules/tools/__tests__/test_tool_registry_openapi.py @@ -726,7 +726,7 @@ def test_strips_path(self): assert _derive_base_url("http://calc:8000/calculate") == "http://calc:8000" def test_strips_nested_path(self): - assert _derive_base_url("http://host:9000/api/v1/run") == "http://host:9000" + assert _derive_base_url("http://host:9000/api/run") == "http://host:9000" def test_no_path(self): assert _derive_base_url("http://tool:8000") == "http://tool:8000" diff --git a/backend/omni/src/modai/modules/user/simple_user_module.py b/backend/omni/src/modai/modules/user/simple_user_module.py index 476107c..52ee791 100644 --- a/backend/omni/src/modai/modules/user/simple_user_module.py +++ b/backend/omni/src/modai/modules/user/simple_user_module.py @@ -1,3 +1,4 @@ +import logging from typing import Any from fastapi import Request, HTTPException from modai.module import ModuleDependencies @@ -7,14 +8,15 @@ class SimpleUserModule(UserModule): - """Simple implementation of the User module.""" + """Simple implementation of the User module with JIT user provisioning.""" def __init__(self, dependencies: ModuleDependencies, config: dict[str, Any]): super().__init__(dependencies, config) + self.logger = logging.getLogger(__name__) # Get required dependencies - self.session_module: SessionModule = dependencies.modules.get("session") - self.user_store: UserStore = dependencies.modules.get("user_store") + self.session_module: SessionModule = dependencies.modules.get("session") # type: ignore[assignment] + self.user_store: UserStore = dependencies.modules.get("user_store") # type: ignore[assignment] if not self.session_module: raise ValueError("User module requires a session module dependency") @@ -25,16 +27,43 @@ async def get_current_user(self, request: Request) -> UserResponse: """ Retrieves the current logged-in user. - Validates the session and returns the user information from the user store. + Validates the session and returns the user information. + Performs JIT (Just-In-Time) provisioning if the user doesn't + exist in the local store yet (e.g., first OIDC login). """ - - # Validate session session = self.session_module.validate_session_for_http(request) - # Get user from user store + # Try to get user from local store user = await self.user_store.get_user_by_id(session.user_id) + if not user: + # JIT provisioning: create user from session claims + user = await self._jit_provision_user(session) + if not user: raise HTTPException(status_code=404, detail="User not found") return UserResponse(id=user.id, email=user.email, full_name=user.full_name) + + async def _jit_provision_user(self, session): + """Provision a user from OIDC session claims if possible.""" + email = session.additional.get("email") + if not email: + return None + + # Check if user exists by email (ID mapping may differ) + user = await self.user_store.get_user_by_email(email) + if user: + return user + + try: + user = await self.user_store.create_user( + email=email, + full_name=session.additional.get("name"), + id=session.user_id, + ) + self.logger.info(f"JIT provisioned user {session.user_id} ({email})") + return user + except Exception as e: + self.logger.error(f"Failed to JIT provision user {session.user_id}: {e}") + return None diff --git a/backend/omni/src/modai/modules/user_store/__tests__/test_oidc_authentication.py b/backend/omni/src/modai/modules/user_store/__tests__/test_oidc_authentication.py new file mode 100644 index 0000000..12746a6 --- /dev/null +++ b/backend/omni/src/modai/modules/user_store/__tests__/test_oidc_authentication.py @@ -0,0 +1,200 @@ +import sys +import os +import pytest +from unittest.mock import Mock, AsyncMock +from fastapi.testclient import TestClient +from fastapi import FastAPI + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) +from modai.module import ModuleDependencies +from modai.modules.authentication.oidc_authentication_module import ( + OIDCAuthenticationModule, +) +from modai.modules.session.module import SessionModule, Session +from modai.modules.user_store.module import UserStore, User + + +@pytest.fixture +def client(): + app = FastAPI() + + # Create a mock session module + session_module = Mock(spec=SessionModule) + + # Create a mock user store module + user_store = Mock(spec=UserStore) + user_store.get_user_by_id = AsyncMock() + user_store.get_user_by_email = AsyncMock() + user_store.create_user = AsyncMock() + + # Create OIDC authentication module + auth_module = OIDCAuthenticationModule( + dependencies=ModuleDependencies( + {"session": session_module, "user_store": user_store} + ), + config={}, + ) + app.include_router(auth_module.router) + return TestClient(app), auth_module, session_module, user_store + + +def test_logout_success(client): + test_client, auth_module, session_module, user_store = client + + session_module.end_session.return_value = None + + response = test_client.post("/api/auth/logout") + assert response.status_code == 200 + assert response.json()["message"] == "Successfully logged out" + + +def test_logout_with_session_error(client): + """Logout should succeed even if end_session raises.""" + test_client, auth_module, session_module, user_store = client + + session_module.end_session.side_effect = Exception("Session error") + + response = test_client.post("/api/auth/logout") + assert response.status_code == 200 + assert response.json()["message"] == "Successfully logged out" + + +def test_userinfo_existing_user(client): + """Test userinfo for a user that already exists in the store.""" + test_client, auth_module, session_module, user_store = client + + session = Session( + user_id="user-123", + additional={"email": "test@example.com", "name": "Test User"}, + ) + session_module.validate_session_for_http.return_value = session + + existing_user = User(id="user-123", email="test@example.com", full_name="Test User") + user_store.get_user_by_id.return_value = existing_user + + response = test_client.get( + "/api/auth/userinfo", + headers={"Authorization": "Bearer test-token"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["id"] == "user-123" + assert data["email"] == "test@example.com" + assert data["full_name"] == "Test User" + + +def test_userinfo_jit_provision_new_user(client): + """Test JIT provisioning when user doesn't exist yet.""" + test_client, auth_module, session_module, user_store = client + + session = Session( + user_id="new-user-456", + additional={"email": "new@example.com", "name": "New User"}, + ) + session_module.validate_session_for_http.return_value = session + + # User doesn't exist by ID or email + user_store.get_user_by_id.return_value = None + user_store.get_user_by_email.return_value = None + + # JIT provisioning creates the user + new_user = User(id="new-user-456", email="new@example.com", full_name="New User") + user_store.create_user.return_value = new_user + + response = test_client.get( + "/api/auth/userinfo", + headers={"Authorization": "Bearer test-token"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["id"] == "new-user-456" + assert data["email"] == "new@example.com" + + # Verify create_user was called with OIDC claims + user_store.create_user.assert_called_once_with( + email="new@example.com", + full_name="New User", + id="new-user-456", + ) + + +def test_userinfo_jit_provision_finds_by_email(client): + """Test JIT provisioning finds existing user by email when ID differs.""" + test_client, auth_module, session_module, user_store = client + + session = Session( + user_id="oidc-id-789", + additional={"email": "existing@example.com", "name": "Existing User"}, + ) + session_module.validate_session_for_http.return_value = session + + # Not found by OIDC ID + user_store.get_user_by_id.return_value = None + # But found by email + existing_user = User( + id="local-id-123", + email="existing@example.com", + full_name="Existing User", + ) + user_store.get_user_by_email.return_value = existing_user + + response = test_client.get( + "/api/auth/userinfo", + headers={"Authorization": "Bearer test-token"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["id"] == "local-id-123" + + # create_user should NOT have been called + user_store.create_user.assert_not_called() + + +def test_userinfo_unauthenticated(client): + """Test userinfo without valid session.""" + test_client, auth_module, session_module, user_store = client + + from fastapi import HTTPException + + session_module.validate_session_for_http.side_effect = HTTPException( + status_code=401, detail="Missing, invalid or expired session" + ) + + response = test_client.get("/api/auth/userinfo") + assert response.status_code == 401 + + +def test_userinfo_user_not_found_no_email(client): + """Test userinfo when user has no email in claims.""" + test_client, auth_module, session_module, user_store = client + + session = Session( + user_id="user-no-email", + additional={}, + ) + session_module.validate_session_for_http.return_value = session + user_store.get_user_by_id.return_value = None + + response = test_client.get( + "/api/auth/userinfo", + headers={"Authorization": "Bearer test-token"}, + ) + assert response.status_code == 404 + + +def test_module_requires_session_dependency(): + """Module should fail without session dependency.""" + with pytest.raises(ValueError, match="session"): + OIDCAuthenticationModule( + dependencies=ModuleDependencies({"user_store": Mock(spec=UserStore)}), + config={}, + ) + + +def test_module_requires_user_store_dependency(): + """Module should fail without user_store dependency.""" + with pytest.raises(ValueError, match="user_store"): + OIDCAuthenticationModule( + dependencies=ModuleDependencies({"session": Mock(spec=SessionModule)}), + config={}, + ) diff --git a/backend/omni/src/modai/modules/user_store/__tests__/test_oidc_session.py b/backend/omni/src/modai/modules/user_store/__tests__/test_oidc_session.py new file mode 100644 index 0000000..3a805a1 --- /dev/null +++ b/backend/omni/src/modai/modules/user_store/__tests__/test_oidc_session.py @@ -0,0 +1,244 @@ +import sys +import os +import pytest +from unittest.mock import MagicMock, patch +import jwt +from datetime import datetime, timedelta, timezone + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) +from modai.module import ModuleDependencies +from modai.modules.session.oidc_session_module import OIDCSessionModule +from modai.modules.session.module import Session + + +# Test RSA key pair for JWT signing +from cryptography.hazmat.primitives.asymmetric import rsa + + +def generate_rsa_keys(): + """Generate RSA key pair for testing.""" + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + public_key = private_key.public_key() + return private_key, public_key + + +PRIVATE_KEY, PUBLIC_KEY = generate_rsa_keys() +TEST_ISSUER = "http://localhost:8080" + + +def create_test_token( + sub="user-123", + email="test@example.com", + name="Test User", + issuer=TEST_ISSUER, + expired=False, + extra_claims=None, +): + """Create a signed JWT for testing.""" + now = datetime.now(timezone.utc) + exp = now - timedelta(hours=1) if expired else now + timedelta(hours=1) + + payload = { + "sub": sub, + "email": email, + "name": name, + "email_verified": True, + "iss": issuer, + "aud": "test-audience", + "exp": exp, + "iat": now, + } + if extra_claims: + payload.update(extra_claims) + + return jwt.encode( + payload, PRIVATE_KEY, algorithm="RS256", headers={"kid": "test-key-1"} + ) + + +@pytest.fixture +def oidc_session(): + """Create an OIDCSessionModule with mocked JWKS client.""" + with patch( + "modai.modules.session.oidc_session_module.PyJWKClient" + ) as mock_jwks_class: + # Mock the JWKS client to return our test public key + mock_jwks_client = MagicMock() + mock_signing_key = MagicMock() + mock_signing_key.key = PUBLIC_KEY + mock_jwks_client.get_signing_key_from_jwt.return_value = mock_signing_key + mock_jwks_class.return_value = mock_jwks_client + + module = OIDCSessionModule( + dependencies=ModuleDependencies(), + config={ + "issuer": TEST_ISSUER, + "algorithms": ["RS256"], + }, + ) + return module, mock_jwks_client + + +def test_validate_session_success(oidc_session): + module, _ = oidc_session + token = create_test_token() + + request = MagicMock() + request.headers = {"Authorization": f"Bearer {token}"} + + session = module.validate_session(request) + + assert isinstance(session, Session) + assert session.user_id == "user-123" + assert session.additional["email"] == "test@example.com" + assert session.additional["name"] == "Test User" + assert session.additional["email_verified"] is True + + +def test_validate_session_no_auth_header(oidc_session): + module, _ = oidc_session + + request = MagicMock() + request.headers = {} + + with pytest.raises(ValueError, match="No Authorization header"): + module.validate_session(request) + + +def test_validate_session_invalid_auth_format(oidc_session): + module, _ = oidc_session + + request = MagicMock() + request.headers = {"Authorization": "Basic abc123"} + + with pytest.raises(ValueError, match="Invalid Authorization header format"): + module.validate_session(request) + + +def test_validate_session_expired_token(oidc_session): + module, _ = oidc_session + token = create_test_token(expired=True) + + request = MagicMock() + request.headers = {"Authorization": f"Bearer {token}"} + + with pytest.raises(ValueError, match="Token has expired"): + module.validate_session(request) + + +def test_validate_session_wrong_issuer(oidc_session): + module, _ = oidc_session + token = create_test_token(issuer="http://wrong-issuer.com") + + request = MagicMock() + request.headers = {"Authorization": f"Bearer {token}"} + + with pytest.raises(ValueError, match="Invalid token issuer"): + module.validate_session(request) + + +def test_validate_session_missing_sub_claim(oidc_session): + module, _ = oidc_session + + # Create token without sub claim + now = datetime.now(timezone.utc) + payload = { + "email": "test@example.com", + "iss": TEST_ISSUER, + "exp": now + timedelta(hours=1), + "iat": now, + } + token = jwt.encode( + payload, PRIVATE_KEY, algorithm="RS256", headers={"kid": "test-key-1"} + ) + + request = MagicMock() + request.headers = {"Authorization": f"Bearer {token}"} + + with pytest.raises(ValueError, match="Token missing required claim"): + module.validate_session(request) + + +def test_validate_session_for_http_success(oidc_session): + module, _ = oidc_session + token = create_test_token() + + request = MagicMock() + request.headers = {"Authorization": f"Bearer {token}"} + + session = module.validate_session_for_http(request) + assert session.user_id == "user-123" + + +def test_validate_session_for_http_failure(oidc_session): + module, _ = oidc_session + + request = MagicMock() + request.headers = {} + + from fastapi import HTTPException + + with pytest.raises(HTTPException) as exc_info: + module.validate_session_for_http(request) + assert exc_info.value.status_code == 401 + + +def test_start_new_session_is_noop(oidc_session): + """start_new_session should be a no-op for OIDC.""" + module, _ = oidc_session + request = MagicMock() + response = MagicMock() + + # Should not raise + module.start_new_session(request, response, "user-123") + + +def test_end_session_is_noop(oidc_session): + """end_session should be a no-op for OIDC.""" + module, _ = oidc_session + request = MagicMock() + response = MagicMock() + + # Should not raise + module.end_session(request, response) + + +def test_constructor_requires_issuer(): + """Module should fail to initialize without issuer config.""" + with patch("modai.modules.session.oidc_session_module.PyJWKClient"): + with pytest.raises(ValueError, match="issuer"): + OIDCSessionModule( + dependencies=ModuleDependencies(), + config={}, + ) + + +def test_custom_user_id_claim(oidc_session): + """Test using a custom claim for user ID.""" + with patch( + "modai.modules.session.oidc_session_module.PyJWKClient" + ) as mock_jwks_class: + mock_jwks_client = MagicMock() + mock_signing_key = MagicMock() + mock_signing_key.key = PUBLIC_KEY + mock_jwks_client.get_signing_key_from_jwt.return_value = mock_signing_key + mock_jwks_class.return_value = mock_jwks_client + + module = OIDCSessionModule( + dependencies=ModuleDependencies(), + config={ + "issuer": TEST_ISSUER, + "algorithms": ["RS256"], + "user_id_claim": "email", + }, + ) + + token = create_test_token() + request = MagicMock() + request.headers = {"Authorization": f"Bearer {token}"} + + session = module.validate_session(request) + assert session.user_id == "test@example.com" diff --git a/backend/omni/src/modai/modules/user_store/inmemory_user_store.py b/backend/omni/src/modai/modules/user_store/inmemory_user_store.py index 473b141..0323665 100644 --- a/backend/omni/src/modai/modules/user_store/inmemory_user_store.py +++ b/backend/omni/src/modai/modules/user_store/inmemory_user_store.py @@ -57,7 +57,7 @@ async def create_user( if user.email == email: raise ValueError(f"Email '{email}' already exists") - user_id = self._generate_user_id() + user_id = additional_fields.get("id") or self._generate_user_id() now = datetime.now() user = User( diff --git a/backend/omni/src/modai/modules/user_store/sql_model_user_store.py b/backend/omni/src/modai/modules/user_store/sql_model_user_store.py index 633c783..78b3a3c 100644 --- a/backend/omni/src/modai/modules/user_store/sql_model_user_store.py +++ b/backend/omni/src/modai/modules/user_store/sql_model_user_store.py @@ -154,7 +154,7 @@ async def create_user( if existing_result.fetchone(): raise ValueError(f"Email '{email}' already exists") - user_id = self._generate_user_id() + user_id = additional_fields.get("id") or self._generate_user_id() now = datetime.now() insert_stmt = self.users_table.insert().values( diff --git a/backend/omni/src/modai/modules/user_store/sqlalchemy_user_store.py b/backend/omni/src/modai/modules/user_store/sqlalchemy_user_store.py index 44f365c..4e60485 100644 --- a/backend/omni/src/modai/modules/user_store/sqlalchemy_user_store.py +++ b/backend/omni/src/modai/modules/user_store/sqlalchemy_user_store.py @@ -155,7 +155,7 @@ async def create_user( if existing_user: raise ValueError(f"Email '{email}' already exists") - user_id = self._generate_user_id() + user_id = additional_fields.get("id") or self._generate_user_id() now = datetime.now() insert_stmt = self.users_table.insert().values( diff --git a/docker-compose-zitadel-develop.yaml b/docker-compose-zitadel-develop.yaml new file mode 100644 index 0000000..30484b9 --- /dev/null +++ b/docker-compose-zitadel-develop.yaml @@ -0,0 +1,101 @@ +--- +# Zitadel Development Docker Compose +# +# Usage: +# (docker|podman) compose -f docker-compose-zitadel-develop.yaml up -d --wait +# +# After startup: +# 1. Run ./auth/setup-zitadel-dev.sh to auto-provision the OIDC application +# 2. The script outputs the Client ID to put in your frontend .env +# +# Zitadel Console: http://localhost:8080/ui/console +# Default admin: zitadel-admin@zitadel.localhost / Password1! + +services: + zitadel: + image: ghcr.io/zitadel/zitadel:latest + command: start-from-init --masterkey "MasterkeyNeedsToHave32Characters" + environment: + ZITADEL_EXTERNALDOMAIN: localhost + ZITADEL_EXTERNALSECURE: false + ZITADEL_EXTERNALPORT: 8080 + ZITADEL_TLS_ENABLED: false + + # Database + ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db + ZITADEL_DATABASE_POSTGRES_PORT: 5432 + ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres + ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres + ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable + ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel + ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel + ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable + + # Login v2 + ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /current-dir/login-client.pat + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: false + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME: login-client + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME: Automatically Initialized IAM_LOGIN_CLIENT + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE: '2029-01-01T00:00:00Z' + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: true + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: http://localhost:3000/ui/v2/login/ + ZITADEL_OIDC_DEFAULTLOGINURLV2: http://localhost:3000/ui/v2/login/login?authRequest= + ZITADEL_OIDC_DEFAULTLOGOUTURLV2: http://localhost:3000/ui/v2/login/logout?post_logout_redirect= + ZITADEL_SAML_DEFAULTLOGINURLV2: http://localhost:3000/ui/v2/login/login?samlRequest= + + # Machine user with IAM_OWNER role for API provisioning + ZITADEL_FIRSTINSTANCE_PATPATH: /current-dir/admin.pat + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: admin + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Automatically Initialized IAM_OWNER + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: '2029-01-01T00:00:00Z' + + healthcheck: + test: ["CMD", "/app/zitadel", "ready"] + interval: 10s + timeout: 60s + retries: 5 + start_period: 10s + user: "0" + volumes: + - ./auth:/current-dir:delegated + ports: + - 8080:8080 + - 3000:3000 + depends_on: + zitadel-db: + condition: service_healthy + + zitadel-login: + image: ghcr.io/zitadel/zitadel-login:latest + environment: + - ZITADEL_API_URL=http://localhost:8080 + - NEXT_PUBLIC_BASE_PATH=/ui/v2/login + - ZITADEL_SERVICE_USER_TOKEN_FILE=/current-dir/login-client.pat + network_mode: service:zitadel + user: "0" + volumes: + - ./auth:/current-dir:ro + depends_on: + zitadel: + condition: service_healthy + restart: false + + zitadel-db: + image: postgres:17 + environment: + PGUSER: postgres + POSTGRES_PASSWORD: postgres + healthcheck: + test: ["CMD-SHELL", "pg_isready -d zitadel -U postgres"] + interval: 10s + timeout: 30s + retries: 5 + start_period: 20s + ports: + - 5432:5432 + volumes: + - zitadel-data:/var/lib/postgresql/data:rw + +volumes: + zitadel-data: diff --git a/e2e_tests/tests_omni_full/playwright.config.ts b/e2e_tests/tests_omni_full/playwright.config.ts index d477e46..ea2cd82 100644 --- a/e2e_tests/tests_omni_full/playwright.config.ts +++ b/e2e_tests/tests_omni_full/playwright.config.ts @@ -1,50 +1,68 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ - testDir: ".", - testMatch: "*.spec.ts", - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, - reporter: [ - ["html", { open: "never" }], // Generate HTML report but don't auto-open - ["list"] // Also show results in terminal - ], - use: { - baseURL: "http://localhost:4173", - trace: "on-first-retry", - }, - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - { - name: "firefox", - use: { ...devices["Desktop Firefox"] }, - }, - { - name: "webkit", - use: { ...devices["Desktop Safari"] }, - }, - ], - webServer: [ - { - command: "cd ../../frontend_omni && ln -sf modules_with_backend.json public/modules.json && pnpm build && pnpm preview", - url: "http://localhost:4173", - reuseExistingServer: !process.env.CI, - }, - { - command: "cd ../../backend/omni && rm -f *.db && uv run uvicorn modai.main:app", - url: "http://localhost:8000/api/health", - reuseExistingServer: !process.env.CI, - }, - { - command: "docker container run --rm -p 3001:8000 ghcr.io/modai-systems/llmock:latest", - url: "http://localhost:3001/health", - reuseExistingServer: !process.env.CI, - gracefulShutdown: { signal: "SIGTERM", timeout: 5000 }, - }, - ], + testDir: ".", + testMatch: "*.spec.ts", + globalSetup: "./src/global-setup.ts", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [ + ["html", { open: "never" }], + ["list"], + ], + use: { + baseURL: "http://localhost:4173", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + ], + webServer: [ + { + // Starts Zitadel via Docker Compose, provisions the OIDC app, and + // shuts the stack down gracefully when tests complete. + command: "scripts/start-zitadel.sh", + url: "http://localhost:8080/debug/healthz", + reuseExistingServer: !process.env.CI, + timeout: 180000, + gracefulShutdown: { signal: "SIGTERM", timeout: 30000 }, + }, + { + // Waits for auth/dev-app-config.json (written by start-zitadel.sh), + // reads the OIDC client ID, then builds and serves the frontend. + command: "scripts/run-frontend.sh", + url: "http://localhost:4173", + reuseExistingServer: !process.env.CI, + timeout: 300000, + }, + { + command: + "cd ../../backend/omni && rm -f *.db && uv run uvicorn modai.main:app", + url: "http://localhost:8000/api/health", + reuseExistingServer: !process.env.CI, + env: { + OIDC_ISSUER: "http://localhost:8080", + }, + }, + { + command: + "docker container run --rm -p 3001:8000 ghcr.io/modai-systems/llmock:latest", + url: "http://localhost:3001/health", + reuseExistingServer: !process.env.CI, + gracefulShutdown: { signal: "SIGTERM", timeout: 5000 }, + }, + ], }); diff --git a/e2e_tests/tests_omni_full/scripts/run-frontend.sh b/e2e_tests/tests_omni_full/scripts/run-frontend.sh new file mode 100755 index 0000000..be7b60a --- /dev/null +++ b/e2e_tests/tests_omni_full/scripts/run-frontend.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Builds and starts the Vite preview server for e2e tests. +# +# Waits for auth/dev-app-config.json written by scripts/start-zitadel.sh so +# that the OIDC client ID is available before building the frontend. +# +# Lifecycle managed by Playwright's webServer config: +# - url: http://localhost:4173 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +FRONTEND_DIR="$ROOT_DIR/frontend_omni" +AUTH_CONFIG="$ROOT_DIR/auth/dev-app-config.json" + +# Wait for the Zitadel provisioning script to write the auth config (up to 2 min). +echo "[frontend-e2e] Waiting for $AUTH_CONFIG..." +retries=0 +while [ ! -f "$AUTH_CONFIG" ] && [ "$retries" -lt 120 ]; do + sleep 1 + retries=$((retries + 1)) +done + +if [ ! -f "$AUTH_CONFIG" ]; then + echo "[frontend-e2e] ERROR: $AUTH_CONFIG not available after 120 s" + exit 1 +fi + +CLIENT_ID=$(node -e "process.stdout.write(JSON.parse(require('fs').readFileSync('$AUTH_CONFIG','utf8')).frontend.client_id)") +echo "[frontend-e2e] Using OIDC client ID: $CLIENT_ID" + +cd "$FRONTEND_DIR" +ln -sf modules_with_backend.json public/modules.json + +export VITE_OIDC_AUTHORITY="http://localhost:8080" +export VITE_OIDC_CLIENT_ID="$CLIENT_ID" + +pnpm build +exec pnpm preview diff --git a/e2e_tests/tests_omni_full/scripts/start-zitadel.sh b/e2e_tests/tests_omni_full/scripts/start-zitadel.sh new file mode 100755 index 0000000..11ef0d9 --- /dev/null +++ b/e2e_tests/tests_omni_full/scripts/start-zitadel.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Starts Zitadel (and its dependencies) for e2e tests via Docker Compose. +# Shuts down the stack gracefully when this process is terminated. +# +# Lifecycle managed by Playwright's webServer config: +# - url: http://localhost:8080/debug/healthz +# - gracefulShutdown: { signal: "SIGTERM", timeout: 30000 } + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +COMPOSE_FILE="$ROOT_DIR/docker-compose-zitadel-develop.yaml" +AUTH_CONFIG="$ROOT_DIR/auth/dev-app-config.json" + +cleanup() { + echo "[zitadel-e2e] Stopping Zitadel stack..." + docker compose -f "$COMPOSE_FILE" down 2>/dev/null || true + echo "[zitadel-e2e] Done." +} +trap cleanup EXIT + +cd "$ROOT_DIR" + +echo "[zitadel-e2e] Starting Zitadel stack..." +docker compose -f "$COMPOSE_FILE" up -d --wait + +# Run provisioning only when the config file is absent (first run or fresh volume). +if [ ! -f "$AUTH_CONFIG" ]; then + echo "[zitadel-e2e] Provisioning Zitadel OIDC application..." + "$ROOT_DIR/auth/setup-zitadel-dev.sh" +fi + +echo "[zitadel-e2e] Zitadel is ready." + +# Block until Playwright sends SIGTERM (gracefulShutdown). +tail -f /dev/null & +BLOCKER=$! +wait "$BLOCKER" || true diff --git a/e2e_tests/tests_omni_full/src/app-loading.spec.ts b/e2e_tests/tests_omni_full/src/app-loading.spec.ts index 6dda282..a585a62 100644 --- a/e2e_tests/tests_omni_full/src/app-loading.spec.ts +++ b/e2e_tests/tests_omni_full/src/app-loading.spec.ts @@ -4,19 +4,23 @@ test.describe("App loading", () => { test("User opens the app", async ({ page }) => { await page.goto("/"); - // Check that the page title contains "modAI" + // The page should have the modAI title (may show briefly before redirect) await expect(page).toHaveTitle(/modAI/); }); - test.describe("User gets redirected to /login as fallback if not logged in", () => { - const testPaths = ["/", "/login", "/foo"]; + test.describe("Unauthenticated user gets redirected to Zitadel login", () => { + const testPaths = ["/", "/chat", "/foo"]; for (const path of testPaths) { - test(`should redirect ${path} to /login`, async ({ page }) => { + test(`should redirect ${path} to Zitadel login`, async ({ + page, + }) => { await page.goto(path); - // Wait for navigation to complete and check the URL - await expect(page).toHaveURL("/login"); + // The app redirects to Zitadel's login UI (on localhost:3000) + await page.waitForURL(/localhost:3000.*login/, { + timeout: 15000, + }); }); } }); diff --git a/e2e_tests/tests_omni_full/src/chat.spec.ts b/e2e_tests/tests_omni_full/src/chat.spec.ts index 461f44f..e1a0c04 100644 --- a/e2e_tests/tests_omni_full/src/chat.spec.ts +++ b/e2e_tests/tests_omni_full/src/chat.spec.ts @@ -1,35 +1,12 @@ import { test } from "@playwright/test"; -import { ChatPage, LLMProvidersPage, LoginPage, SignupPage } from "./pages"; +import { TEST_USER_EMAIL, TEST_USER_PASSWORD } from "./global-setup"; +import { ChatPage, LLMProvidersPage, ZitadelLoginPage } from "./pages"; test.describe("Chat", () => { - test.beforeAll(async ({ browser }) => { - const page = await browser.newPage(); - const signupPage = new SignupPage(page); - - try { - await signupPage.goto(); - await signupPage.signupUser( - "admin@example.com", - "admin", - "Administrator", - ); - console.log("Test user signup initiated"); - } catch (error) { - console.warn("Could not signup test user:", error); - } finally { - await page.close(); - } - }); - test.beforeEach(async ({ page }) => { - await page.goto("/"); - await page.evaluate(() => { - localStorage.clear(); - }); - - const loginPage = new LoginPage(page); - await loginPage.goto(); - await loginPage.login("admin@example.com", "admin"); + // Login via Zitadel (this navigates to / first, triggering the OIDC flow) + const loginPage = new ZitadelLoginPage(page); + await loginPage.login(TEST_USER_EMAIL, TEST_USER_PASSWORD); }); test("should send a message and receive a response", async ({ page }) => { diff --git a/e2e_tests/tests_omni_full/src/global-setup.ts b/e2e_tests/tests_omni_full/src/global-setup.ts new file mode 100644 index 0000000..9d420fe --- /dev/null +++ b/e2e_tests/tests_omni_full/src/global-setup.ts @@ -0,0 +1,27 @@ +/** + * Playwright global setup. + * + * Runs once before all tests to: + * 1. Create a test user in Zitadel via the Management API + */ + +import { createTestUser } from "./zitadel-helpers"; + +export const TEST_USER_EMAIL = "e2e-test@example.com"; +export const TEST_USER_PASSWORD = "E2eTestPassword1!"; + +async function globalSetup() { + console.log("=== E2E Global Setup ==="); + + // Create the test user in Zitadel (idempotent - ignores 409 conflict) + await createTestUser( + TEST_USER_EMAIL, + TEST_USER_PASSWORD, + "E2E", + "TestUser", + ); + + console.log("=== E2E Global Setup Complete ==="); +} + +export default globalSetup; diff --git a/e2e_tests/tests_omni_full/src/pages.ts b/e2e_tests/tests_omni_full/src/pages.ts index 254279a..8f2e216 100644 --- a/e2e_tests/tests_omni_full/src/pages.ts +++ b/e2e_tests/tests_omni_full/src/pages.ts @@ -30,7 +30,6 @@ export class LLMProvidersPage { } async assertSuccessfulAddedToast(): Promise { - // waitForSelector also works if there are multiple toasts await this.page.waitForSelector('text="Provider created successfully"'); } @@ -59,8 +58,7 @@ export class LLMProvidersPage { await this.page.getByText("Save", exact).click(); } - async deleteProvider(providerName: string, confirm: boolean = true) { - // Find the provider card and click the delete button within it + async deleteProvider(providerName: string, confirm = true) { const providerCard = this.page .locator("div") .filter({ hasText: providerName }) @@ -89,38 +87,54 @@ export class LLMProvidersPage { } } -export class LoginPage { +/** + * Page object for the Zitadel Login v2 page. + * + * Automates the OIDC redirect login flow: + * 1. Navigate to the app (triggers redirect to Zitadel) + * 2. Enter login name (email) + * 3. Enter password + * 4. Complete login (redirects back to app) + */ +export class ZitadelLoginPage { constructor(private page: Page) {} - async goto() { - await this.page.goto("/login"); - } - + /** + * Performs a full login flow through Zitadel's login UI. + * + * Navigates to the app root which triggers the OIDC redirect to Zitadel, + * fills in the login form, and waits for the redirect back to the app. + */ async login(email: string, password: string) { - await this.page.getByLabel("Email", exact).fill(email); - await this.page.getByLabel("Password", exact).fill(password); - await this.page.getByRole("button", { name: "Login" }).click(); - // After login, user is redirected to /chat or / depending on the app configuration - await expect(this.page).not.toHaveURL("/login"); - } -} - -export class SignupPage { - constructor(private page: Page) {} - - async goto() { - await this.page.goto("/register"); - } - - async signupUser(email: string, password: string, fullName?: string) { - if (fullName) { - await this.page - .getByLabel("Full Name (Optional)", exact) - .fill(fullName); - } - await this.page.getByLabel("Email", exact).fill(email); - await this.page.getByLabel("Password", exact).fill(password); - await this.page.getByRole("button", { name: "Create Account" }).click(); + // Navigate to app - this triggers redirect to Zitadel login + await this.page.goto("/"); + + // Wait for Zitadel login page to load (it's on localhost:3000) + await this.page.waitForURL(/localhost:3000.*login/, { + timeout: 30000, + }); + + // Step 1: Enter login name (email) + // Zitadel login v2 renders desktop + mobile views; use role selector + const loginNameInput = this.page.getByRole("textbox", { + name: /loginname/i, + }); + await loginNameInput.waitFor({ state: "visible", timeout: 15000 }); + await loginNameInput.fill(email); + + // Click the "Continue" button to submit the login name + await this.page.getByRole("button", { name: "Continue" }).click(); + + // Step 2: Enter password + const passwordInput = this.page.locator('input[type="password"]'); + await passwordInput + .first() + .waitFor({ state: "visible", timeout: 15000 }); + await passwordInput.first().fill(password); + await this.page.getByRole("button", { name: "Continue" }).click(); + + // Wait for redirect back to the app (callback -> /) + await this.page.waitForURL(/localhost:4173/, { timeout: 30000 }); } } @@ -148,7 +162,6 @@ export class ChatPage { await selectTrigger.click(); const options = this.page.locator('[role="option"]'); const count = await options.count(); - // Close the dropdown by pressing Escape await this.page.keyboard.press("Escape"); return count; } @@ -177,7 +190,6 @@ export class ChatPage { } async waitForResponse(): Promise { - // Wait for the assistant message to appear await this.page.waitForSelector(".is-assistant", { timeout: 10000 }); } diff --git a/e2e_tests/tests_omni_full/src/zitadel-helpers.ts b/e2e_tests/tests_omni_full/src/zitadel-helpers.ts new file mode 100644 index 0000000..c953420 --- /dev/null +++ b/e2e_tests/tests_omni_full/src/zitadel-helpers.ts @@ -0,0 +1,86 @@ +/** + * Zitadel E2E test helpers. + * + * Provides utilities for: + * - Creating test users via the Zitadel Management API + * - Reading the dev-app-config.json for OIDC client details + * - Cleaning up test users after tests + */ + +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +interface ZitadelAppConfig { + zitadel_url: string; + project_id: string; + frontend: { app_id: string; client_id: string }; + backend: { + app_id: string; + client_id: string; + client_secret: string; + }; +} + +const CURRENT_DIR = dirname(fileURLToPath(import.meta.url)); +const AUTH_DIR = resolve(CURRENT_DIR, "../../../auth"); + +export function getZitadelConfig(): ZitadelAppConfig { + const configPath = resolve(AUTH_DIR, "dev-app-config.json"); + return JSON.parse(readFileSync(configPath, "utf-8")); +} + +export function getAdminPAT(): string { + const patPath = resolve(AUTH_DIR, "admin.pat"); + return readFileSync(patPath, "utf-8").trim(); +} + +/** + * Creates a human user in Zitadel via the v2 User API. + * Uses the v2 endpoint which properly sets the password for login v2. + * If the user already exists (409 conflict), it is silently ignored. + */ +export async function createTestUser( + email: string, + password: string, + firstName = "E2E", + lastName = "TestUser", +): Promise { + const config = getZitadelConfig(); + const pat = getAdminPAT(); + + const response = await fetch(`${config.zitadel_url}/v2/users/human`, { + method: "POST", + headers: { + Authorization: `Bearer ${pat}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: email, + profile: { + givenName: firstName, + familyName: lastName, + displayName: `${firstName} ${lastName}`, + }, + email: { + email, + isVerified: true, + }, + password: { + password, + changeRequired: false, + }, + }), + }); + + if (response.ok) { + console.log(`Created Zitadel test user: ${email}`); + } else if (response.status === 409) { + console.log(`Zitadel test user already exists: ${email}`); + } else { + const body = await response.text(); + throw new Error( + `Failed to create Zitadel user ${email}: ${response.status} ${body}`, + ); + } +} diff --git a/frontend_omni/.env.sample b/frontend_omni/.env.sample new file mode 100644 index 0000000..78e4f75 --- /dev/null +++ b/frontend_omni/.env.sample @@ -0,0 +1,8 @@ +# OIDC Configuration for local development with Zitadel +# Run ./auth/setup-zitadel-dev.sh to get the Client ID + +VITE_OIDC_AUTHORITY=http://localhost:8080 +VITE_OIDC_CLIENT_ID= +# Optional - defaults to ${window.location.origin}/auth/callback +# VITE_OIDC_REDIRECT_URI=http://localhost:5173/auth/callback +# VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5173/ diff --git a/frontend_omni/docs/architecture/core.md b/frontend_omni/docs/architecture/core.md index 30471e2..19c3e5b 100644 --- a/frontend_omni/docs/architecture/core.md +++ b/frontend_omni/docs/architecture/core.md @@ -193,8 +193,9 @@ Template for the documentation ````markdown # Authentication Service -Provides authentication backend communication for user management, including login, signup, and logout operations. -No UI components availabe in this module group. +Provides OIDC-based authentication via `oidc-client-ts`. Handles login (redirect to IDP), +callback (PKCE code exchange), logout, and token management. +No UI components available in this module group. ## Intended Usage @@ -202,15 +203,15 @@ No UI components availabe in this module group. Example: -Other modules can access authentication functionality through the `useAuthService` hook to perform user authentication operations. +Other modules can access authentication functionality through the `useAuthService` hook or `useAuthenticatedFetch` for API calls with Bearer tokens. ```jsx -import { useAuthService } from "@/modules/authentication-service/AuthContextProvider"; +import { useAuthService } from "@/modules/authentication-service"; -function LoginComponent() { +function LogoutButton() { const authService = useAuthService(); ... - const response = await authService.login({ email, password }) + await authService.logout(); // Redirects to IDP logout ... } ``` diff --git a/frontend_omni/package.json b/frontend_omni/package.json index 709d7be..7a56489 100644 --- a/frontend_omni/package.json +++ b/frontend_omni/package.json @@ -43,6 +43,7 @@ "motion": "^12.23.26", "nanoid": "^5.1.6", "next-themes": "^0.4.6", + "oidc-client-ts": "^3.4.1", "openai": "^6.15.0", "react": "^19.2.3", "react-dom": "^19.2.3", diff --git a/frontend_omni/pnpm-lock.yaml b/frontend_omni/pnpm-lock.yaml index 1becc2c..2ff4a8b 100644 --- a/frontend_omni/pnpm-lock.yaml +++ b/frontend_omni/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + oidc-client-ts: + specifier: ^3.4.1 + version: 3.4.1 openai: specifier: ^6.15.0 version: 6.15.0(ws@8.18.3)(zod@4.1.12) @@ -2095,6 +2098,10 @@ packages: engines: {node: '>=6'} hasBin: true + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + katex@0.16.27: resolution: {integrity: sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==} hasBin: true @@ -2451,6 +2458,10 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + oidc-client-ts@3.4.1: + resolution: {integrity: sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==} + engines: {node: '>=18'} + oniguruma-parser@0.12.1: resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} @@ -5092,6 +5103,8 @@ snapshots: json5@2.2.3: {} + jwt-decode@4.0.0: {} + katex@0.16.27: dependencies: commander: 8.3.0 @@ -5657,6 +5670,10 @@ snapshots: obug@2.1.1: {} + oidc-client-ts@3.4.1: + dependencies: + jwt-decode: 4.0.0 + oniguruma-parser@0.12.1: {} oniguruma-to-es@4.3.4: diff --git a/frontend_omni/public/modules_with_backend.json b/frontend_omni/public/modules_with_backend.json index eb11380..67dd948 100644 --- a/frontend_omni/public/modules_with_backend.json +++ b/frontend_omni/public/modules_with_backend.json @@ -10,7 +10,7 @@ "id": "authentication-router", "type": "RouterEntry", "path": "@/modules/authentication/createAuthRouterEntry", - "dependencies": ["module:authentication-service", "flag:!sessionActive"] + "dependencies": ["module:authentication-service"] }, { "id": "authentication-sidebar-footer", @@ -22,18 +22,19 @@ "id": "authentication-fallback-router", "type": "FallbackRouterEntry", "path": "@/modules/authentication/createAuthFallbackRouterEntry", - "dependencies": ["flag:!sessionActive"] + "dependencies": ["module:authentication-service", "flag:!sessionActive"] }, { "id": "session", "type": "GlobalContextProvider", - "path": "@/modules/session-provider/SessionContextProvider" + "path": "@/modules/session-provider/SessionContextProvider", + "dependencies": ["module:authentication-service"] }, { "id": "user-service", "type": "GlobalContextProvider", "path": "@/modules/user-service/UserServiceContextProvider", - "dependencies": ["module:session"] + "dependencies": ["module:authentication-service", "module:session"] }, { "id": "chat-layout-sidebar", diff --git a/frontend_omni/src/modules/authentication-service/AuthContextProvider.tsx b/frontend_omni/src/modules/authentication-service/AuthContextProvider.tsx index 70e18a9..d23fe13 100644 --- a/frontend_omni/src/modules/authentication-service/AuthContextProvider.tsx +++ b/frontend_omni/src/modules/authentication-service/AuthContextProvider.tsx @@ -1,28 +1,69 @@ /** * Authentication Service Context Provider * - * Provides the AuthenticationService instance to the entire application - * via React Context. + * Provides the OIDC AuthenticationService instance and an authenticated fetch + * function to the entire application via React Context. */ import type React from "react"; +import { useMemo } from "react"; import { AuthServiceContext } from "."; -import { AuthenticationService } from "./AuthenticationService"; +import { OIDCAuthenticationService } from "./AuthenticationService"; +import { + AuthenticatedFetchContext, + createAuthenticatedFetch, +} from "./authenticatedFetch"; /** - * Context provider that makes the authentication service available - * throughout the application component tree + * Context provider that makes the OIDC authentication service and + * authenticated fetch available throughout the application component tree. + * + * Required Vite environment variables: + * - VITE_OIDC_AUTHORITY: The OIDC provider URL (e.g., http://localhost:8080) + * - VITE_OIDC_CLIENT_ID: The OIDC client ID + * - VITE_OIDC_REDIRECT_URI: Where to redirect after login (e.g., http://localhost:5173/auth/callback) + * - VITE_OIDC_POST_LOGOUT_REDIRECT_URI: Where to redirect after logout (e.g., http://localhost:5173/) */ export function AuthContextProvider({ children, }: { children: React.ReactNode; }) { - const authServiceInstance = new AuthenticationService(); + const authService = useMemo(() => { + const authority = import.meta.env.VITE_OIDC_AUTHORITY; + const clientId = import.meta.env.VITE_OIDC_CLIENT_ID; + + if (!authority || !clientId) { + console.error( + "OIDC configuration missing. Set VITE_OIDC_AUTHORITY and VITE_OIDC_CLIENT_ID environment variables.", + ); + } + + const redirectUri = + import.meta.env.VITE_OIDC_REDIRECT_URI || + `${window.location.origin}/auth/callback`; + const postLogoutRedirectUri = + import.meta.env.VITE_OIDC_POST_LOGOUT_REDIRECT_URI || + `${window.location.origin}/`; + + return new OIDCAuthenticationService({ + authority, + clientId, + redirectUri, + postLogoutRedirectUri, + }); + }, []); + + const authenticatedFetch = useMemo( + () => createAuthenticatedFetch(authService), + [authService], + ); return ( - - {children} + + + {children} + ); } diff --git a/frontend_omni/src/modules/authentication-service/AuthenticationService.ts b/frontend_omni/src/modules/authentication-service/AuthenticationService.ts index c524d4e..f824cfc 100644 --- a/frontend_omni/src/modules/authentication-service/AuthenticationService.ts +++ b/frontend_omni/src/modules/authentication-service/AuthenticationService.ts @@ -1,110 +1,64 @@ /** - * Authentication Service Implementation + * OIDC Authentication Service Implementation * - * Provides concrete implementation of the AuthService interface for handling - * user authentication operations including login, signup, and logout. + * Uses oidc-client-ts to handle OIDC authentication flows including + * login (redirect to IDP), callback (PKCE code exchange), and logout. + * Provider-agnostic — works with any OIDC-compliant identity provider. */ -import type { - AuthError, - AuthService, - LoginRequest, - LoginResponse, - SignupRequest, - SignupResponse, -} from "."; +import { type User, UserManager, WebStorageStateStore } from "oidc-client-ts"; +import type { AuthService } from "."; -export class AuthenticationService implements AuthService { - /** - * Authenticates a user with email and password - * - * @param credentials User login credentials - * @returns Promise Success message from backend - * @throws Error if login fails - */ - async login(credentials: LoginRequest): Promise { - const response = await fetch("/api/auth/login", { - method: "POST", - credentials: "include", // Include cookies for session authentication - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(credentials), - }); - - if (!response.ok) { - await this.handleApiError(response, "Login failed"); - } +/** + * OIDC configuration. These values should come from environment variables + * or application configuration. + */ +export interface OIDCConfig { + authority: string; + clientId: string; + redirectUri: string; + postLogoutRedirectUri: string; + scope?: string; +} - const result: LoginResponse = await response.json(); - return result; - } +export class OIDCAuthenticationService implements AuthService { + private userManager: UserManager; - /** - * Registers a new user account - * - * @param credentials User signup credentials - * @returns Promise Success message and user ID from backend - * @throws Error if signup fails - */ - async signup(credentials: SignupRequest): Promise { - const response = await fetch("/api/auth/signup", { - method: "POST", - credentials: "include", // Include cookies for session authentication - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(credentials), + constructor(config: OIDCConfig) { + this.userManager = new UserManager({ + authority: config.authority, + client_id: config.clientId, + redirect_uri: config.redirectUri, + post_logout_redirect_uri: config.postLogoutRedirectUri, + scope: config.scope ?? "openid profile email", + response_type: "code", + userStore: new WebStorageStateStore({ store: window.localStorage }), + automaticSilentRenew: true, }); - - if (!response.ok) { - await this.handleApiError(response, "Signup failed"); - } - - const result: SignupResponse = await response.json(); - return result; } - /** - * Logs out the current user - * - * @returns Promise Success message from backend - * @throws Error if logout fails - */ - async logout(): Promise { - const response = await fetch("/api/auth/logout", { - method: "POST", - credentials: "include", // Include cookies for session authentication - headers: { - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - await this.handleApiError(response, "Logout failed"); - } + async login(): Promise { + await this.userManager.signinRedirect(); + } - const result: LoginResponse = await response.json(); - return result; + async handleCallback(): Promise { + return await this.userManager.signinRedirectCallback(); } - /** - * Handles errors from authentication API responses - */ - private async handleApiError( - response: Response, - defaultMessage: string, - ): Promise { - let errorMessage = defaultMessage; + async logout(): Promise { + await this.userManager.signoutRedirect(); + } - try { - const errorData: AuthError = await response.json(); - errorMessage = errorData.detail || errorMessage; - } catch { - // If we can't parse the error response, use status text - errorMessage = response.statusText || errorMessage; + async getUser(): Promise { + const user = await this.userManager.getUser(); + if (user?.expired) { + return null; } + return user; + } - throw new Error(errorMessage); + async getAccessToken(): Promise { + const user = await this.getUser(); + return user?.access_token ?? null; } } diff --git a/frontend_omni/src/modules/authentication-service/authenticatedFetch.ts b/frontend_omni/src/modules/authentication-service/authenticatedFetch.ts new file mode 100644 index 0000000..9b4e0e9 --- /dev/null +++ b/frontend_omni/src/modules/authentication-service/authenticatedFetch.ts @@ -0,0 +1,53 @@ +/** + * Authenticated Fetch Utility + * + * Creates a fetch wrapper that automatically adds the Bearer token + * from the OIDC auth service to all requests. + */ + +import type { AuthService } from "."; + +/** + * Creates a fetch function that automatically includes the Bearer token. + * + * @param authService The auth service to get the access token from + * @returns A fetch function with the same signature as window.fetch + */ +export function createAuthenticatedFetch( + authService: AuthService, +): typeof fetch { + return async (input: RequestInfo | URL, init?: RequestInit) => { + const token = await authService.getAccessToken(); + + const headers = new Headers(init?.headers); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + + return fetch(input, { + ...init, + headers, + }); + }; +} + +// Context for the authenticated fetch function +import { createContext, useContext } from "react"; + +export const AuthenticatedFetchContext = createContext< + typeof fetch | undefined +>(undefined); + +/** + * Hook to access the authenticated fetch function from any component. + * This fetch function automatically includes the Bearer token. + */ +export function useAuthenticatedFetch(): typeof fetch { + const context = useContext(AuthenticatedFetchContext); + if (!context) { + throw new Error( + "useAuthenticatedFetch must be used within an AuthenticatedFetchProvider", + ); + } + return context; +} diff --git a/frontend_omni/src/modules/authentication-service/index.ts b/frontend_omni/src/modules/authentication-service/index.ts index 669cfb6..8adf85c 100644 --- a/frontend_omni/src/modules/authentication-service/index.ts +++ b/frontend_omni/src/modules/authentication-service/index.ts @@ -1,57 +1,41 @@ +import type { User } from "oidc-client-ts"; import { createContext, useContext } from "react"; -// Authentication Request/Response Types -export interface LoginRequest { - email: string; - password: string; -} - -export interface SignupRequest { - email: string; - password: string; - full_name?: string; -} - -export interface LoginResponse { - message: string; -} - -export interface SignupResponse { - message: string; - user_id: string; -} - -export interface AuthError { - detail: string; -} - -// Authentication Service Interface +/** + * Authentication Service Interface + * + * Provides OIDC-based authentication operations. The actual authentication + * is handled by the OIDC provider (e.g., Zitadel) via browser redirects. + */ export interface AuthService { /** - * Authenticates a user with email and password - * - * @param credentials User login credentials - * @returns Promise Success message from backend - * @throws Error if login fails + * Initiates the OIDC login flow by redirecting to the identity provider. */ - login(credentials: LoginRequest): Promise; + login(): Promise; /** - * Registers a new user account + * Handles the OIDC callback after the identity provider redirects back. + * Completes the PKCE code exchange and stores the tokens. * - * @param credentials User signup credentials - * @returns Promise Success message and user ID from backend - * @throws Error if signup fails + * @returns The OIDC User object containing tokens and user info */ - signup(credentials: SignupRequest): Promise; + handleCallback(): Promise; /** - * Logs out the current user - * - * @returns Promise Success message from backend - * @throws Error if logout fails + * Logs out the current user by redirecting to the identity provider's + * end_session_endpoint. + */ + logout(): Promise; + + /** + * Returns the current OIDC user (with tokens) if authenticated, null otherwise. + */ + getUser(): Promise; + + /** + * Returns the current access token, or null if not authenticated. */ - logout(): Promise; + getAccessToken(): Promise; } // Create context for the authentication service diff --git a/frontend_omni/src/modules/authentication/AuthRouterEntry.tsx b/frontend_omni/src/modules/authentication/AuthRouterEntry.tsx index 8c4eddc..8ab1d46 100644 --- a/frontend_omni/src/modules/authentication/AuthRouterEntry.tsx +++ b/frontend_omni/src/modules/authentication/AuthRouterEntry.tsx @@ -1,17 +1,12 @@ import type { RouteObject } from "react-router-dom"; import type { Modules } from "@/modules/module-system"; -import { LoginPage } from "./LoginPage"; -import RegisterPage from "./RegisterPage"; +import { OIDCCallbackPage } from "./OIDCCallbackPage"; export function createAuthRouterEntry(_modules: Modules): RouteObject[] { return [ { - path: "/login", - element: , - }, - { - path: "/register", - element: , + path: "/auth/callback", + element: , }, ]; } diff --git a/frontend_omni/src/modules/authentication/AuthSidebarFooterItem.tsx b/frontend_omni/src/modules/authentication/AuthSidebarFooterItem.tsx index b9784cb..7bd5090 100644 --- a/frontend_omni/src/modules/authentication/AuthSidebarFooterItem.tsx +++ b/frontend_omni/src/modules/authentication/AuthSidebarFooterItem.tsx @@ -1,6 +1,5 @@ -import { LogIn, LogOut, UserPlus } from "lucide-react"; +import { LogIn, LogOut } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { Link, useLocation, useNavigate } from "react-router-dom"; import { useAuthService } from "@/modules/authentication-service"; import { useSession } from "@/modules/session-provider"; import { @@ -8,31 +7,29 @@ import { SidebarMenuItem, } from "@/shadcn/components/ui/sidebar"; -const LOGIN_PATH = "/login"; -const REGISTER_PATH = "/register"; - interface LogoutButtonProps { className?: string; } export function AuthSidebarFooterItem({ className }: LogoutButtonProps) { - const navigate = useNavigate(); - const location = useLocation(); const { clearSession, session } = useSession(); const authService = useAuthService(); const { t } = useTranslation("authentication"); const handleLogout = async () => { try { - await authService.logout(); - // Clear the session after successful logout clearSession(); - navigate(LOGIN_PATH); + await authService.logout(); } catch (error) { console.error("Logout failed:", error); - // Still clear session even if logout fails - clearSession(); - navigate(LOGIN_PATH); + } + }; + + const handleLogin = async () => { + try { + await authService.login(); + } catch (error) { + console.error("Login redirect failed:", error); } }; @@ -40,40 +37,20 @@ export function AuthSidebarFooterItem({ className }: LogoutButtonProps) { <> - diff --git a/frontend_omni/src/modules/authentication/FallbackRouterEntry.tsx b/frontend_omni/src/modules/authentication/FallbackRouterEntry.tsx index 6bc8f11..4ebe193 100644 --- a/frontend_omni/src/modules/authentication/FallbackRouterEntry.tsx +++ b/frontend_omni/src/modules/authentication/FallbackRouterEntry.tsx @@ -1,9 +1,33 @@ -import { Navigate, type RouteObject } from "react-router-dom"; +import { useEffect } from "react"; +import type { RouteObject } from "react-router-dom"; +import { useAuthService } from "@/modules/authentication-service"; import type { Modules } from "@/modules/module-system"; +/** + * When there is no active session, redirect the user to the OIDC + * provider's login page via the auth service. + */ +function RedirectToLogin() { + const authService = useAuthService(); + + useEffect(() => { + authService.login().catch((err) => { + console.error("OIDC login redirect failed:", err); + }); + }, [authService]); + + return ( +
+
+

Redirecting to login...

+
+
+ ); +} + export function AuthFallbackRouterEntry(_modules: Modules): RouteObject { return { path: "*", - element: , + element: , }; } diff --git a/frontend_omni/src/modules/authentication/LoginPage.tsx b/frontend_omni/src/modules/authentication/LoginPage.tsx deleted file mode 100644 index 156889d..0000000 --- a/frontend_omni/src/modules/authentication/LoginPage.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { useAuthService } from "@/modules/authentication-service"; -import { useSession } from "@/modules/session-provider"; -import { LoginRegisterForm } from "./LoginRegisterForm"; - -interface LoginProps { - enableForgetPassword?: boolean; -} - -export function LoginPage() { - return ( -
-
- -
-
- ); -} - -function Login({ enableForgetPassword = true }: LoginProps) { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const navigate = useNavigate(); - const { refreshSession } = useSession(); - const authService = useAuthService(); - - const handleSubmit = async ({ - email, - password, - }: { - email: string; - password: string; - fullName: string; - }) => { - setIsLoading(true); - setError(null); - - try { - await authService.login({ email, password }); - await refreshSession(); - navigate("/"); - } catch (err) { - setError(err instanceof Error ? err.message : "Login failed"); - } finally { - setIsLoading(false); - } - }; - - return ( - - - - - - - - - - - - - ); -} diff --git a/frontend_omni/src/modules/authentication/LoginRegisterForm.tsx b/frontend_omni/src/modules/authentication/LoginRegisterForm.tsx deleted file mode 100644 index e7c06e6..0000000 --- a/frontend_omni/src/modules/authentication/LoginRegisterForm.tsx +++ /dev/null @@ -1,341 +0,0 @@ -import { createContext, useContext, useId, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Link } from "react-router-dom"; -import { Button } from "@/shadcn/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/shadcn/components/ui/card"; -import { Input } from "@/shadcn/components/ui/input"; -import { Label } from "@/shadcn/components/ui/label"; -import { cn } from "@/shadcn/lib/utils"; - -// Context for shared form state and handlers -interface FormContextType { - email: string; - setEmail: (email: string) => void; - password: string; - setPassword: (password: string) => void; - fullName: string; - setFullName: (fullName: string) => void; - isLoading: boolean; - error: string | null; - onSubmit: (e: React.FormEvent) => void; -} - -const FormContext = createContext(undefined); - -function useFormContext() { - const context = useContext(FormContext); - if (!context) { - throw new Error( - "LoginRegisterForm subcomponents must be used within LoginRegisterForm.Provider", - ); - } - return context; -} - -// Main compound component -export function LoginRegisterForm({ - children, - className, - ...props -}: React.ComponentPropsWithoutRef<"div">) { - const { onSubmit } = useFormContext(); - - return ( -
- -
{children}
-
-
- ); -} - -// Provider for form state -function Provider({ - children, - onSubmit, - isLoading = false, - error = null, -}: { - children: React.ReactNode; - onSubmit: (formData: { - email: string; - password: string; - fullName: string; - }) => Promise; - isLoading?: boolean; - error?: string | null; -}) { - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [fullName, setFullName] = useState(""); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - await onSubmit({ email, password, fullName }); - }; - - const value: FormContextType = { - email, - setEmail, - password, - setPassword, - fullName, - setFullName, - isLoading, - error, - onSubmit: handleSubmit, - }; - - return {children}; -} - -// Header components -function LoginHeader() { - const { t } = useTranslation("authentication"); - - return ( - - - {t("loginTitle", { defaultValue: "Login" })} - - - {t("loginDescription", { - defaultValue: - "Enter your email below to login to your account", - })} - - - ); -} - -function RegisterHeader() { - const { t } = useTranslation("authentication"); - - return ( - - - {t("registerTitle", { defaultValue: "Sign Up" })} - - - {t("registerDescription", { - defaultValue: "Create a new account to get started", - })} - - - ); -} - -// Input components -function Email() { - const { email, setEmail, isLoading } = useFormContext(); - const { t } = useTranslation("authentication"); - const emailId = useId(); - - return ( -
- - setEmail(e.target.value)} - disabled={isLoading} - required - /> -
- ); -} - -function Password({ variant = "login" }: { variant?: "login" | "register" }) { - const { password, setPassword, isLoading } = useFormContext(); - const { t } = useTranslation("authentication"); - const passwordId = useId(); - - return ( -
- - setPassword(e.target.value)} - disabled={isLoading} - required - /> -
- ); -} - -function FullName() { - const { fullName, setFullName, isLoading } = useFormContext(); - const { t } = useTranslation("authentication"); - const fullNameId = useId(); - - return ( -
- - setFullName(e.target.value)} - disabled={isLoading} - /> -
- ); -} - -// Button components -function LoginButton() { - const { isLoading } = useFormContext(); - const { t } = useTranslation("authentication"); - - return ( - - ); -} - -function CreateAccountButton() { - const { isLoading } = useFormContext(); - const { t } = useTranslation("authentication"); - - return ( - - ); -} - -// Utility components -function ErrorMessage() { - const { error } = useFormContext(); - - if (!error) return null; - - return ( -
- {error} -
- ); -} - -function ForgotPasswordLink() { - const { t } = useTranslation("authentication"); - - return ( - - ); -} - -function LoginHint() { - const { t } = useTranslation("authentication"); - - return ( -
- {t("noAccount", { defaultValue: "Don't have an account?" })}{" "} - - {t("signUpLink", { defaultValue: "Sign up" })} - -
- ); -} - -function RegisterHint() { - const { t } = useTranslation("authentication"); - - return ( -
- {t("hasAccount", { defaultValue: "Already have an account?" })}{" "} - - {t("signInLink", { defaultValue: "Sign in" })} - -
- ); -} - -// Form content wrapper -function Content({ children }: { children: React.ReactNode }) { - return ( - -
{children}
-
- ); -} - -// Password field wrapper for login (includes forgot password link) -function PasswordWithForgot({ - enableForgetPassword = true, -}: { - enableForgetPassword?: boolean; -}) { - const { password, setPassword, isLoading } = useFormContext(); - const passwordId = useId(); - - return ( -
-
- - {enableForgetPassword && } -
- setPassword(e.target.value)} - disabled={isLoading} - required - /> -
- ); -} - -// Attach subcomponents to main component -LoginRegisterForm.Provider = Provider; -LoginRegisterForm.LoginHeader = LoginHeader; -LoginRegisterForm.RegisterHeader = RegisterHeader; -LoginRegisterForm.Content = Content; -LoginRegisterForm.Email = Email; -LoginRegisterForm.Password = Password; -LoginRegisterForm.PasswordWithForgot = PasswordWithForgot; -LoginRegisterForm.FullName = FullName; -LoginRegisterForm.LoginButton = LoginButton; -LoginRegisterForm.CreateAccountButton = CreateAccountButton; -LoginRegisterForm.ErrorMessage = ErrorMessage; -LoginRegisterForm.ForgotPasswordLink = ForgotPasswordLink; -LoginRegisterForm.LoginHint = LoginHint; -LoginRegisterForm.RegisterHint = RegisterHint; diff --git a/frontend_omni/src/modules/authentication/OIDCCallbackPage.tsx b/frontend_omni/src/modules/authentication/OIDCCallbackPage.tsx new file mode 100644 index 0000000..078fcb5 --- /dev/null +++ b/frontend_omni/src/modules/authentication/OIDCCallbackPage.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuthService } from "@/modules/authentication-service"; +import { useSession } from "@/modules/session-provider"; + +/** + * OIDC Callback Page + * + * Handles the redirect back from the identity provider after login. + * Completes the PKCE code exchange and refreshes the session. + */ +export function OIDCCallbackPage() { + const authService = useAuthService(); + const { refreshSession } = useSession(); + const navigate = useNavigate(); + const [error, setError] = useState(null); + + useEffect(() => { + async function handleCallback() { + try { + await authService.handleCallback(); + refreshSession(); + navigate("/", { replace: true }); + } catch (err) { + console.error("OIDC callback error:", err); + setError( + err instanceof Error + ? err.message + : "Authentication failed", + ); + } + } + handleCallback(); + }, [authService, refreshSession, navigate]); + + if (error) { + return ( +
+
+

+ Authentication Error +

+

{error}

+ +
+
+ ); + } + + return ( +
+
+

+ Completing authentication... +

+
+
+ ); +} diff --git a/frontend_omni/src/modules/authentication/RegisterPage.tsx b/frontend_omni/src/modules/authentication/RegisterPage.tsx deleted file mode 100644 index ac92efd..0000000 --- a/frontend_omni/src/modules/authentication/RegisterPage.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { useAuthService } from "@/modules/authentication-service"; -import { LoginRegisterForm } from "./LoginRegisterForm"; - -export default function RegisterPage() { - return ( -
-
- -
-
- ); -} - -export function Register() { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const navigate = useNavigate(); - const authService = useAuthService(); - - const handleSubmit = async ({ - email, - password, - fullName, - }: { - email: string; - password: string; - fullName: string; - }) => { - setIsLoading(true); - setError(null); - - try { - await authService.signup({ - email, - password, - full_name: fullName.trim() || undefined, - }); - navigate("/login"); - } catch (err) { - setError( - err instanceof Error ? err.message : "Registration failed", - ); - } finally { - setIsLoading(false); - } - }; - - return ( - - - - - - - - - - - - - - ); -} diff --git a/frontend_omni/src/modules/chat-service/OpenAIService.ts b/frontend_omni/src/modules/chat-service/OpenAIService.ts index d9e22d0..bca255f 100644 --- a/frontend_omni/src/modules/chat-service/OpenAIService.ts +++ b/frontend_omni/src/modules/chat-service/OpenAIService.ts @@ -101,18 +101,57 @@ export abstract class OpenAIChatService implements ChatService { /** * OpenAI Chat Service for use with a backend. * Routes requests through the backend /api endpoint which handles - * provider routing and authentication. + * provider routing. Authentication is done via Bearer token from OIDC. + * + * Uses a custom fetch wrapper that reads the access token from + * oidc-client-ts storage and injects it as a Bearer token. */ export class WithBackendOpenAIChatService extends OpenAIChatService { protected createOpenAI(_modelId: string): OpenAI { return new OpenAI({ - apiKey: "not-needed-backend-handles-auth", + apiKey: "bearer-token-via-fetch", baseURL: `${window.location.origin}/api`, dangerouslyAllowBrowser: true, + fetch: authenticatedFetch, }); } } +/** + * Custom fetch wrapper that reads the OIDC access token from localStorage + * (where oidc-client-ts stores it) and injects it as a Bearer token. + */ +async function authenticatedFetch( + input: RequestInfo | URL, + init?: RequestInit, +): Promise { + const token = getAccessTokenFromStorage(); + const headers = new Headers(init?.headers); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + return fetch(input, { ...init, headers }); +} + +function getAccessTokenFromStorage(): string | null { + try { + // oidc-client-ts stores user data in localStorage with key pattern: + // oidc.user:: + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith("oidc.user:")) { + const data = JSON.parse(localStorage.getItem(key) || ""); + if (data?.access_token) { + return data.access_token; + } + } + } + } catch { + // Ignore storage errors + } + return null; +} + /** * OpenAI Chat Service for browser-only mode (no backend). * Fetches provider configuration from localStorage and makes direct diff --git a/frontend_omni/src/modules/llm-provider-service/LLMRestProviderService.tsx b/frontend_omni/src/modules/llm-provider-service/LLMRestProviderService.tsx index f19c10d..82985ee 100644 --- a/frontend_omni/src/modules/llm-provider-service/LLMRestProviderService.tsx +++ b/frontend_omni/src/modules/llm-provider-service/LLMRestProviderService.tsx @@ -6,6 +6,7 @@ * managing providers and models across different provider types. */ +import { useAuthenticatedFetch } from "@/modules/authentication-service/authenticatedFetch"; import { type ApiErrorResponse, type CreateProviderRequest, @@ -41,11 +42,17 @@ async function handleResponse(response: Response): Promise { } class LLMRestProviderService implements ProviderService { + private fetchFn: typeof fetch; + + constructor(fetchFn: typeof fetch) { + this.fetchFn = fetchFn; + } + /** * Get all models from all providers (via /models endpoint) */ async getAllModels(): Promise { - const response = await fetch(`/api/models`); + const response = await this.fetchFn(`/api/models`); if (!response.ok) { throw new Error( @@ -61,7 +68,7 @@ class LLMRestProviderService implements ProviderService { * Get all providers from all types */ async getAllProviders(): Promise { - const response = await fetch(`/api/models/providers`); + const response = await this.fetchFn(`/api/models/providers`); if (!response.ok) { throw new Error( @@ -83,7 +90,9 @@ class LLMRestProviderService implements ProviderService { typeof providerType === "string" ? providerType : providerType.value; - const response = await fetch(`/api/models/providers/${typeValue}`); + const response = await this.fetchFn( + `/api/models/providers/${typeValue}`, + ); if (!response.ok) { throw new Error( @@ -106,7 +115,7 @@ class LLMRestProviderService implements ProviderService { typeof providerType === "string" ? providerType : providerType.value; - const response = await fetch( + const response = await this.fetchFn( `/api/models/providers/${typeValue}/${providerId}`, ); @@ -130,7 +139,7 @@ class LLMRestProviderService implements ProviderService { typeof providerType === "string" ? providerType : providerType.value; - const response = await fetch( + const response = await this.fetchFn( `/api/models/providers/${typeValue}/${providerId}/models`, ); @@ -155,13 +164,16 @@ class LLMRestProviderService implements ProviderService { typeof providerType === "string" ? providerType : providerType.value; - const response = await fetch(`/api/models/providers/${typeValue}`, { - method: "POST", - headers: { - "Content-Type": "application/json", + const response = await this.fetchFn( + `/api/models/providers/${typeValue}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), }, - body: JSON.stringify(data), - }); + ); return await handleResponse(response); } @@ -178,7 +190,7 @@ class LLMRestProviderService implements ProviderService { typeof providerType === "string" ? providerType : providerType.value; - const response = await fetch( + const response = await this.fetchFn( `/api/models/providers/${typeValue}/${providerId}`, { method: "PUT", @@ -203,7 +215,7 @@ class LLMRestProviderService implements ProviderService { typeof providerType === "string" ? providerType : providerType.value; - const response = await fetch( + const response = await this.fetchFn( `/api/models/providers/${typeValue}/${providerId}`, { method: "DELETE", @@ -223,7 +235,10 @@ export function LLMRestProviderServiceContextProvider({ }: { children: React.ReactNode; }) { - const llmProviderServiceInstance = new LLMRestProviderService(); + const authenticatedFetch = useAuthenticatedFetch(); + const llmProviderServiceInstance = new LLMRestProviderService( + authenticatedFetch, + ); return ( diff --git a/frontend_omni/src/modules/module-context-provider/ModuleContextProvider.tsx b/frontend_omni/src/modules/module-context-provider/ModuleContextProvider.tsx index 2ea793a..30d44ad 100644 --- a/frontend_omni/src/modules/module-context-provider/ModuleContextProvider.tsx +++ b/frontend_omni/src/modules/module-context-provider/ModuleContextProvider.tsx @@ -12,8 +12,10 @@ export function ModuleContextProvider({ const contextProviders = modules.getAll>(name); - // Wrap children with all context provider modules - return contextProviders.reduce( + // Wrap children with all context provider modules. + // Providers are ordered by dependency (dependencies first), + // so we reduceRight to make dependencies the outermost wrappers. + return contextProviders.reduceRight( (wrappedChildren, Component, index) => ( {wrappedChildren} diff --git a/frontend_omni/src/modules/session-provider/SessionContextProvider.tsx b/frontend_omni/src/modules/session-provider/SessionContextProvider.tsx index a49c370..400d958 100644 --- a/frontend_omni/src/modules/session-provider/SessionContextProvider.tsx +++ b/frontend_omni/src/modules/session-provider/SessionContextProvider.tsx @@ -1,8 +1,8 @@ import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import type React from "react"; import { startTransition, useCallback, useEffect } from "react"; +import { useAuthService } from "@/modules/authentication-service"; import { type ModuleFlags, useModuleFlags } from "@/modules/module-system"; -import { useUserService } from "@/modules/user-service"; import { type Session, SessionContext, type SessionContextType } from "."; import { refreshSession as refreshSessionService } from "./sessionService"; @@ -13,18 +13,22 @@ interface SessionProviderProps { const MODULE_FLAG_SESSION_ACTIVE = "sessionActive"; /** - * SessionProvider component that manages session state using the Session class + * SessionProvider component that manages session state. + * + * In the OIDC flow, the session is derived from the OIDC user stored + * by oidc-client-ts. The access token from the OIDC user is used for + * all API calls. */ export function SessionContextProvider({ children, }: SessionProviderProps): React.JSX.Element { - const userService = useUserService(); + const authService = useAuthService(); const queryClient = useQueryClient(); const moduleFlags = useModuleFlags(); const { data: session } = useSuspenseQuery({ queryKey: ["userSession"], - queryFn: async () => await refreshSessionService(userService), + queryFn: async () => await refreshSessionService(authService), }); const refreshSession = useCallback( diff --git a/frontend_omni/src/modules/session-provider/sessionService.ts b/frontend_omni/src/modules/session-provider/sessionService.ts index 87e47ab..d2c5f8a 100644 --- a/frontend_omni/src/modules/session-provider/sessionService.ts +++ b/frontend_omni/src/modules/session-provider/sessionService.ts @@ -1,21 +1,34 @@ +import type { AuthService } from "@/modules/authentication-service"; import type { Session } from "@/modules/session-provider"; -import type { UserService } from "@/modules/user-service"; /** - * Creates a new session by refreshing user data from the user service + * Creates a session from the OIDC user stored by oidc-client-ts. * - * @returns Promise A new Session instance if successful, null if authentication fails + * Checks if there is a valid (non-expired) OIDC user in storage + * and extracts user information from the ID token claims. + * + * @returns Promise A new Session instance if authenticated, null otherwise */ export async function refreshSession( - userService: UserService, + authService: AuthService, ): Promise { try { - const user = await userService.fetchCurrentUser(); + const oidcUser = await authService.getUser(); - if (user) { - return { user }; + if (!oidcUser) { + return null; } - return null; + + // Extract user info from OIDC ID token claims + const profile = oidcUser.profile; + + return { + user: { + id: profile.sub, + email: profile.email ?? "", + full_name: profile.name, + }, + }; } catch (error) { console.warn("Failed to refresh session:", error); return null; diff --git a/frontend_omni/src/modules/user-service/HttpUserService.ts b/frontend_omni/src/modules/user-service/HttpUserService.ts index bf88e46..c970579 100644 --- a/frontend_omni/src/modules/user-service/HttpUserService.ts +++ b/frontend_omni/src/modules/user-service/HttpUserService.ts @@ -1,9 +1,23 @@ import type { User, UserService } from "@/modules/user-service"; /** - * Implementation of UserService for HTTP API communication + * Token provider function type. + * Returns the current access token or null if not authenticated. + */ +export type TokenProvider = () => Promise; + +/** + * Implementation of UserService for HTTP API communication. + * + * Uses a Bearer token (from OIDC) for authentication instead of cookies. */ export class HttpUserService implements UserService { + private getToken: TokenProvider; + + constructor(getToken: TokenProvider) { + this.getToken = getToken; + } + /** * Fetches the current authenticated user from the backend * @@ -11,12 +25,18 @@ export class HttpUserService implements UserService { * @throws Error if the request fails or user is not authenticated */ async fetchCurrentUser(): Promise { + const token = await this.getToken(); + + const headers: Record = { + "Content-Type": "application/json", + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + const response = await fetch("/api/user", { method: "GET", - credentials: "include", // Include cookies for session authentication - headers: { - "Content-Type": "application/json", - }, + headers, }); if (!response.ok) { diff --git a/frontend_omni/src/modules/user-service/UserServiceContextProvider.tsx b/frontend_omni/src/modules/user-service/UserServiceContextProvider.tsx index 5d53697..573acc1 100644 --- a/frontend_omni/src/modules/user-service/UserServiceContextProvider.tsx +++ b/frontend_omni/src/modules/user-service/UserServiceContextProvider.tsx @@ -1,4 +1,6 @@ import type React from "react"; +import { useMemo } from "react"; +import { useAuthService } from "@/modules/authentication-service"; import { UserServiceContext } from "@/modules/user-service"; import { HttpUserService } from "./HttpUserService"; @@ -7,7 +9,13 @@ export function UserServiceContextProvider({ }: { children: React.ReactNode; }) { - const userServiceInstance = new HttpUserService(); + const authService = useAuthService(); + + const userServiceInstance = useMemo( + () => new HttpUserService(() => authService.getAccessToken()), + [authService], + ); + return ( {children}