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:
{description}
+