diff --git a/backend/core/models/migrationscripts/20261107_add_id_token_to_auth_sessions.go b/backend/core/models/migrationscripts/20261107_add_id_token_to_auth_sessions.go new file mode 100644 index 00000000000..e5bacd04345 --- /dev/null +++ b/backend/core/models/migrationscripts/20261107_add_id_token_to_auth_sessions.go @@ -0,0 +1,48 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*addIdTokenToAuthSessions)(nil) + +type authSession20261107 struct { + IDToken string `gorm:"type:text"` +} + +func (authSession20261107) TableName() string { + return "auth_sessions" +} + +type addIdTokenToAuthSessions struct{} + +func (*addIdTokenToAuthSessions) Up(basicRes context.BasicRes) errors.Error { + return basicRes.GetDal().AutoMigrate(&authSession20261107{}) +} + +func (*addIdTokenToAuthSessions) Version() uint64 { + return 20261107000001 +} + +func (*addIdTokenToAuthSessions) Name() string { + return "add id_token to auth_sessions for OIDC id_token_hint on logout" +} diff --git a/backend/core/models/migrationscripts/register.go b/backend/core/models/migrationscripts/register.go index 94994b612e0..b6f8a0cfe76 100644 --- a/backend/core/models/migrationscripts/register.go +++ b/backend/core/models/migrationscripts/register.go @@ -155,5 +155,6 @@ func All() []plugin.MigrationScript { new(backfillSubProjectFromMonorepo), new(addBlueprintIdIndexToPipelines), new(expandDomainTextColumns), + new(addIdTokenToAuthSessions), } } diff --git a/backend/helpers/oidchelper/config.go b/backend/helpers/oidchelper/config.go index 6f0a4f0dc7b..0f0ebe24e81 100644 --- a/backend/helpers/oidchelper/config.go +++ b/backend/helpers/oidchelper/config.go @@ -19,6 +19,7 @@ package oidchelper import ( "fmt" + "net/url" "sort" "strings" "time" @@ -34,9 +35,17 @@ const ( // back as the X-CSRF-Token header (double-submit cookie pattern). CSRFCookieName = "devlake_csrf" CSRFHeaderName = "X-CSRF-Token" + // LogoutHintCookieName carries the encrypted id_token of a login that + // was rejected after a successful IdP sign-in (e.g. user not allowed), + // so "sign in with a different account" can end the IdP session + // without a confirmation prompt. + LogoutHintCookieName = "devlake_oidc_logout_hint" // State cookie max-age. Covers a slow user typing 2FA at the IdP. StateCookieMaxAge = 10 * time.Minute + // Logout-hint cookie max-age. Long enough to read the error page and + // click "sign in with a different account". + LogoutHintCookieMaxAge = 10 * time.Minute defaultSessionTTL = 8 * time.Hour defaultScopes = "openid,profile,email" @@ -53,6 +62,11 @@ type ProviderConfig struct { Scopes []string DisplayName string + // PostLogoutRedirectURL is sent as post_logout_redirect_uri to the IdP's + // end_session_endpoint. Defaults to the origin of RedirectURL plus "/". + // Must be registered with the IdP as an allowed post-logout redirect. + PostLogoutRedirectURL string + // UseWorkloadIdentity authenticates the code exchange with an Azure // Workload Identity federated assertion (read from the SA token file) // instead of ClientSecret. Entra-specific. @@ -72,6 +86,10 @@ type Config struct { AllowEmails map[string]struct{} AllowDomains map[string]struct{} + // AccessRequestURL is shown on the login error page so rejected users + // know where to request access. Optional; must be http(s). + AccessRequestURL string + SessionSecret []byte SessionTTL time.Duration @@ -145,6 +163,13 @@ func LoadConfig(basicRes context.BasicRes) (*Config, error) { AllowDomains: parseStringSet(cfg.GetString("OIDC_ALLOW_DOMAINS")), } + if v := strings.TrimSpace(cfg.GetString("OIDC_ACCESS_REQUEST_URL")); v != "" { + if !isHTTPURL(v) { + return nil, fmt.Errorf("OIDC_ACCESS_REQUEST_URL must be an absolute http(s) URL, got %q", v) + } + out.AccessRequestURL = v + } + if !out.OIDCEnabled { return out, nil } @@ -194,9 +219,33 @@ func loadProviderConfig(cfg config.ConfigReader, name, prefix string) (*Provider if len(missing) > 0 { return nil, fmt.Errorf("provider %q missing required env vars: %s", name, strings.Join(missing, ", ")) } + + if v := strings.TrimSpace(cfg.GetString(prefix + "POST_LOGOUT_REDIRECT_URL")); v != "" { + if !isHTTPURL(v) { + return nil, fmt.Errorf("%sPOST_LOGOUT_REDIRECT_URL must be an absolute http(s) URL, got %q", prefix, v) + } + p.PostLogoutRedirectURL = v + } else { + p.PostLogoutRedirectURL = originOf(p.RedirectURL) + } return p, nil } +func isHTTPURL(raw string) bool { + u, err := url.Parse(raw) + return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" +} + +// originOf returns scheme://host/ of raw, or "" if raw is not an absolute +// http(s) URL. +func originOf(raw string) string { + if !isHTTPURL(raw) { + return "" + } + u, _ := url.Parse(raw) + return (&url.URL{Scheme: u.Scheme, Host: u.Host, Path: "/"}).String() +} + func parseProviderNames(raw string) []string { parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) diff --git a/backend/helpers/oidchelper/config_test.go b/backend/helpers/oidchelper/config_test.go index c74d47c4147..91748a11a01 100644 --- a/backend/helpers/oidchelper/config_test.go +++ b/backend/helpers/oidchelper/config_test.go @@ -132,3 +132,64 @@ func TestLoadConfigRequiresSessionSecretForOIDC(t *testing.T) { t.Fatal("LoadConfig should reject OIDC-enabled config without SESSION_SECRET") } } + +func oidcTestViper() *viper.Viper { + v := viper.New() + v.Set("AUTH_ENABLED", true) + v.Set("OIDC_ENABLED", true) + v.Set("SESSION_SECRET", "test-secret-with-at-least-32-bytes!") + v.Set("OIDC_PROVIDERS", "keycloak") + v.Set("OIDC_KEYCLOAK_ISSUER_URL", "https://idp.example.com/realms/company") + v.Set("OIDC_KEYCLOAK_CLIENT_ID", "devlake") + v.Set("OIDC_KEYCLOAK_CLIENT_SECRET", "secret") + v.Set("OIDC_KEYCLOAK_REDIRECT_URL", "https://devlake.example.com:8443/api/auth/callback") + return v +} + +func TestLoadConfigPostLogoutRedirectDefaultsToRedirectOrigin(t *testing.T) { + cfg, err := LoadConfig(basicResStub{cfg: oidcTestViper()}) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if got := cfg.Providers["keycloak"].PostLogoutRedirectURL; got != "https://devlake.example.com:8443/" { + t.Fatalf("PostLogoutRedirectURL = %q", got) + } +} + +func TestLoadConfigPostLogoutRedirectExplicit(t *testing.T) { + v := oidcTestViper() + v.Set("OIDC_KEYCLOAK_POST_LOGOUT_REDIRECT_URL", "https://devlake.example.com/devlake/") + cfg, err := LoadConfig(basicResStub{cfg: v}) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if got := cfg.Providers["keycloak"].PostLogoutRedirectURL; got != "https://devlake.example.com/devlake/" { + t.Fatalf("PostLogoutRedirectURL = %q", got) + } +} + +func TestLoadConfigRejectsInvalidURLs(t *testing.T) { + cases := map[string]string{ + "OIDC_KEYCLOAK_POST_LOGOUT_REDIRECT_URL": "/relative", + "OIDC_ACCESS_REQUEST_URL": "javascript:alert(1)", + } + for key, value := range cases { + v := oidcTestViper() + v.Set(key, value) + if _, err := LoadConfig(basicResStub{cfg: v}); err == nil { + t.Errorf("LoadConfig should reject %s=%q", key, value) + } + } +} + +func TestLoadConfigAccessRequestURL(t *testing.T) { + v := oidcTestViper() + v.Set("OIDC_ACCESS_REQUEST_URL", "https://intranet.example.com/devlake-access") + cfg, err := LoadConfig(basicResStub{cfg: v}) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.AccessRequestURL != "https://intranet.example.com/devlake-access" { + t.Fatalf("AccessRequestURL = %q", cfg.AccessRequestURL) + } +} diff --git a/backend/helpers/oidchelper/cookie.go b/backend/helpers/oidchelper/cookie.go index c628d07e864..87151440724 100644 --- a/backend/helpers/oidchelper/cookie.go +++ b/backend/helpers/oidchelper/cookie.go @@ -52,6 +52,14 @@ func ClearStateCookie(c *gin.Context, cfg *Config) { writeCookie(c, cfg, StateCookieName, "", -1, true) } +func SetLogoutHintCookie(c *gin.Context, cfg *Config, value string) { + writeCookie(c, cfg, LogoutHintCookieName, value, int(LogoutHintCookieMaxAge.Seconds()), true) +} + +func ClearLogoutHintCookie(c *gin.Context, cfg *Config) { + writeCookie(c, cfg, LogoutHintCookieName, "", -1, true) +} + // SetCSRFCookie writes the CSRF token. Not HttpOnly so that JS can read // it and echo the value back as the X-CSRF-Token header. func SetCSRFCookie(c *gin.Context, cfg *Config, token string) { diff --git a/backend/helpers/oidchelper/provider.go b/backend/helpers/oidchelper/provider.go index bf2b10ac55b..4eb982cb25b 100644 --- a/backend/helpers/oidchelper/provider.go +++ b/backend/helpers/oidchelper/provider.go @@ -20,6 +20,7 @@ package oidchelper import ( "context" "fmt" + "net/url" "sync" "time" @@ -111,7 +112,12 @@ func (p *Provider) VerifyIDToken(ctx context.Context, raw string) (*oidc.IDToken return verifier.Verify(ctx, raw) } -func (p *Provider) EndSessionURL(ctx context.Context) (string, error) { +// EndSessionURL builds an RP-Initiated Logout URL (OpenID Connect +// RP-Initiated Logout 1.0) for the IdP's end_session_endpoint. client_id +// and post_logout_redirect_uri let the IdP return the user to DevLake; +// idTokenHint (optional) lets IdPs such as Keycloak skip the logout +// confirmation prompt. Returns "" when the IdP advertises no endpoint. +func (p *Provider) EndSessionURL(ctx context.Context, idTokenHint string) (string, error) { prov, err := p.OIDC(ctx) if err != nil { return "", err @@ -122,5 +128,21 @@ func (p *Provider) EndSessionURL(ctx context.Context) (string, error) { if err := prov.Claims(&claims); err != nil { return "", err } - return claims.EndSessionEndpoint, nil + if claims.EndSessionEndpoint == "" { + return "", nil + } + u, err := url.Parse(claims.EndSessionEndpoint) + if err != nil { + return "", fmt.Errorf("parse end_session_endpoint: %w", err) + } + q := u.Query() + q.Set("client_id", p.cfg.ClientID) + if p.cfg.PostLogoutRedirectURL != "" { + q.Set("post_logout_redirect_uri", p.cfg.PostLogoutRedirectURL) + } + if idTokenHint != "" { + q.Set("id_token_hint", idTokenHint) + } + u.RawQuery = q.Encode() + return u.String(), nil } diff --git a/backend/helpers/oidchelper/provider_test.go b/backend/helpers/oidchelper/provider_test.go new file mode 100644 index 00000000000..35f2b847441 --- /dev/null +++ b/backend/helpers/oidchelper/provider_test.go @@ -0,0 +1,112 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package oidchelper + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +// discoveryServer serves a minimal OIDC discovery document whose +// end_session_endpoint is built from endSessionPath ("" omits it). +func discoveryServer(t *testing.T, endSessionPath string) *httptest.Server { + t.Helper() + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "jwks_uri": srv.URL + "/jwks", + } + if endSessionPath != "" { + doc["end_session_endpoint"] = srv.URL + endSessionPath + } + _ = json.NewEncoder(w).Encode(doc) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestEndSessionURLAddsLogoutParams(t *testing.T) { + srv := discoveryServer(t, "/logout?ui_locales=en") + p := NewProvider(&ProviderConfig{ + IssuerURL: srv.URL, + ClientID: "devlake", + PostLogoutRedirectURL: "https://devlake.example.com/", + }) + + raw, err := p.EndSessionURL(context.Background(), "the-id-token") + if err != nil { + t.Fatalf("EndSessionURL: %v", err) + } + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse %q: %v", raw, err) + } + if u.Path != "/logout" { + t.Fatalf("path = %q", u.Path) + } + q := u.Query() + want := map[string]string{ + "ui_locales": "en", + "client_id": "devlake", + "post_logout_redirect_uri": "https://devlake.example.com/", + "id_token_hint": "the-id-token", + } + for k, v := range want { + if q.Get(k) != v { + t.Errorf("%s = %q, want %q (url=%s)", k, q.Get(k), v, raw) + } + } +} + +func TestEndSessionURLOmitsEmptyHint(t *testing.T) { + srv := discoveryServer(t, "/logout") + p := NewProvider(&ProviderConfig{IssuerURL: srv.URL, ClientID: "devlake"}) + + raw, err := p.EndSessionURL(context.Background(), "") + if err != nil { + t.Fatalf("EndSessionURL: %v", err) + } + u, _ := url.Parse(raw) + q := u.Query() + if q.Has("id_token_hint") || q.Has("post_logout_redirect_uri") { + t.Fatalf("unexpected optional params in %s", raw) + } + if q.Get("client_id") != "devlake" { + t.Fatalf("client_id missing in %s", raw) + } +} + +func TestEndSessionURLEmptyWithoutEndpoint(t *testing.T) { + srv := discoveryServer(t, "") + p := NewProvider(&ProviderConfig{IssuerURL: srv.URL, ClientID: "devlake"}) + + raw, err := p.EndSessionURL(context.Background(), "hint") + if err != nil { + t.Fatalf("EndSessionURL: %v", err) + } + if raw != "" { + t.Fatalf("expected empty URL, got %q", raw) + } +} diff --git a/backend/helpers/oidchelper/state.go b/backend/helpers/oidchelper/state.go index d43b82fdf00..29d47b6db64 100644 --- a/backend/helpers/oidchelper/state.go +++ b/backend/helpers/oidchelper/state.go @@ -44,11 +44,54 @@ type StatePayload struct { // EncodeState seals a StatePayload with AES-GCM (key derived from // SESSION_SECRET via SHA-256) and returns a URL-safe base64 string. func EncodeState(secret []byte, p *StatePayload) (string, error) { + return sealJSON(secret, p) +} + +// DecodeState reverses EncodeState and rejects payloads older than StateCookieMaxAge. +func DecodeState(secret []byte, encoded string) (*StatePayload, error) { + var p StatePayload + if err := openJSON(secret, encoded, &p); err != nil { + return nil, fmt.Errorf("state %w", err) + } + if time.Since(p.IssuedAt) > StateCookieMaxAge { + return nil, fmt.Errorf("state expired") + } + return &p, nil +} + +// LogoutHintPayload is encrypted into the logout-hint cookie. It lets the +// switch-account endpoint end the IdP session of a user whose login was +// rejected by DevLake (no DevLake session exists in that case). +type LogoutHintPayload struct { + Provider string `json:"v"` + IDToken string `json:"i"` + IssuedAt time.Time `json:"t"` +} + +// EncodeLogoutHint seals a LogoutHintPayload like EncodeState. +func EncodeLogoutHint(secret []byte, p *LogoutHintPayload) (string, error) { + return sealJSON(secret, p) +} + +// DecodeLogoutHint reverses EncodeLogoutHint and rejects payloads older +// than LogoutHintCookieMaxAge. +func DecodeLogoutHint(secret []byte, encoded string) (*LogoutHintPayload, error) { + var p LogoutHintPayload + if err := openJSON(secret, encoded, &p); err != nil { + return nil, fmt.Errorf("logout hint %w", err) + } + if time.Since(p.IssuedAt) > LogoutHintCookieMaxAge { + return nil, fmt.Errorf("logout hint expired") + } + return &p, nil +} + +func sealJSON(secret []byte, v any) (string, error) { gcm, err := newGCM(secret) if err != nil { return "", err } - plaintext, err := json.Marshal(p) + plaintext, err := json.Marshal(v) if err != nil { return "", err } @@ -60,32 +103,27 @@ func EncodeState(secret []byte, p *StatePayload) (string, error) { return base64.RawURLEncoding.EncodeToString(ciphertext), nil } -// DecodeState reverses EncodeState and rejects payloads older than StateCookieMaxAge. -func DecodeState(secret []byte, encoded string) (*StatePayload, error) { +func openJSON(secret []byte, encoded string, v any) error { gcm, err := newGCM(secret) if err != nil { - return nil, err + return err } raw, err := base64.RawURLEncoding.DecodeString(encoded) if err != nil { - return nil, fmt.Errorf("state decode: %w", err) + return fmt.Errorf("decode: %w", err) } if len(raw) < gcm.NonceSize() { - return nil, fmt.Errorf("state too short") + return fmt.Errorf("too short") } nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():] plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) if err != nil { - return nil, fmt.Errorf("state decrypt: %w", err) + return fmt.Errorf("decrypt: %w", err) } - var p StatePayload - if err := json.Unmarshal(plaintext, &p); err != nil { - return nil, fmt.Errorf("state unmarshal: %w", err) + if err := json.Unmarshal(plaintext, v); err != nil { + return fmt.Errorf("unmarshal: %w", err) } - if time.Since(p.IssuedAt) > StateCookieMaxAge { - return nil, fmt.Errorf("state expired") - } - return &p, nil + return nil } // NewNonce returns a 24-char base64 random string suitable for the OIDC diff --git a/backend/helpers/oidchelper/state_test.go b/backend/helpers/oidchelper/state_test.go index 3401cd372a1..0407fae820b 100644 --- a/backend/helpers/oidchelper/state_test.go +++ b/backend/helpers/oidchelper/state_test.go @@ -108,3 +108,44 @@ func flipChar(b byte) string { } return "A" } + +func TestLogoutHintRoundTrip(t *testing.T) { + secret := []byte("a-test-secret-with-at-least-32-bytes!") + in := &LogoutHintPayload{Provider: "keycloak", IDToken: "raw.id.token", IssuedAt: time.Now()} + encoded, err := EncodeLogoutHint(secret, in) + if err != nil { + t.Fatalf("encode: %v", err) + } + if strings.Contains(encoded, in.IDToken) { + t.Fatal("logout hint must not contain the id_token in clear text") + } + out, err := DecodeLogoutHint(secret, encoded) + if err != nil { + t.Fatalf("decode: %v", err) + } + if out.Provider != in.Provider || out.IDToken != in.IDToken { + t.Fatalf("round-trip mismatch: %+v vs %+v", in, out) + } +} + +func TestDecodeLogoutHintRejectsExpiredAndForeign(t *testing.T) { + secret := []byte("a-test-secret-with-at-least-32-bytes!") + old, err := EncodeLogoutHint(secret, &LogoutHintPayload{ + Provider: "keycloak", + IDToken: "t", + IssuedAt: time.Now().Add(-LogoutHintCookieMaxAge - time.Minute), + }) + if err != nil { + t.Fatalf("encode: %v", err) + } + if _, err := DecodeLogoutHint(secret, old); err == nil || !strings.Contains(err.Error(), "expired") { + t.Fatalf("expected expired error, got: %v", err) + } + fresh, err := EncodeLogoutHint(secret, &LogoutHintPayload{Provider: "keycloak", IDToken: "t", IssuedAt: time.Now()}) + if err != nil { + t.Fatalf("encode: %v", err) + } + if _, err := DecodeLogoutHint([]byte("other-secret-with-at-least-32-bytes!"), fresh); err == nil { + t.Fatal("expected decode to fail with a different secret") + } +} diff --git a/backend/server/api/auth/auth.go b/backend/server/api/auth/auth.go index 7eac2b73b86..fcd088dbda1 100644 --- a/backend/server/api/auth/auth.go +++ b/backend/server/api/auth/auth.go @@ -50,11 +50,25 @@ import ( // Auth-related route paths, defined in one place so router registration and // the middleware whitelist cannot drift. const ( - PathMethods = "/auth/methods" - PathLogin = "/auth/login" - PathCallback = "/auth/callback" - PathLogout = "/auth/logout" - PathUserInfo = "/auth/userinfo" + PathMethods = "/auth/methods" + PathLogin = "/auth/login" + PathCallback = "/auth/callback" + PathLogout = "/auth/logout" + PathUserInfo = "/auth/userinfo" + PathSwitchAccount = "/auth/switch-account" +) + +// uiAuthErrorPath is the config-ui route that renders browser-facing login +// errors. Like the post-login return URL, it is relative to the UI root. +const uiAuthErrorPath = "/auth-error" + +// Error codes passed to uiAuthErrorPath. Technical details only go to the +// server log; the UI maps each code to a user-facing message. +const ( + AuthErrorForbidden = "forbidden" + AuthErrorExpired = "expired" + AuthErrorIdP = "idp_error" + AuthErrorServerError = "server_error" ) // lastSeenThrottle bounds DB writes to one per-jti per window. Tracking @@ -144,6 +158,8 @@ type ProviderInfo struct { type Methods struct { Providers []ProviderInfo `json:"providers,omitempty"` APIKey *APIKey `json:"apiKey,omitempty"` + // AccessRequestURL tells rejected users where to request access. + AccessRequestURL string `json:"accessRequestUrl,omitempty"` } type APIKey struct { @@ -159,6 +175,7 @@ func GetMethods(c *gin.Context) { defaultService.GetMethods(c) } func (s *Service) GetMethods(c *gin.Context) { out := Methods{APIKey: &APIKey{Enabled: true}} if s.cfg != nil && s.cfg.OIDCEnabled { + out.AccessRequestURL = s.cfg.AccessRequestURL for _, name := range s.cfg.ProviderNames() { pc := s.cfg.Providers[name] out.Providers = append(out.Providers, ProviderInfo{ @@ -183,20 +200,21 @@ func (s *Service) LoginInit(c *gin.Context) { if !s.ensureOIDC(c) { return } - name, p, ok := s.pickProvider(c, c.Query("provider")) - if !ok { + name, p, err := s.pickProvider(c.Query("provider")) + if err != nil { + s.failRedirect(c, AuthErrorServerError, "pick provider", err, "") return } returnURL := safeReturnURL(c.Query("return_url")) verifier, err := newPKCEVerifier() if err != nil { - fail(c, http.StatusInternalServerError, "pkce generate", err) + s.failRedirect(c, AuthErrorServerError, "pkce generate", err, name) return } nonce, err := oidchelper.NewNonce() if err != nil { - fail(c, http.StatusInternalServerError, "state nonce", err) + s.failRedirect(c, AuthErrorServerError, "state nonce", err, name) return } encoded, err := oidchelper.EncodeState(s.cfg.SessionSecret, &oidchelper.StatePayload{ @@ -207,16 +225,16 @@ func (s *Service) LoginInit(c *gin.Context) { IssuedAt: time.Now(), }) if err != nil { - fail(c, http.StatusInternalServerError, "state encode", err) + s.failRedirect(c, AuthErrorServerError, "state encode", err, name) return } - oidchelper.SetStateCookie(c, s.cfg, encoded) oa, err := p.OAuth2Config(c.Request.Context()) if err != nil { - fail(c, http.StatusBadGateway, "oauth2 config", err) + s.failRedirect(c, AuthErrorIdP, "oauth2 config", err, name) return } + oidchelper.SetStateCookie(c, s.cfg, encoded) authURL := oa.AuthCodeURL(nonce, oauth2.SetAuthURLParam("code_challenge", pkceChallenge(verifier)), oauth2.SetAuthURLParam("code_challenge_method", "S256"), @@ -237,40 +255,40 @@ func (s *Service) Callback(c *gin.Context) { encoded, err := c.Cookie(oidchelper.StateCookieName) if err != nil || encoded == "" { - fail(c, http.StatusBadRequest, "missing state cookie", err) + s.failRedirect(c, AuthErrorExpired, "missing state cookie", err, "") return } oidchelper.ClearStateCookie(c, s.cfg) state, err := oidchelper.DecodeState(s.cfg.SessionSecret, encoded) if err != nil { - fail(c, http.StatusBadRequest, "state decode", err) + s.failRedirect(c, AuthErrorExpired, "state decode", err, "") return } // Constant-time compare even though the nonce is non-secret. Keeps the // auth path free of timing-leak audit questions. if subtle.ConstantTimeCompare([]byte(c.Query("state")), []byte(state.Nonce)) != 1 { - fail(c, http.StatusBadRequest, "state mismatch", nil) + s.failRedirect(c, AuthErrorExpired, "state mismatch", nil, "") return } p, ok := s.providers[state.Provider] if !ok { - fail(c, http.StatusBadRequest, "unknown provider in state: "+state.Provider, nil) + s.failRedirect(c, AuthErrorExpired, "unknown provider in state: "+state.Provider, nil, "") return } code := c.Query("code") if code == "" { if msg := c.Query("error"); msg != "" { - fail(c, http.StatusBadRequest, "idp error: "+msg+" "+c.Query("error_description"), nil) + s.failRedirect(c, AuthErrorIdP, "idp error: "+msg+" "+c.Query("error_description"), nil, state.Provider) return } - fail(c, http.StatusBadRequest, "missing authorization code", nil) + s.failRedirect(c, AuthErrorIdP, "missing authorization code", nil, state.Provider) return } oa, err := p.OAuth2Config(c.Request.Context()) if err != nil { - fail(c, http.StatusBadGateway, "oauth2 config", err) + s.failRedirect(c, AuthErrorIdP, "oauth2 config", err, state.Provider) return } exchangeOpts := []oauth2.AuthCodeOption{ @@ -279,7 +297,7 @@ func (s *Service) Callback(c *gin.Context) { if pc := s.cfg.Providers[state.Provider]; pc != nil && pc.UseWorkloadIdentity { assertion, werr := oidchelper.FederatedAssertion() if werr != nil { - fail(c, http.StatusInternalServerError, "workload identity assertion", werr) + s.failRedirect(c, AuthErrorServerError, "workload identity assertion", werr, state.Provider) return } exchangeOpts = append(exchangeOpts, @@ -289,39 +307,50 @@ func (s *Service) Callback(c *gin.Context) { } tok, err := oa.Exchange(c.Request.Context(), code, exchangeOpts...) if err != nil { - fail(c, http.StatusBadGateway, "code exchange", err) + s.failRedirect(c, AuthErrorIdP, "code exchange", err, state.Provider) return } rawID, _ := tok.Extra("id_token").(string) if rawID == "" { - fail(c, http.StatusBadGateway, "id_token missing from token response", nil) + s.failRedirect(c, AuthErrorIdP, "id_token missing from token response", nil, state.Provider) return } idTok, err := p.VerifyIDToken(c.Request.Context(), rawID) if err != nil { - fail(c, http.StatusUnauthorized, "id_token verify", err) + s.failRedirect(c, AuthErrorIdP, "id_token verify", err, state.Provider) return } sub, email, name, err := extractUser(idTok) if err != nil { - fail(c, http.StatusBadGateway, "extract claims", err) + s.failRedirect(c, AuthErrorIdP, "extract claims", err, state.Provider) return } if !s.cfg.IsUserAllowed(email) { - fail(c, http.StatusForbidden, "user is not allowed", nil) + // The IdP session stays alive, so remember the id_token to let + // "sign in with a different account" end it without a prompt. + hint, herr := oidchelper.EncodeLogoutHint(s.cfg.SessionSecret, &oidchelper.LogoutHintPayload{ + Provider: state.Provider, + IDToken: rawID, + IssuedAt: time.Now(), + }) + if herr == nil { + oidchelper.SetLogoutHintCookie(c, s.cfg, hint) + } + s.logger.Warn(nil, "oidc login rejected: provider=%s sub=%s email=%s is not allowed", state.Provider, sub, email) + s.redirectAuthError(c, AuthErrorForbidden, state.Provider) return } jti := uuid.NewString() jwt, expiresAt, err := oidchelper.IssueSession(s.cfg, jti, state.Provider, sub, email, name) if err != nil { - fail(c, http.StatusInternalServerError, "issue session", err) + s.failRedirect(c, AuthErrorServerError, "issue session", err, state.Provider) return } now := time.Now() - if dbErr := CreateSession(s.db, &AuthSession{ + session := &AuthSession{ Jti: jti, Sub: sub, Email: email, @@ -329,22 +358,68 @@ func (s *Service) Callback(c *gin.Context) { IssuedAt: now, ExpiresAt: expiresAt, LastSeenAt: now, - }); dbErr != nil { - fail(c, http.StatusInternalServerError, "persist session", dbErr) + } + if s.cfg.LogoutRedirect { + session.IDToken = rawID + } + if dbErr := CreateSession(s.db, session); dbErr != nil { + s.failRedirect(c, AuthErrorServerError, "persist session", dbErr, state.Provider) return } csrf, err := oidchelper.NewCSRFToken() if err != nil { - fail(c, http.StatusInternalServerError, "csrf token", err) + s.failRedirect(c, AuthErrorServerError, "csrf token", err, state.Provider) return } oidchelper.SetSessionCookie(c, s.cfg, jwt) oidchelper.SetCSRFCookie(c, s.cfg, csrf) + oidchelper.ClearLogoutHintCookie(c, s.cfg) s.logger.Info("oidc login: provider=%s sub=%s email=%s jti=%s", state.Provider, sub, email, jti) c.Redirect(http.StatusSeeOther, state.ReturnURL) } +func SwitchAccount(c *gin.Context) { defaultService.SwitchAccount(c) } + +// SwitchAccount ends the IdP session of a user whose login DevLake rejected, +// so the next sign-in prompts for credentials again. The IdP then returns +// the user to the provider's post-logout redirect URL. +// +// @Summary Sign out at the IdP to sign in with a different account +// @Tags framework/auth +// @Param provider query string false "provider name (used when the logout hint cookie is missing)" +// @Success 303 +// @Router /auth/switch-account [get] +func (s *Service) SwitchAccount(c *gin.Context) { + if !s.ensureOIDC(c) { + return + } + providerName, idTokenHint := c.Query("provider"), "" + if raw, err := c.Cookie(oidchelper.LogoutHintCookieName); err == nil && raw != "" { + oidchelper.ClearLogoutHintCookie(c, s.cfg) + if hint, err := oidchelper.DecodeLogoutHint(s.cfg.SessionSecret, raw); err == nil { + providerName, idTokenHint = hint.Provider, hint.IDToken + } else { + s.logger.Debug("auth: ignore logout hint cookie: %v", err) + } + } + _, p, err := s.pickProvider(providerName) + if err != nil { + s.failRedirect(c, AuthErrorServerError, "switch account: pick provider", err, "") + return + } + u, err := p.EndSessionURL(c.Request.Context(), idTokenHint) + if err != nil { + s.failRedirect(c, AuthErrorIdP, "switch account: end_session_endpoint", err, p.Name()) + return + } + if u == "" { + s.logger.Warn(nil, "auth: provider %s has no end_session_endpoint; cannot end IdP session", p.Name()) + u = "/" + } + c.Redirect(http.StatusSeeOther, u) +} + type logoutResponse struct { OK bool `json:"ok"` LogoutURL string `json:"logoutUrl,omitempty"` @@ -362,11 +437,19 @@ func (s *Service) Logout(c *gin.Context) { return } - var sessionProvider string + var sessionProvider, idTokenHint string if len(s.cfg.SessionSecret) > 0 { if raw, err := c.Cookie(oidchelper.SessionCookieName); err == nil && raw != "" { if claims, err := oidchelper.ParseSession(s.cfg.SessionSecret, raw); err == nil && claims.ID != "" { sessionProvider = claims.Provider + if s.cfg.OIDCEnabled && s.cfg.LogoutRedirect && sessionProvider != "" { + // Read before revoking: revocation clears the stored token. + if tok, err := GetSessionIDToken(s.db, claims.ID); err != nil { + s.logger.Warn(err, "auth: load id_token for logout hint") + } else { + idTokenHint = tok + } + } if err := RevokeSession(s.db, claims.ID); err != nil { s.logger.Error(err, "auth: revoke session row") } @@ -380,7 +463,7 @@ func (s *Service) Logout(c *gin.Context) { out := logoutResponse{OK: true} if s.cfg.OIDCEnabled && s.cfg.LogoutRedirect && sessionProvider != "" { if p, ok := s.providers[sessionProvider]; ok { - if u, err := p.EndSessionURL(c.Request.Context()); err == nil && u != "" { + if u, err := p.EndSessionURL(c.Request.Context(), idTokenHint); err == nil && u != "" { out.LogoutURL = u } } @@ -418,24 +501,21 @@ func (s *Service) UserInfo(c *gin.Context) { // pickProvider resolves the requested provider name. Empty names are allowed // only when exactly one provider is configured (single-IdP convenience). -func (s *Service) pickProvider(c *gin.Context, requested string) (string, *oidchelper.Provider, bool) { +func (s *Service) pickProvider(requested string) (string, *oidchelper.Provider, error) { name := strings.ToLower(strings.TrimSpace(requested)) if name == "" { - if len(s.providers) == 1 { - for n := range s.providers { - name = n - } - } else { - fail(c, http.StatusBadRequest, "provider query param required (multiple configured)", nil) - return "", nil, false + if len(s.providers) != 1 { + return "", nil, fmt.Errorf("provider query param required (multiple configured)") + } + for n := range s.providers { + name = n } } p, ok := s.providers[name] if !ok { - fail(c, http.StatusBadRequest, "unknown provider: "+name, nil) - return "", nil, false + return "", nil, fmt.Errorf("unknown provider: %s", name) } - return name, p, true + return name, p, nil } func (s *Service) ensureOIDC(c *gin.Context) bool { @@ -465,12 +545,19 @@ func (s *Service) bumpLastSeen(jti string) { }() } -func fail(c *gin.Context, status int, msg string, cause error) { - wrapped := errors.HttpStatus(status).New(msg) - if cause != nil { - wrapped = errors.HttpStatus(status).Wrap(cause, msg) +// failRedirect logs a browser-facing login failure and sends the user to the +// UI error page. Only the coarse error code reaches the browser. +func (s *Service) failRedirect(c *gin.Context, code, msg string, cause error, provider string) { + s.logger.Error(cause, "oidc login failed (%s): %s", code, msg) + s.redirectAuthError(c, code, provider) +} + +func (s *Service) redirectAuthError(c *gin.Context, code, provider string) { + q := url.Values{"code": {code}} + if provider != "" { + q.Set("provider", provider) } - shared.ApiOutputError(c, wrapped) + c.Redirect(http.StatusSeeOther, uiAuthErrorPath+"?"+q.Encode()) } // safeReturnURL prevents open-redirect by stripping anything that doesn't diff --git a/backend/server/api/auth/handlers_test.go b/backend/server/api/auth/handlers_test.go index 1a279ce5fbb..9347a7b8bb6 100644 --- a/backend/server/api/auth/handlers_test.go +++ b/backend/server/api/auth/handlers_test.go @@ -36,6 +36,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" "github.com/apache/devlake/helpers/oidchelper" "github.com/apache/devlake/impls/logruslog" mockdal "github.com/apache/devlake/mocks/core/dal" @@ -169,6 +170,8 @@ func newTestService(t *testing.T, idp *fakeIdP) (*Service, *mockdal.Dal) { RedirectURL: "http://localhost/auth/callback", Scopes: []string{"openid", "profile", "email"}, DisplayName: "Test IdP", + + PostLogoutRedirectURL: "http://localhost/", } cfg := &oidchelper.Config{ AuthEnabled: true, @@ -180,8 +183,27 @@ func newTestService(t *testing.T, idp *fakeIdP) (*Service, *mockdal.Dal) { CookieSecure: false, } db := &mockdal.Dal{} - db.On("Create", mock.Anything, mock.Anything).Return(nil) + // Remember the last persisted session so First can serve its id_token. + var lastSession AuthSession + var lastSessionMu sync.Mutex + recordSession := func(args mock.Arguments) { + if row, ok := args.Get(0).(*AuthSession); ok { + lastSessionMu.Lock() + lastSession = *row + lastSessionMu.Unlock() + } + } + db.On("Create", mock.Anything).Run(recordSession).Return(nil) + db.On("Create", mock.Anything, mock.Anything).Run(recordSession).Return(nil) + db.On("First", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + if dst, ok := args.Get(0).(*AuthSession); ok { + lastSessionMu.Lock() + dst.IDToken = lastSession.IDToken + lastSessionMu.Unlock() + } + }).Return(nil) db.On("UpdateColumn", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + db.On("UpdateColumns", mock.Anything, mock.Anything, mock.Anything).Return(nil) db.On("All", mock.Anything, mock.Anything).Return(nil) db.On("Delete", mock.Anything, mock.Anything).Return(nil) @@ -206,6 +228,8 @@ func newTestRouter(s *Service) *gin.Engine { r.GET(PathCallback, s.Callback) r.GET(PathUserInfo, s.UserInfo) r.POST(PathLogout, s.Logout) + r.GET(PathSwitchAccount, s.SwitchAccount) + r.GET(PathMethods, s.GetMethods) return r } @@ -283,7 +307,10 @@ func TestFullLoginCallbackFlow(t *testing.T) { if sessionCookie.Value == "" || csrfCookie.Value == "" { t.Fatal("expected session and csrf cookies to be set") } - db.AssertCalled(t, "Create", mock.Anything, mock.Anything) + created, ok := db.Calls[indexOfCall(db, "Create")].Arguments.Get(0).(*AuthSession) + if !ok || created.IDToken == "" { + t.Fatalf("expected the session row to persist the id_token, got %+v", created) + } // 3. /auth/userinfo with the session cookie should report authenticated. uiReq := httptest.NewRequest(http.MethodGet, PathUserInfo, nil) @@ -311,7 +338,14 @@ func TestFullLoginCallbackFlow(t *testing.T) { if logoutW.Code != http.StatusOK { t.Fatalf("logout: expected 200, got %d body=%s", logoutW.Code, logoutW.Body.String()) } - db.AssertCalled(t, "UpdateColumn", mock.Anything, "revoked_at", mock.Anything, mock.Anything) + db.AssertCalled(t, "UpdateColumns", mock.Anything, mock.MatchedBy(func(set []dal.DalSet) bool { + return len(set) == 2 && set[0].ColumnName == "revoked_at" && set[1].ColumnName == "id_token" && set[1].Value == "" + }), mock.Anything) + var logoutBody logoutResponse + if err := json.Unmarshal(logoutW.Body.Bytes(), &logoutBody); err != nil { + t.Fatalf("decode logout: %v: %s", err, logoutW.Body.String()) + } + assertEndSessionURL(t, idp, logoutBody.LogoutURL, created.IDToken) // 5. The same session cookie now belongs to a revoked jti; userinfo // should report not authenticated. @@ -341,12 +375,7 @@ func TestCallbackRejectsStateMismatch(t *testing.T) { cbReq.AddCookie(stateCookie) cbW := httptest.NewRecorder() r.ServeHTTP(cbW, cbReq) - if cbW.Code != http.StatusBadRequest { - t.Fatalf("expected 400 on state mismatch, got %d", cbW.Code) - } - if !strings.Contains(cbW.Body.String(), "state mismatch") { - t.Fatalf("expected state mismatch in body, got %s", cbW.Body.String()) - } + assertAuthErrorRedirect(t, cbW, AuthErrorExpired, "") } func TestCallbackRejectsMissingStateCookie(t *testing.T) { @@ -357,12 +386,7 @@ func TestCallbackRejectsMissingStateCookie(t *testing.T) { cbReq := httptest.NewRequest(http.MethodGet, PathCallback+"?code=x&state=anything", nil) cbW := httptest.NewRecorder() r.ServeHTTP(cbW, cbReq) - if cbW.Code != http.StatusBadRequest { - t.Fatalf("expected 400 with missing state cookie, got %d", cbW.Code) - } - if !strings.Contains(cbW.Body.String(), "missing state cookie") { - t.Fatalf("expected missing state cookie in body, got %s", cbW.Body.String()) - } + assertAuthErrorRedirect(t, cbW, AuthErrorExpired, "") } func TestCSRFRequiredOnUnsafeMethod(t *testing.T) { @@ -421,6 +445,258 @@ func TestLogoutPublicWhenNoSession(t *testing.T) { } } +// indexOfCall returns the index of the first recorded call to method. +func indexOfCall(db *mockdal.Dal, method string) int { + for i, c := range db.Calls { + if c.Method == method { + return i + } + } + panic("no call to " + method) +} + +// assertAuthErrorRedirect checks that w redirects to the UI error page with +// the given code (and provider, if non-empty) and leaks no error details. +func assertAuthErrorRedirect(t *testing.T, w *httptest.ResponseRecorder, code, provider string) { + t.Helper() + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d body=%s", w.Code, w.Body.String()) + } + loc, err := url.Parse(w.Header().Get("Location")) + if err != nil { + t.Fatalf("parse Location: %v", err) + } + if loc.Path != uiAuthErrorPath || loc.IsAbs() { + t.Fatalf("expected relative redirect to %s, got %s", uiAuthErrorPath, loc) + } + q := loc.Query() + if q.Get("code") != code { + t.Fatalf("code = %q, want %q (Location=%s)", q.Get("code"), code, loc) + } + if q.Get("provider") != provider { + t.Fatalf("provider = %q, want %q (Location=%s)", q.Get("provider"), provider, loc) + } + if len(q) > 2 { + t.Fatalf("unexpected extra query params in %s", loc) + } +} + +// assertEndSessionURL checks an RP-initiated logout URL against the fake IdP. +func assertEndSessionURL(t *testing.T, idp *fakeIdP, raw, wantHint string) { + t.Helper() + u, err := url.Parse(raw) + if err != nil || raw == "" { + t.Fatalf("invalid end-session URL %q: %v", raw, err) + } + if got := u.Scheme + "://" + u.Host + u.Path; got != idp.issuer+"/logout" { + t.Fatalf("end-session URL %q does not target the IdP logout endpoint", raw) + } + q := u.Query() + if q.Get("client_id") != "test-client" { + t.Errorf("client_id = %q", q.Get("client_id")) + } + if q.Get("post_logout_redirect_uri") != "http://localhost/" { + t.Errorf("post_logout_redirect_uri = %q", q.Get("post_logout_redirect_uri")) + } + if q.Get("id_token_hint") != wantHint { + t.Errorf("id_token_hint = %q, want %q", q.Get("id_token_hint"), wantHint) + } +} + +// startLogin runs /auth/login and returns the state cookie and its nonce. +func startLogin(t *testing.T, r *gin.Engine, s *Service) (*http.Cookie, string) { + t.Helper() + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, PathLogin+"?provider=test", nil)) + if w.Code != http.StatusSeeOther { + t.Fatalf("login: expected 303, got %d", w.Code) + } + c := extractCookie(t, w.Result(), oidchelper.StateCookieName) + return c, stateNonceFromCookie(t, s.cfg.SessionSecret, c.Value) +} + +func findCookie(resp *http.Response, name string) *http.Cookie { + for _, c := range resp.Cookies() { + if c.Name == name { + return c + } + } + return nil +} + +func TestCallbackForbiddenRedirectsToErrorPage(t *testing.T) { + idp := newFakeIdP(t) + s, db := newTestService(t, idp) + s.cfg.AllowDomains = map[string]struct{}{"other.example": {}} + r := newTestRouter(s) + + stateCookie, nonce := startLogin(t, r, s) + cbReq := httptest.NewRequest(http.MethodGet, PathCallback+"?code=c&state="+url.QueryEscape(nonce), nil) + cbReq.AddCookie(stateCookie) + cbW := httptest.NewRecorder() + r.ServeHTTP(cbW, cbReq) + + assertAuthErrorRedirect(t, cbW, AuthErrorForbidden, "test") + resp := cbW.Result() + if c := findCookie(resp, oidchelper.SessionCookieName); c != nil && c.Value != "" { + t.Fatal("rejected user must not get a session cookie") + } + db.AssertNotCalled(t, "Create", mock.Anything) + hint := extractCookie(t, resp, oidchelper.LogoutHintCookieName) + if !hint.HttpOnly || hint.Value == "" { + t.Fatalf("expected an HttpOnly logout hint cookie, got %+v", hint) + } + + // "Sign in with a different account" ends the IdP session using the + // id_token from the hint and clears the cookie. + swReq := httptest.NewRequest(http.MethodGet, PathSwitchAccount, nil) + swReq.AddCookie(hint) + swW := httptest.NewRecorder() + r.ServeHTTP(swW, swReq) + if swW.Code != http.StatusSeeOther { + t.Fatalf("switch-account: expected 303, got %d body=%s", swW.Code, swW.Body.String()) + } + decoded, err := oidchelper.DecodeLogoutHint(s.cfg.SessionSecret, hint.Value) + if err != nil { + t.Fatalf("decode hint: %v", err) + } + assertEndSessionURL(t, idp, swW.Header().Get("Location"), decoded.IDToken) + if c := findCookie(swW.Result(), oidchelper.LogoutHintCookieName); c == nil || c.MaxAge >= 0 { + t.Fatalf("expected logout hint cookie to be cleared, got %+v", c) + } +} + +func TestSwitchAccountWithoutHint(t *testing.T) { + idp := newFakeIdP(t) + s, _ := newTestService(t, idp) + r := newTestRouter(s) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, PathSwitchAccount+"?provider=test", nil)) + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d body=%s", w.Code, w.Body.String()) + } + assertEndSessionURL(t, idp, w.Header().Get("Location"), "") +} + +func TestSwitchAccountUnknownProvider(t *testing.T) { + idp := newFakeIdP(t) + s, _ := newTestService(t, idp) + r := newTestRouter(s) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, PathSwitchAccount+"?provider=nope", nil)) + assertAuthErrorRedirect(t, w, AuthErrorServerError, "") +} + +func TestCallbackIdPErrorRedirectsToErrorPage(t *testing.T) { + idp := newFakeIdP(t) + s, _ := newTestService(t, idp) + r := newTestRouter(s) + + stateCookie, nonce := startLogin(t, r, s) + cbReq := httptest.NewRequest(http.MethodGet, + PathCallback+"?error=access_denied&error_description=denied&state="+url.QueryEscape(nonce), nil) + cbReq.AddCookie(stateCookie) + cbW := httptest.NewRecorder() + r.ServeHTTP(cbW, cbReq) + assertAuthErrorRedirect(t, cbW, AuthErrorIdP, "test") +} + +func TestCallbackPersistFailureRedirectsToErrorPage(t *testing.T) { + idp := newFakeIdP(t) + s, _ := newTestService(t, idp) + failing := &mockdal.Dal{} + failing.On("Create", mock.Anything).Return(errors.Default.New("db down")) + s.db = failing + r := newTestRouter(s) + + stateCookie, nonce := startLogin(t, r, s) + cbReq := httptest.NewRequest(http.MethodGet, PathCallback+"?code=c&state="+url.QueryEscape(nonce), nil) + cbReq.AddCookie(stateCookie) + cbW := httptest.NewRecorder() + r.ServeHTTP(cbW, cbReq) + assertAuthErrorRedirect(t, cbW, AuthErrorServerError, "test") +} + +func TestLoginInitUnknownProviderRedirectsToErrorPage(t *testing.T) { + idp := newFakeIdP(t) + s, _ := newTestService(t, idp) + r := newTestRouter(s) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, PathLogin+"?provider=nope", nil)) + assertAuthErrorRedirect(t, w, AuthErrorServerError, "") +} + +func TestLoginInitUnreachableIdPRedirectsToErrorPage(t *testing.T) { + idp := newFakeIdP(t) + s, _ := newTestService(t, idp) + idp.server.Close() + r := newTestRouter(s) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, PathLogin+"?provider=test", nil)) + assertAuthErrorRedirect(t, w, AuthErrorIdP, "test") + if c := findCookie(w.Result(), oidchelper.StateCookieName); c != nil { + t.Fatal("no state cookie expected when the login cannot start") + } +} + +func TestLogoutRedirectDisabledKeepsNoIDToken(t *testing.T) { + idp := newFakeIdP(t) + s, db := newTestService(t, idp) + s.cfg.LogoutRedirect = false + r := newTestRouter(s) + + stateCookie, nonce := startLogin(t, r, s) + cbReq := httptest.NewRequest(http.MethodGet, PathCallback+"?code=c&state="+url.QueryEscape(nonce), nil) + cbReq.AddCookie(stateCookie) + cbW := httptest.NewRecorder() + r.ServeHTTP(cbW, cbReq) + if cbW.Code != http.StatusSeeOther { + t.Fatalf("callback: expected 303, got %d", cbW.Code) + } + created := db.Calls[indexOfCall(db, "Create")].Arguments.Get(0).(*AuthSession) + if created.IDToken != "" { + t.Fatal("id_token must not be stored when OIDC_LOGOUT_REDIRECT is off") + } + + sessionCookie := extractCookie(t, cbW.Result(), oidchelper.SessionCookieName) + csrfCookie := extractCookie(t, cbW.Result(), oidchelper.CSRFCookieName) + logoutReq := httptest.NewRequest(http.MethodPost, PathLogout, nil) + logoutReq.AddCookie(sessionCookie) + logoutReq.AddCookie(csrfCookie) + logoutReq.Header.Set(oidchelper.CSRFHeaderName, csrfCookie.Value) + logoutW := httptest.NewRecorder() + r.ServeHTTP(logoutW, logoutReq) + var body logoutResponse + if err := json.Unmarshal(logoutW.Body.Bytes(), &body); err != nil { + t.Fatalf("decode logout: %v", err) + } + if !body.OK || body.LogoutURL != "" { + t.Fatalf("expected local-only logout, got %+v", body) + } + db.AssertNotCalled(t, "First", mock.Anything, mock.Anything) +} + +func TestGetMethodsExposesAccessRequestURL(t *testing.T) { + idp := newFakeIdP(t) + s, _ := newTestService(t, idp) + s.cfg.AccessRequestURL = "https://intranet.example.com/access" + r := newTestRouter(s) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, PathMethods, nil)) + var m Methods + if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil { + t.Fatalf("decode methods: %v: %s", err, w.Body.String()) + } + if m.AccessRequestURL != "https://intranet.example.com/access" { + t.Fatalf("accessRequestUrl = %q", m.AccessRequestURL) + } +} + // Compile-time assertion that the testify mock satisfies the dal.Dal // interface so the tests catch any new method that gets added. var _ dal.Dal = (*mockdal.Dal)(nil) diff --git a/backend/server/api/auth/middleware.go b/backend/server/api/auth/middleware.go index e90805671ba..ef4d3bd5ae7 100644 --- a/backend/server/api/auth/middleware.go +++ b/backend/server/api/auth/middleware.go @@ -44,6 +44,7 @@ var publicPaths = map[string]struct{}{ PathCallback: {}, PathLogout: {}, PathUserInfo: {}, + PathSwitchAccount: {}, } func OIDCAuthentication() gin.HandlerFunc { return defaultService.OIDCAuthentication() } diff --git a/backend/server/api/auth/store.go b/backend/server/api/auth/store.go index 69a135162ef..35cd642e279 100644 --- a/backend/server/api/auth/store.go +++ b/backend/server/api/auth/store.go @@ -37,6 +37,9 @@ type AuthSession struct { ExpiresAt time.Time `gorm:"index" json:"expiresAt"` RevokedAt *time.Time `gorm:"index" json:"revokedAt,omitempty"` LastSeenAt time.Time `json:"lastSeenAt"` + // IDToken is the raw id_token from login, kept only to send as + // id_token_hint on RP-initiated logout. Cleared on revocation. + IDToken string `gorm:"type:text" json:"-"` } func (AuthSession) TableName() string { return "auth_sessions" } @@ -45,10 +48,26 @@ func CreateSession(db dal.Dal, s *AuthSession) errors.Error { return db.Create(s) } -// RevokeSession marks the session row revoked. No-op if no row matches. +// RevokeSession marks the session row revoked and drops its id_token. +// No-op if no row matches. func RevokeSession(db dal.Dal, jti string) errors.Error { now := time.Now() - return db.UpdateColumn(&AuthSession{}, "revoked_at", now, dal.Where("jti = ?", jti)) + return db.UpdateColumns(&AuthSession{}, []dal.DalSet{ + {ColumnName: "revoked_at", Value: now}, + {ColumnName: "id_token", Value: ""}, + }, dal.Where("jti = ?", jti)) +} + +// GetSessionIDToken returns the id_token stored for jti, or "" if none. +func GetSessionIDToken(db dal.Dal, jti string) (string, errors.Error) { + var row AuthSession + if err := db.First(&row, dal.Select("id_token"), dal.Where("jti = ?", jti)); err != nil { + if db.IsErrorNotFound(err) { + return "", nil + } + return "", err + } + return row.IDToken, nil } // UpdateLastSeen records that the session was used. Throttled by the caller diff --git a/backend/server/api/router.go b/backend/server/api/router.go index c517e349fbe..ea9ee228ddb 100644 --- a/backend/server/api/router.go +++ b/backend/server/api/router.go @@ -93,6 +93,7 @@ func RegisterRouter(r *gin.Engine, basicRes context.BasicRes) { r.GET(auth.PathCallback, auth.Callback) r.POST(auth.PathLogout, auth.Logout) r.GET(auth.PathUserInfo, auth.UserInfo) + r.GET(auth.PathSwitchAccount, auth.SwitchAccount) // mount all api resources for all plugins resources, err := services.GetPluginsApiResources() diff --git a/config-ui/src/api/auth/index.ts b/config-ui/src/api/auth/index.ts index 96b3b046a65..99c647fde35 100644 --- a/config-ui/src/api/auth/index.ts +++ b/config-ui/src/api/auth/index.ts @@ -29,6 +29,7 @@ export type Methods = { apiKey?: { enabled: boolean; }; + accessRequestUrl?: string; }; export type UserInfo = { diff --git a/config-ui/src/app/router.tsx b/config-ui/src/app/router.tsx index e7f1a9043f3..a4e891452ce 100644 --- a/config-ui/src/app/router.tsx +++ b/config-ui/src/app/router.tsx @@ -25,6 +25,7 @@ import { Layout, layoutLoader, Login, + AuthError, Connections, Connection, ProjectHomePage, @@ -54,6 +55,10 @@ export const router = createBrowserRouter([ path: `${PATH_PREFIX}/login`, element: , }, + { + path: `${PATH_PREFIX}/auth-error`, + element: , + }, { path: `${PATH_PREFIX}/onboard`, element: , diff --git a/config-ui/src/routes/auth-error/auth-error.test.tsx b/config-ui/src/routes/auth-error/auth-error.test.tsx new file mode 100644 index 00000000000..7fcc2412fbf --- /dev/null +++ b/config-ui/src/routes/auth-error/auth-error.test.tsx @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +import { AuthError } from '.'; + +const state = vi.hoisted(() => ({ methods: vi.fn() })); +vi.mock('@/api', () => ({ default: { auth: { methods: state.methods } } })); +vi.mock('@/config', () => ({ DEVLAKE_ENDPOINT: '/api' })); +vi.mock('@/components', () => ({ TipLayout: ({ children }: { children: React.ReactNode }) =>
{children}
})); + +function renderAt(search: string) { + return render( + + + } /> + Login page} /> + + , + ); +} + +describe('auth error page', () => { + beforeEach(() => { + state.methods.mockReset().mockResolvedValue({}); + }); + + it('explains a rejected account and offers access request and account switch', async () => { + state.methods.mockResolvedValue({ accessRequestUrl: 'https://intranet.example.com/access' }); + const assign = vi.fn(); + const original = window.location; + Object.defineProperty(window, 'location', { + configurable: true, + value: { + ...original, + set href(v: string) { + assign(v); + }, + }, + }); + + try { + renderAt('?code=forbidden&provider=keycloak'); + expect(screen.getByText('Access denied')).toBeTruthy(); + + const request = await screen.findByRole('link', { name: 'Request access' }); + expect(request.getAttribute('href')).toBe('https://intranet.example.com/access'); + expect(request.getAttribute('rel')).toContain('noopener'); + expect(screen.queryByRole('button', { name: 'Back to sign in' })).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Sign in with a different account' })); + expect(assign).toHaveBeenCalledWith('/api/auth/switch-account?provider=keycloak'); + } finally { + Object.defineProperty(window, 'location', { configurable: true, value: original }); + } + }); + + it('hides the access request button when no URL is configured', async () => { + renderAt('?code=forbidden'); + await waitFor(() => expect(state.methods).toHaveBeenCalled()); + expect(screen.queryByRole('link', { name: 'Request access' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Sign in with a different account' })).toBeTruthy(); + }); + + it.each([ + ['expired', 'Sign-in expired'], + ['idp_error', 'Sign-in failed'], + ['server_error', 'Something went wrong'], + ['unknown-code', 'Something went wrong'], + ['', 'Something went wrong'], + ])('shows a message for code %j and links back to sign in', (code, title) => { + renderAt(code ? `?code=${code}` : ''); + expect(screen.getByText(title)).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Sign in with a different account' })).toBeNull(); + expect(state.methods).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Back to sign in' })); + expect(screen.getByText('Login page')).toBeTruthy(); + }); +}); diff --git a/config-ui/src/routes/auth-error/index.tsx b/config-ui/src/routes/auth-error/index.tsx new file mode 100644 index 00000000000..4d8513ae5cd --- /dev/null +++ b/config-ui/src/routes/auth-error/index.tsx @@ -0,0 +1,118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { ClockCircleOutlined, CloseCircleOutlined, StopOutlined } from '@ant-design/icons'; +import { Card, Space, Flex, Button } from 'antd'; + +import API from '@/api'; +import { TipLayout } from '@/components'; +import { DEVLAKE_ENDPOINT } from '@/config'; + +const PATH_PREFIX = import.meta.env.DEVLAKE_PATH_PREFIX ?? ''; + +// Keep in sync with the AuthError* codes in backend/server/api/auth/auth.go. +type AuthErrorCode = 'forbidden' | 'expired' | 'idp_error' | 'server_error'; + +const MESSAGES: Record = { + forbidden: { + title: 'Access denied', + description: + 'You signed in successfully, but your account is not authorized to use DevLake. If you think you should have access, request it or sign in with a different account.', + }, + expired: { + title: 'Sign-in expired', + description: 'Your sign-in attempt expired or could not be verified. Please sign in again.', + }, + idp_error: { + title: 'Sign-in failed', + description: + 'The identity provider could not complete your sign-in. Please try again. If the problem persists, contact your DevLake administrator.', + }, + server_error: { + title: 'Something went wrong', + description: + 'DevLake could not complete your sign-in. Please try again. If the problem persists, contact your DevLake administrator.', + }, +}; + +const toCode = (raw: string | null): AuthErrorCode => + raw && Object.prototype.hasOwnProperty.call(MESSAGES, raw) ? (raw as AuthErrorCode) : 'server_error'; + +const ICONS: Record = { + forbidden: , + expired: , + idp_error: , + server_error: , +}; + +export const AuthError = () => { + const [searchParams] = useSearchParams(); + const code = toCode(searchParams.get('code')); + const provider = searchParams.get('provider') ?? ''; + const [accessRequestUrl, setAccessRequestUrl] = useState(); + + const navigate = useNavigate(); + + useEffect(() => { + if (code !== 'forbidden') return; + API.auth + .methods() + .then((m) => setAccessRequestUrl(m.accessRequestUrl)) + .catch(() => setAccessRequestUrl(undefined)); + }, [code]); + + // Retrying would reuse the still-active IdP session and fail again, so a + // rejected user can only end that session and pick another account. + const handleSwitchAccount = () => { + const query = provider ? `?provider=${encodeURIComponent(provider)}` : ''; + window.location.href = `${DEVLAKE_ENDPOINT}/auth/switch-account${query}`; + }; + + const { title, description } = MESSAGES[code]; + + return ( + + +

+ + {ICONS[code]} + {title} + +

+

{description}

+ + + {code === 'forbidden' && accessRequestUrl && ( + + )} + {code === 'forbidden' && } + {code !== 'forbidden' && ( + + )} + + +
+
+ ); +}; diff --git a/config-ui/src/routes/index.ts b/config-ui/src/routes/index.ts index 8550e0a0f9d..7c295562a78 100644 --- a/config-ui/src/routes/index.ts +++ b/config-ui/src/routes/index.ts @@ -17,6 +17,7 @@ */ export * from './api-keys'; +export * from './auth-error'; export * from './blueprint'; export * from './connection'; export * from './db-migrate'; diff --git a/env.example b/env.example index 9a4acb0dc51..992bb612cca 100755 --- a/env.example +++ b/env.example @@ -168,6 +168,11 @@ OIDC_ENTRA_REDIRECT_URL= OIDC_ENTRA_SCOPES=openid,profile,email # Label rendered on the UI login button. OIDC_ENTRA_DISPLAY_NAME=Entra ID +# Where the IdP sends the user after logout (post_logout_redirect_uri). +# Defaults to the origin of OIDC_ENTRA_REDIRECT_URL plus "/", i.e. the +# DevLake start page. Must be registered with the IdP as an allowed +# post-logout redirect URI (Keycloak: "Valid post logout redirect URIs"). +OIDC_ENTRA_POST_LOGOUT_REDIRECT_URL= # Authenticate the code exchange with an Azure Workload Identity federated # assertion (read from the SA token file injected by the workload-identity # webhook) instead of OIDC_ENTRA_CLIENT_SECRET. Requires the pod label @@ -186,7 +191,9 @@ OIDC_GOOGLE_SCOPES=openid,profile,email OIDC_GOOGLE_DISPLAY_NAME=Google # When true, /auth/logout returns the IdP's end_session_endpoint so the UI -# can also sign the user out at the IdP. +# can also sign the user out at the IdP. The URL carries client_id, +# id_token_hint and post_logout_redirect_uri (OIDC RP-Initiated Logout), so +# the IdP can skip its logout prompt and return the user to DevLake. OIDC_LOGOUT_REDIRECT=false # Required when OIDC_ENABLED=true. At least 32 bytes of high-entropy data. @@ -204,3 +211,6 @@ COOKIE_SECURE=true # Restrict OIDC logins to specific users or domains (comma-separated, case-insensitive) OIDC_ALLOW_EMAILS= OIDC_ALLOW_DOMAINS= +# Optional absolute http(s) URL shown as "Request access" on the login error +# page when a signed-in user is not allowed (e.g. an intranet request form). +OIDC_ACCESS_REQUEST_URL=