From 5744181952ffc48f8ed327477d893f7a0d1077e8 Mon Sep 17 00:00:00 2001 From: Marcus Spiegel Date: Sat, 3 Oct 2026 12:58:17 +0200 Subject: [PATCH 1/7] feat(backend): add generic OpenID Connect login Adds an opt-in OIDC provider next to Google, GitHub and Slack, configured with OIDC_ISSUER_URL, OIDC_CLIENT_ID and optionally OIDC_CLIENT_SECRET and OIDC_DISPLAY_NAME. Endpoints are resolved through discovery. The authorization code flow always uses PKCE (S256), so public clients without a secret work. Accounts are matched by email like the other providers, and logins without a verified email are rejected. A public GET /api/config endpoint tells clients whether OIDC login is available. If the identity provider cannot be reached at startup, OIDC is disabled with a warning and the server still starts. Uses the openidConnect provider of goth and golang.org/x/oauth2, both already in the module graph. Co-Authored-By: Claude Fable 5.1 --- backend/api-files/openapi.yaml | 32 ++ backend/go.mod | 2 +- backend/internal/config/config.go | 23 ++ backend/internal/handlers/handlers.go | 21 ++ backend/internal/handlers/instanceConfig.go | 34 ++ backend/internal/handlers/oidc.go | 150 ++++++++ backend/internal/server/server.go | 12 + backend/test/integration/oidc_test.go | 362 ++++++++++++++++++++ tauri/src/openapi.d.ts | 49 +++ web-app/src/openapi.d.ts | 49 +++ 10 files changed, 733 insertions(+), 1 deletion(-) create mode 100644 backend/internal/handlers/instanceConfig.go create mode 100644 backend/internal/handlers/oidc.go create mode 100644 backend/test/integration/oidc_test.go diff --git a/backend/api-files/openapi.yaml b/backend/api-files/openapi.yaml index 50d41a4e..5f2006dd 100644 --- a/backend/api-files/openapi.yaml +++ b/backend/api-files/openapi.yaml @@ -306,6 +306,26 @@ components: type: string format: date-time + InstanceConfig: + type: object + description: Public configuration of this instance, used by clients to decide which flows to offer + required: + - oidc + properties: + oidc: + type: object + description: Generic OpenID Connect login + required: + - enabled + - display_name + properties: + enabled: + type: boolean + description: True when an OpenID Connect provider is configured and reachable + display_name: + type: string + description: Label for the login button + securitySchemes: BearerAuth: type: http @@ -332,6 +352,18 @@ paths: "200": description: Metrics in Prometheus format + /api/config: + get: + summary: Public instance configuration + description: Unauthenticated. Tells clients which login flows are available on this instance. + responses: + "200": + description: Instance configuration + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceConfig" + /api/auth/social/:provider: get: summary: Initiate social login with specified provider diff --git a/backend/go.mod b/backend/go.mod index 72a3cb9b..808dc896 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -29,6 +29,7 @@ require ( github.com/twitchtv/twirp v8.1.3+incompatible github.com/wader/gormstore/v2 v2.0.3 golang.org/x/crypto v0.46.0 + golang.org/x/oauth2 v0.34.0 gorm.io/datatypes v1.2.7 gorm.io/driver/postgres v1.5.9 gorm.io/driver/sqlite v1.6.0 @@ -126,7 +127,6 @@ require ( golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect golang.org/x/mod v0.30.0 // indirect golang.org/x/net v0.48.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 62ce0b4d..e836997a 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -33,6 +33,14 @@ type Config struct { GitHubRedirect string CallbackURL string SessionSecret string + // OIDC configures an optional generic OpenID Connect provider. + OIDC struct { + IssuerURL string + ClientID string + ClientSecret string // optional, public clients rely on PKCE alone + DisplayName string // label of the login button + Redirect string + } } Livekit struct { APIKey string @@ -145,6 +153,15 @@ func Load() (*Config, error) { c.Auth.GitHubSecret = os.Getenv("GITHUB_SECRET") c.Auth.GitHubRedirect = fmt.Sprintf("https://%s/api/auth/social/github/callback", c.Server.DeployDomain) + c.Auth.OIDC.IssuerURL = strings.TrimRight(os.Getenv("OIDC_ISSUER_URL"), "/") + c.Auth.OIDC.ClientID = os.Getenv("OIDC_CLIENT_ID") + c.Auth.OIDC.ClientSecret = os.Getenv("OIDC_CLIENT_SECRET") + c.Auth.OIDC.DisplayName = os.Getenv("OIDC_DISPLAY_NAME") + if c.Auth.OIDC.DisplayName == "" { + c.Auth.OIDC.DisplayName = "SSO" + } + c.Auth.OIDC.Redirect = fmt.Sprintf("https://%s/api/auth/social/oidc/callback", c.Server.DeployDomain) + c.Database.DSN = os.Getenv("DATABASE_DSN") c.Database.RedisURI = os.Getenv("REDIS_URI") @@ -221,3 +238,9 @@ func (c *Config) IsTurnstileEnabled() bool { func (c *Config) IsStripeEnabled() bool { return c.Stripe.SecretKey != "" } + +// IsOIDCConfigured reports whether the generic OpenID Connect provider is +// configured. Only the issuer and client ID are required. +func (c *Config) IsOIDCConfigured() bool { + return c.Auth.OIDC.IssuerURL != "" && c.Auth.OIDC.ClientID != "" +} diff --git a/backend/internal/handlers/handlers.go b/backend/internal/handlers/handlers.go index d2a423e5..dd04c35f 100644 --- a/backend/internal/handlers/handlers.go +++ b/backend/internal/handlers/handlers.go @@ -92,6 +92,11 @@ func (r *RealGothicProvider) CompleteUserAuth(res http.ResponseWriter, req *http } func (h *AuthHandler) SocialLoginCallback(c echo.Context) error { + isOIDC := c.Param("provider") == oidcProviderName + if isOIDC { + addOIDCCodeVerifier(c) + } + user, err := h.SocialAuth.CompleteUserAuth(c.Response(), c.Request()) if err != nil { return err @@ -102,6 +107,18 @@ func (h *AuthHandler) SocialLoginCallback(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "Email is required but not provided by the authentication provider") } + if isOIDC { + // Accounts are matched by email, so an unverified address must not be + // able to take over an existing account. + if !oidcEmailVerified(user) { + c.Logger().Warn("OIDC login rejected, email is not verified by the identity provider") + return c.Redirect(http.StatusFound, "/login?error=email_not_verified") + } + if user.FirstName == "" { + user.FirstName = oidcFallbackFirstName(user) + } + } + var u models.User // Will be used to get Slack's team name in case its not an invite var teamName string @@ -348,6 +365,10 @@ func (h *AuthHandler) SocialLogin(c echo.Context) error { q.Set("provider", provider) req.URL.RawQuery = q.Encode() + if provider == oidcProviderName { + return beginOIDCAuth(c) + } + gothic.BeginAuthHandler(c.Response(), req) return nil } diff --git a/backend/internal/handlers/instanceConfig.go b/backend/internal/handlers/instanceConfig.go new file mode 100644 index 00000000..0c811284 --- /dev/null +++ b/backend/internal/handlers/instanceConfig.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "net/http" + + "github.com/labstack/echo/v4" + "github.com/markbates/goth" +) + +// OIDCConfigResponse describes the generic OpenID Connect login. +type OIDCConfigResponse struct { + Enabled bool `json:"enabled"` + DisplayName string `json:"display_name"` +} + +// InstanceConfigResponse is the public, unauthenticated description of how this +// instance is configured. Clients use it to decide which flows to offer. +type InstanceConfigResponse struct { + OIDC OIDCConfigResponse `json:"oidc"` +} + +// GetInstanceConfig returns the instance configuration relevant to clients. +func (h *AuthHandler) GetInstanceConfig(c echo.Context) error { + // Enabled only if the provider was actually registered: discovery can fail + // at startup even though OIDC is configured. + _, err := goth.GetProvider(oidcProviderName) + + return c.JSON(http.StatusOK, InstanceConfigResponse{ + OIDC: OIDCConfigResponse{ + Enabled: err == nil, + DisplayName: h.Config.Auth.OIDC.DisplayName, + }, + }) +} diff --git a/backend/internal/handlers/oidc.go b/backend/internal/handlers/oidc.go new file mode 100644 index 00000000..28b89955 --- /dev/null +++ b/backend/internal/handlers/oidc.go @@ -0,0 +1,150 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "hopp-backend/internal/config" + + "github.com/labstack/echo-contrib/session" + "github.com/labstack/echo/v4" + "github.com/markbates/goth" + "github.com/markbates/goth/gothic" + "github.com/markbates/goth/providers/openidConnect" + "golang.org/x/oauth2" +) + +const ( + // oidcProviderName is the :provider value of the generic OpenID Connect + // provider in /api/auth/social/:provider. + oidcProviderName = "oidc" + // oidcVerifierSessionKey holds the PKCE code verifier between the redirect + // to the identity provider and the callback. + oidcVerifierSessionKey = "oidc_pkce_verifier" + oidcDiscoveryTimeout = 10 * time.Second +) + +// NewOIDCProvider builds the generic OpenID Connect provider from the issuer's +// discovery document. No client secret is needed for public clients, the +// authorization code is always protected with PKCE. +func NewOIDCProvider(cfg *config.Config) (goth.Provider, error) { + oidc := cfg.Auth.OIDC + + // Discovery is done here rather than by goth so it can time out instead of + // blocking server start when the identity provider does not answer. + client := &http.Client{Timeout: oidcDiscoveryTimeout} + res, err := client.Get(oidc.IssuerURL + "/.well-known/openid-configuration") + if err != nil { + return nil, fmt.Errorf("discovery request failed: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("discovery request returned status %d", res.StatusCode) + } + + var discovery openidConnect.OpenIDConfig + if err := json.NewDecoder(res.Body).Decode(&discovery); err != nil { + return nil, fmt.Errorf("invalid discovery document: %w", err) + } + if strings.TrimRight(discovery.Issuer, "/") != oidc.IssuerURL { + return nil, fmt.Errorf("issuer %q in discovery document does not match OIDC_ISSUER_URL", discovery.Issuer) + } + if discovery.AuthEndpoint == "" || discovery.TokenEndpoint == "" { + return nil, fmt.Errorf("discovery document lacks the authorization or token endpoint") + } + + provider, err := openidConnect.NewCustomisedURL( + oidc.ClientID, oidc.ClientSecret, oidc.Redirect, + discovery.AuthEndpoint, discovery.TokenEndpoint, discovery.Issuer, + discovery.UserInfoEndpoint, discovery.EndSessionEndpoint, + "openid", "email", "profile", + ) + if err != nil { + return nil, err + } + provider.SetName(oidcProviderName) + return provider, nil +} + +// beginOIDCAuth redirects to the identity provider like gothic.BeginAuthHandler, +// but adds a PKCE (S256) challenge and remembers the verifier in the session. +func beginOIDCAuth(c echo.Context) error { + req, res := c.Request(), c.Response() + + authURL, err := gothic.GetAuthURL(res, req) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + // Kept in the application session, next to the team invite. gothic's own + // session is replaced on every write, so it cannot hold a second value. + verifier := oauth2.GenerateVerifier() + sess, err := session.Get("session", c) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to load login session") + } + sess.Values[oidcVerifierSessionKey] = verifier + if err := sess.Save(req, res); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to store login session") + } + + u, err := url.Parse(authURL) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Invalid authorization URL") + } + q := u.Query() + q.Set("code_challenge", oauth2.S256ChallengeFromVerifier(verifier)) + q.Set("code_challenge_method", "S256") + u.RawQuery = q.Encode() + + return c.Redirect(http.StatusTemporaryRedirect, u.String()) +} + +// addOIDCCodeVerifier moves the PKCE code verifier from the session into the +// callback request, where goth picks it up for the token exchange. Any +// code_verifier sent by the caller is discarded. +func addOIDCCodeVerifier(c echo.Context) { + req := c.Request() + q := req.URL.Query() + q.Del("code_verifier") + + if sess, err := session.Get("session", c); err == nil { + if verifier, ok := sess.Values[oidcVerifierSessionKey].(string); ok { + q.Set("code_verifier", verifier) + // Single use + delete(sess.Values, oidcVerifierSessionKey) + sess.Save(req, c.Response()) + } + } + + req.URL.RawQuery = q.Encode() +} + +// oidcEmailVerified reports whether the identity provider vouches for the +// user's email address via the standard email_verified claim. +func oidcEmailVerified(user goth.User) bool { + switch verified := user.RawData["email_verified"].(type) { + case bool: + return verified + case string: + return verified == "true" + default: + return false + } +} + +// oidcFallbackFirstName picks a first name for providers that send no +// given_name claim. +func oidcFallbackFirstName(user goth.User) string { + for _, candidate := range []string{user.Name, user.NickName} { + if candidate != "" { + return candidate + } + } + name, _, _ := strings.Cut(user.Email, "@") + return name +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index b130d4af..99953edc 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -335,6 +335,17 @@ func (s *Server) setupGothProviders() { slack.New(s.Config.Auth.SlackKey, s.Config.Auth.SlackSecret, s.Config.Auth.SlackRedirect, "users:read", "users:read.email", "team:read"), github.New(s.Config.Auth.GitHubKey, s.Config.Auth.GitHubSecret, s.Config.Auth.GitHubRedirect, "user:email", "read:user"), ) + + if s.Config.IsOIDCConfigured() { + // An unreachable or misconfigured identity provider must not keep the + // server from starting, the other login methods still work. + provider, err := handlers.NewOIDCProvider(s.Config) + if err != nil { + s.Echo.Logger.Warnf("OIDC login disabled, provider setup failed: %v", err) + return + } + goth.UseProviders(provider) + } } func (s *Server) setupEmailClient() { @@ -378,6 +389,7 @@ func (s *Server) setupRoutes() { return c.String(200, "OK") }) api.GET("/metrics", echoprometheus.NewHandler()) + api.GET("/config", auth.GetInstanceConfig) // Add invitation details endpoint api.GET("/invitation-details/:uuid", auth.GetInvitationDetails) diff --git a/backend/test/integration/oidc_test.go b/backend/test/integration/oidc_test.go new file mode 100644 index 00000000..30707467 --- /dev/null +++ b/backend/test/integration/oidc_test.go @@ -0,0 +1,362 @@ +//go:build integration +// +build integration + +package integration + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/labstack/gommon/log" + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "hopp-backend/internal/config" + "hopp-backend/internal/handlers" + "hopp-backend/internal/models" + "hopp-backend/internal/server" +) + +const oidcTestClientID = "hopp-test-client" + +// fakeIDP is a minimal OpenID Connect provider for a public client: discovery +// and a token endpoint that enforces PKCE (S256). +type fakeIDP struct { + server *httptest.Server + // claims are merged into the ID token returned for the next login. + claims map[string]interface{} + // challenge is the code_challenge the client sent to the authorization + // endpoint, which the test relays here like a real provider would store it. + challenge string + // tokenRequests counts successful token exchanges. + tokenRequests int +} + +func newFakeIDP(t *testing.T) *fakeIDP { + idp := &fakeIDP{} + mux := http.NewServeMux() + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{ + "issuer": idp.server.URL, + "authorization_endpoint": idp.server.URL + "/authorize", + "token_endpoint": idp.server.URL + "/token", + }) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + + // Public client: no secret, the code verifier is the only proof. + sum := sha256.Sum256([]byte(r.Form.Get("code_verifier"))) + if idp.challenge == "" || base64.RawURLEncoding.EncodeToString(sum[:]) != idp.challenge { + http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest) + return + } + if r.Form.Get("client_secret") != "" { + http.Error(w, `{"error":"invalid_client"}`, http.StatusBadRequest) + return + } + + claims := map[string]interface{}{ + "iss": idp.server.URL, + "aud": oidcTestClientID, + "sub": "user-123", + "exp": time.Now().Add(time.Hour).Unix(), + } + for k, v := range idp.claims { + claims[k] = v + } + payload, _ := json.Marshal(claims) + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + idToken := header + "." + base64.RawURLEncoding.EncodeToString(payload) + ".signature" + + idp.tokenRequests++ + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "access-token", + "token_type": "Bearer", + "expires_in": 3600, + "id_token": idToken, + }) + }) + + idp.server = httptest.NewServer(mux) + t.Cleanup(idp.server.Close) + return idp +} + +func setupTestServerWithOIDC(t *testing.T, idp *fakeIDP, configure func(cfg *config.Config)) (*server.Server, func()) { + cfg := &config.Config{} + cfg.Server.Port = "8080" + cfg.Server.Host = "localhost" + cfg.Server.DeployDomain = "localhost:8080" + cfg.Database.DSN = "file::memory:?cache=shared" + cfg.Auth.SessionSecret = "test-secret-key-for-testing-only" + cfg.Resend.DefaultSender = "test@example.com" + cfg.Auth.OIDC.IssuerURL = idp.server.URL + cfg.Auth.OIDC.ClientID = oidcTestClientID + cfg.Auth.OIDC.DisplayName = "Test IdP" + cfg.Auth.OIDC.Redirect = "https://localhost:8080/api/auth/social/oidc/callback" + if configure != nil { + configure(cfg) + } + + srv := server.New(cfg) + srv.Echo.Logger.SetLevel(log.OFF) + require.NoError(t, srv.Initialize()) + + cleanup := func() { + goth.ClearProviders() + if srv.DB != nil { + if sqlDB, _ := srv.DB.DB(); sqlDB != nil { + sqlDB.Close() + } + } + } + return srv, cleanup +} + +// oidcLogin drives the full authorization code flow against the fake provider +// and returns the response of the callback. +func oidcLogin(t *testing.T, srv *server.Server, idp *fakeIDP, claims map[string]interface{}, tamper func(callback url.Values)) *httptest.ResponseRecorder { + return oidcLoginFrom(t, srv, idp, "/api/auth/social/oidc", claims, tamper) +} + +// oidcLoginFrom is oidcLogin with a custom start URL, e.g. one carrying a team +// invitation. +func oidcLoginFrom(t *testing.T, srv *server.Server, idp *fakeIDP, startURL string, claims map[string]interface{}, tamper func(callback url.Values)) *httptest.ResponseRecorder { + idp.claims = claims + + // 1. Start the login, the backend redirects to the provider. + req := httptest.NewRequest(http.MethodGet, startURL, nil) + rec := httptest.NewRecorder() + srv.Echo.ServeHTTP(rec, req) + require.Equal(t, http.StatusTemporaryRedirect, rec.Code, rec.Body.String()) + + authURL, err := url.Parse(rec.Header().Get("Location")) + require.NoError(t, err) + assert.Equal(t, idp.server.URL+"/authorize", authURL.Scheme+"://"+authURL.Host+authURL.Path) + assert.Equal(t, oidcTestClientID, authURL.Query().Get("client_id")) + assert.Equal(t, "S256", authURL.Query().Get("code_challenge_method")) + require.NotEmpty(t, authURL.Query().Get("code_challenge")) + idp.challenge = authURL.Query().Get("code_challenge") + + // 2. The provider redirects back with a code and the state. + callback := url.Values{} + callback.Set("code", "auth-code") + callback.Set("state", authURL.Query().Get("state")) + if tamper != nil { + tamper(callback) + } + req = httptest.NewRequest(http.MethodGet, "/api/auth/social/oidc/callback?"+callback.Encode(), nil) + cookies := map[string]*http.Cookie{} + for _, cookie := range rec.Result().Cookies() { + cookies[cookie.Name] = cookie // the last Set-Cookie per name wins, as in a browser + } + for _, cookie := range cookies { + req.AddCookie(cookie) + } + rec = httptest.NewRecorder() + srv.Echo.ServeHTTP(rec, req) + return rec +} + +func verifiedClaims(email, givenName string) map[string]interface{} { + return map[string]interface{}{ + "email": email, + "email_verified": true, + "given_name": givenName, + "family_name": "Tester", + } +} + +func getInstanceConfig(t *testing.T, srv *server.Server) handlers.InstanceConfigResponse { + req := httptest.NewRequest(http.MethodGet, "/api/config", nil) + rec := httptest.NewRecorder() + srv.Echo.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + var resp handlers.InstanceConfigResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + return resp +} + +func TestOIDC_NewUserSignsInWithPKCE(t *testing.T) { + idp := newFakeIDP(t) + srv, cleanup := setupTestServerWithOIDC(t, idp, nil) + defer cleanup() + + rec := oidcLogin(t, srv, idp, verifiedClaims("ada@gmail.com", "Ada"), nil) + + require.Equal(t, http.StatusFound, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Header().Get("Location"), "/login?token=") + assert.Equal(t, 1, idp.tokenRequests) + + var user models.User + require.NoError(t, srv.DB.Where("email = ?", "ada@gmail.com").First(&user).Error) + assert.Equal(t, "Ada", user.FirstName) + assert.Equal(t, "Tester", user.LastName) + assert.True(t, user.IsAdmin) + assert.NotNil(t, user.TeamID) +} + +func TestOIDC_ExistingUserIsMatchedByEmail(t *testing.T) { + idp := newFakeIDP(t) + srv, cleanup := setupTestServerWithOIDC(t, idp, nil) + defer cleanup() + existing := createTestUser(t, srv.DB, "ada@gmail.com", "Ada", "Existing", "securepassword123", false) + + rec := oidcLogin(t, srv, idp, verifiedClaims("ada@gmail.com", "Ada"), nil) + + require.Equal(t, http.StatusFound, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Header().Get("Location"), "/login?token=") + + var count int64 + require.NoError(t, srv.DB.Model(&models.User{}).Count(&count).Error) + assert.Equal(t, int64(1), count) + var user models.User + require.NoError(t, srv.DB.Where("email = ?", "ada@gmail.com").First(&user).Error) + assert.Equal(t, existing.ID, user.ID) +} + +func TestOIDC_UnverifiedEmailRejected(t *testing.T) { + idp := newFakeIDP(t) + srv, cleanup := setupTestServerWithOIDC(t, idp, nil) + defer cleanup() + + for name, claims := range map[string]map[string]interface{}{ + "claim false": {"email": "ada@gmail.com", "email_verified": false, "given_name": "Ada"}, + "claim missing": {"email": "ada@gmail.com", "given_name": "Ada"}, + } { + t.Run(name, func(t *testing.T) { + rec := oidcLogin(t, srv, idp, claims, nil) + + assert.Equal(t, http.StatusFound, rec.Code) + assert.Equal(t, "/login?error=email_not_verified", rec.Header().Get("Location")) + + var count int64 + require.NoError(t, srv.DB.Model(&models.User{}).Count(&count).Error) + assert.Equal(t, int64(0), count) + }) + } +} + +func TestOIDC_CallerSuppliedVerifierIsIgnored(t *testing.T) { + idp := newFakeIDP(t) + srv, cleanup := setupTestServerWithOIDC(t, idp, nil) + defer cleanup() + + // A code_verifier in the callback URL must not replace the one in the + // session, otherwise the token exchange below would fail. + rec := oidcLogin(t, srv, idp, verifiedClaims("ada@gmail.com", "Ada"), func(callback url.Values) { + callback.Set("code_verifier", "attacker-controlled") + }) + + assert.Equal(t, http.StatusFound, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Header().Get("Location"), "/login?token=") +} + +func TestOIDC_StateMismatchRejected(t *testing.T) { + idp := newFakeIDP(t) + srv, cleanup := setupTestServerWithOIDC(t, idp, nil) + defer cleanup() + + rec := oidcLogin(t, srv, idp, verifiedClaims("ada@gmail.com", "Ada"), func(callback url.Values) { + callback.Set("state", "forged") + }) + + assert.NotEqual(t, http.StatusFound, rec.Code) + assert.Equal(t, 0, idp.tokenRequests) +} + +func TestOIDC_DefaultCreatesOwnTeamPerUser(t *testing.T) { + idp := newFakeIDP(t) + srv, cleanup := setupTestServerWithOIDC(t, idp, nil) + defer cleanup() + + require.Equal(t, http.StatusFound, oidcLogin(t, srv, idp, verifiedClaims("ada@gmail.com", "Ada"), nil).Code) + require.Equal(t, http.StatusFound, oidcLogin(t, srv, idp, verifiedClaims("grace@gmail.com", "Grace"), nil).Code) + + var ada, grace models.User + require.NoError(t, srv.DB.Where("email = ?", "ada@gmail.com").First(&ada).Error) + require.NoError(t, srv.DB.Where("email = ?", "grace@gmail.com").First(&grace).Error) + assert.NotEqual(t, *ada.TeamID, *grace.TeamID) + assert.True(t, grace.IsAdmin) +} + +func TestOIDC_InvitedUserJoinsInvitingTeam(t *testing.T) { + idp := newFakeIDP(t) + srv, cleanup := setupTestServerWithOIDC(t, idp, nil) + defer cleanup() + + createTestTeam(t, srv.DB, "Some Other Team") + team := createTestTeam(t, srv.DB, "Inviting Team") + invitation := models.TeamInvitation{TeamID: int(team.ID), UniqueID: "0199a000-0000-7000-8000-000000000001"} + require.NoError(t, srv.DB.Create(&invitation).Error) + + rec := oidcLoginFrom(t, srv, idp, "/api/auth/social/oidc?invite_uuid="+invitation.UniqueID, verifiedClaims("ada@gmail.com", "Ada"), nil) + require.Equal(t, http.StatusFound, rec.Code, rec.Body.String()) + + var user models.User + require.NoError(t, srv.DB.Where("email = ?", "ada@gmail.com").First(&user).Error) + assert.Equal(t, team.ID, *user.TeamID) + assert.False(t, user.IsAdmin) +} + +func TestOIDC_FirstNameFallsBackWithoutGivenName(t *testing.T) { + idp := newFakeIDP(t) + srv, cleanup := setupTestServerWithOIDC(t, idp, nil) + defer cleanup() + + rec := oidcLogin(t, srv, idp, map[string]interface{}{ + "email": "ada@gmail.com", + "email_verified": true, + "preferred_username": "ada.l", + }, nil) + require.Equal(t, http.StatusFound, rec.Code, rec.Body.String()) + + var user models.User + require.NoError(t, srv.DB.Where("email = ?", "ada@gmail.com").First(&user).Error) + assert.Equal(t, "ada.l", user.FirstName) +} + +func TestInstanceConfig_OIDC(t *testing.T) { + t.Run("enabled when the provider is registered", func(t *testing.T) { + idp := newFakeIDP(t) + srv, cleanup := setupTestServerWithOIDC(t, idp, nil) + defer cleanup() + + resp := getInstanceConfig(t, srv) + assert.True(t, resp.OIDC.Enabled) + assert.Equal(t, "Test IdP", resp.OIDC.DisplayName) + }) + + t.Run("disabled when not configured", func(t *testing.T) { + srv, cleanup := setupTestServerFast(t) + defer cleanup() + defer goth.ClearProviders() + + assert.False(t, getInstanceConfig(t, srv).OIDC.Enabled) + }) + + t.Run("disabled when discovery fails, server still starts", func(t *testing.T) { + idp := newFakeIDP(t) + idp.server.Close() + srv, cleanup := setupTestServerWithOIDC(t, idp, nil) + defer cleanup() + + assert.False(t, getInstanceConfig(t, srv).OIDC.Enabled) + + req := httptest.NewRequest(http.MethodGet, "/api/auth/social/oidc", nil) + rec := httptest.NewRecorder() + srv.Echo.ServeHTTP(rec, req) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) +} diff --git a/tauri/src/openapi.d.ts b/tauri/src/openapi.d.ts index c0b9b5b9..99fa04d0 100644 --- a/tauri/src/openapi.d.ts +++ b/tauri/src/openapi.d.ts @@ -74,6 +74,45 @@ export interface paths { patch?: never; trace?: never; }; + "/api/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Public instance configuration + * @description Unauthenticated. Tells clients which login flows are available on this instance. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Instance configuration */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InstanceConfig"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/social/:provider": { parameters: { query?: never; @@ -2812,6 +2851,16 @@ export interface components { /** Format: date-time */ updated_at?: string; }; + /** @description Public configuration of this instance, used by clients to decide which flows to offer */ + InstanceConfig: { + /** @description Generic OpenID Connect login */ + oidc: { + /** @description True when an OpenID Connect provider is configured and reachable */ + enabled: boolean; + /** @description Label for the login button */ + display_name: string; + }; + }; }; responses: never; parameters: never; diff --git a/web-app/src/openapi.d.ts b/web-app/src/openapi.d.ts index c0b9b5b9..99fa04d0 100644 --- a/web-app/src/openapi.d.ts +++ b/web-app/src/openapi.d.ts @@ -74,6 +74,45 @@ export interface paths { patch?: never; trace?: never; }; + "/api/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Public instance configuration + * @description Unauthenticated. Tells clients which login flows are available on this instance. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Instance configuration */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InstanceConfig"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/social/:provider": { parameters: { query?: never; @@ -2812,6 +2851,16 @@ export interface components { /** Format: date-time */ updated_at?: string; }; + /** @description Public configuration of this instance, used by clients to decide which flows to offer */ + InstanceConfig: { + /** @description Generic OpenID Connect login */ + oidc: { + /** @description True when an OpenID Connect provider is configured and reachable */ + enabled: boolean; + /** @description Label for the login button */ + display_name: string; + }; + }; }; responses: never; parameters: never; From 0fef3ad10ad638f9badc651fcc0f7b0825e21fb2 Mon Sep 17 00:00:00 2001 From: Marcus Spiegel Date: Sat, 3 Oct 2026 12:58:19 +0200 Subject: [PATCH 2/7] feat(backend): add OIDC_SINGLE_TEAM to put all OIDC users into one team By default a new user without an invitation gets their own team. With OIDC_SINGLE_TEAM=true the first OIDC user creates the team and becomes its admin, and every later OIDC user without an invitation joins it. Co-Authored-By: Claude Fable 5.1 --- backend/internal/config/config.go | 4 ++++ backend/internal/handlers/handlers.go | 13 +++++++++++++ backend/test/integration/oidc_test.go | 24 ++++++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index e836997a..2fc431b0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -40,6 +40,9 @@ type Config struct { ClientSecret string // optional, public clients rely on PKCE alone DisplayName string // label of the login button Redirect string + // SingleTeam makes new OIDC users without an invitation join the + // first team of the instance instead of creating their own. + SingleTeam bool } } Livekit struct { @@ -161,6 +164,7 @@ func Load() (*Config, error) { c.Auth.OIDC.DisplayName = "SSO" } c.Auth.OIDC.Redirect = fmt.Sprintf("https://%s/api/auth/social/oidc/callback", c.Server.DeployDomain) + c.Auth.OIDC.SingleTeam = os.Getenv("OIDC_SINGLE_TEAM") == "true" c.Database.DSN = os.Getenv("DATABASE_DSN") c.Database.RedisURI = os.Getenv("REDIS_URI") diff --git a/backend/internal/handlers/handlers.go b/backend/internal/handlers/handlers.go index dd04c35f..d32e1036 100644 --- a/backend/internal/handlers/handlers.go +++ b/backend/internal/handlers/handlers.go @@ -152,6 +152,19 @@ func (h *AuthHandler) SocialLoginCallback(c echo.Context) error { sess.Save(c.Request(), c.Response()) } + // Single-team mode: OIDC users without an invitation join the first + // team of the instance. On an empty instance there is none yet, so + // the first user falls through and creates it as admin. + if assignedTeamID == nil && isOIDC && h.Config.Auth.OIDC.SingleTeam { + var team models.Team + err := tx.Order("id ASC").First(&team).Error + if err == nil { + assignedTeamID = &team.ID + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("failed to look up team: %w", err) + } + } + var isAdmin = false // If no team invitation, we need to create a new team if assignedTeamID == nil { diff --git a/backend/test/integration/oidc_test.go b/backend/test/integration/oidc_test.go index 30707467..6ad6fe98 100644 --- a/backend/test/integration/oidc_test.go +++ b/backend/test/integration/oidc_test.go @@ -291,6 +291,30 @@ func TestOIDC_DefaultCreatesOwnTeamPerUser(t *testing.T) { assert.True(t, grace.IsAdmin) } +func TestOIDC_SingleTeamJoinsFirstTeam(t *testing.T) { + idp := newFakeIDP(t) + srv, cleanup := setupTestServerWithOIDC(t, idp, func(cfg *config.Config) { + cfg.Auth.OIDC.SingleTeam = true + }) + defer cleanup() + + require.Equal(t, http.StatusFound, oidcLogin(t, srv, idp, verifiedClaims("ada@gmail.com", "Ada"), nil).Code) + require.Equal(t, http.StatusFound, oidcLogin(t, srv, idp, verifiedClaims("grace@gmail.com", "Grace"), nil).Code) + + var ada, grace models.User + require.NoError(t, srv.DB.Where("email = ?", "ada@gmail.com").First(&ada).Error) + require.NoError(t, srv.DB.Where("email = ?", "grace@gmail.com").First(&grace).Error) + + // The first user bootstraps the team as admin, everyone else joins it. + assert.True(t, ada.IsAdmin) + assert.False(t, grace.IsAdmin) + assert.Equal(t, *ada.TeamID, *grace.TeamID) + + var teams int64 + require.NoError(t, srv.DB.Model(&models.Team{}).Count(&teams).Error) + assert.Equal(t, int64(1), teams) +} + func TestOIDC_InvitedUserJoinsInvitingTeam(t *testing.T) { idp := newFakeIDP(t) srv, cleanup := setupTestServerWithOIDC(t, idp, nil) From dd330022cea141df1240078179aefd107e045640 Mon Sep 17 00:00:00 2001 From: Marcus Spiegel Date: Sat, 3 Oct 2026 12:58:19 +0200 Subject: [PATCH 3/7] feat(web-app): add OpenID Connect login button Shown when GET /api/config reports OIDC as enabled, labelled with the configured display name. Co-Authored-By: Claude Fable 5.1 --- web-app/src/pages/Login.tsx | 49 +++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/web-app/src/pages/Login.tsx b/web-app/src/pages/Login.tsx index 31e12d4c..d929bfee 100644 --- a/web-app/src/pages/Login.tsx +++ b/web-app/src/pages/Login.tsx @@ -1,6 +1,7 @@ import { cn } from "@/lib/utils"; import { FaGoogle } from "react-icons/fa"; import { GrGithub } from "react-icons/gr"; +import { HiOutlineKey } from "react-icons/hi2"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -112,6 +113,18 @@ export function LoginForm({ className, isInvitation = false, ...props }: LoginFo }, ); + // Tells us whether this instance offers login through an OpenID Connect provider. + const { data: instanceConfig } = useQuery("get", "/api/config"); + const oidc = instanceConfig?.oidc; + + useEffect(() => { + if (searchParams.get("error") === "email_not_verified") { + toast.error("Your identity provider has not verified your email address, so we can't sign you in.", { + id: "email-not-verified", + }); + } + }, [searchParams]); + useEffect(() => { if (invitationError) { toast.error("Failed to fetch invitation details, contact your admin for a new invitation link"); @@ -247,6 +260,15 @@ export function LoginForm({ className, isInvitation = false, ...props }: LoginFo window.location.href = url.toString(); }; + const handleOIDCLogin = () => { + setCookie("lastUsedLogin", "oidc", { expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7) }); // 7 days + const url = new URL(`${BACKEND_URLS.BASE}/api/auth/social/oidc`); + if (formData.teamInviteUUID) { + url.searchParams.set("invite_uuid", formData.teamInviteUUID); + } + window.location.href = url.toString(); + }; + const LastUsedPill = () => (
Last Used @@ -275,22 +297,29 @@ export function LoginForm({ className, isInvitation = false, ...props }: LoginFo {isInvitation && invitationDetails ? `Join ${invitationDetails.name} team on Hopp` - : isSignUp ? - "Create an account" - : "Welcome back"} + : isSignUp ? + "Create an account" + : "Welcome back"} {isInvitation && invitationDetails ? "Sign up to join your team" - : isSignUp ? - "Sign up for a new account" - : "Login with your email or social account"} + : isSignUp ? + "Sign up for a new account" + : "Login with your email or social account"}
+ {oidc?.enabled && ( + + )}
{!isInvitation && ( @@ -384,7 +413,7 @@ export function LoginForm({ className, isInvitation = false, ...props }: LoginFo Sign in - : <> + : <> Don't have an account?{" "}