diff --git a/backend/core/models/migrationscripts/20260426_add_auth_sessions.go b/backend/core/models/migrationscripts/20260426_add_auth_sessions.go new file mode 100644 index 00000000000..7e6eab8aacd --- /dev/null +++ b/backend/core/models/migrationscripts/20260426_add_auth_sessions.go @@ -0,0 +1,58 @@ +/* +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 ( + "time" + + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/migrationhelper" +) + +var _ plugin.MigrationScript = (*addAuthSessions)(nil) + +type authSession20260426 struct { + Jti string `gorm:"primaryKey;type:varchar(36)"` + Sub string `gorm:"type:varchar(255);index"` + Email string `gorm:"type:varchar(255)"` + Name string `gorm:"type:varchar(255)"` + IssuedAt time.Time `gorm:"not null"` + ExpiresAt time.Time `gorm:"index;not null"` + RevokedAt *time.Time `gorm:"index"` + LastSeenAt time.Time +} + +func (authSession20260426) TableName() string { + return "auth_sessions" +} + +type addAuthSessions struct{} + +func (*addAuthSessions) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables(basicRes, new(authSession20260426)) +} + +func (*addAuthSessions) Version() uint64 { + return 20260426000001 +} + +func (*addAuthSessions) Name() string { + return "add auth_sessions table for session revocation" +} diff --git a/backend/core/models/migrationscripts/register.go b/backend/core/models/migrationscripts/register.go index 9abb4f0aeae..b4596154e2c 100644 --- a/backend/core/models/migrationscripts/register.go +++ b/backend/core/models/migrationscripts/register.go @@ -144,5 +144,6 @@ func All() []plugin.MigrationScript { new(fixNullPriority), new(modifyCicdDeploymentsToText), new(increaseCqIssuesProjectKeyLength), + new(addAuthSessions), } } diff --git a/backend/go.mod b/backend/go.mod index b744729e1d5..47e7ad31c0f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -32,7 +32,7 @@ require ( github.com/viant/afs v1.16.0 golang.org/x/crypto v0.26.0 // indirect golang.org/x/exp v0.0.0-20221028150844-83b7d23a625f - golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602 + golang.org/x/oauth2 v0.13.0 golang.org/x/sync v0.8.0 gorm.io/datatypes v1.0.1 gorm.io/driver/mysql v1.5.1 @@ -42,6 +42,7 @@ require ( require ( github.com/chainguard-dev/git-urls v1.0.2 + github.com/coreos/go-oidc/v3 v3.9.0 github.com/go-sql-driver/mysql v1.7.1 github.com/golang-jwt/jwt/v5 v5.0.0-rc.1 github.com/merico-ai/graphql v0.0.0-20260206020408-b7fd267bcfac @@ -70,6 +71,7 @@ require ( github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.5.0 // indirect + github.com/go-jose/go-jose/v3 v3.0.1 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonreference v0.19.6 // indirect github.com/go-openapi/spec v0.20.4 // indirect @@ -79,7 +81,7 @@ require ( github.com/goccy/go-json v0.10.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -121,8 +123,8 @@ require ( golang.org/x/sys v0.23.0 // indirect golang.org/x/text v0.17.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/protobuf v1.30.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/protobuf v1.31.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 2344cde8b0a..765636622b5 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -89,6 +89,8 @@ github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZe github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/coreos/go-oidc/v3 v3.9.0 h1:0J/ogVOd4y8P0f0xUh8l9t07xRP/d8tccvjHl2dcsSo= +github.com/coreos/go-oidc/v3 v3.9.0/go.mod h1:rTKz2PYwftcrtoCzV5g5kvfJoWcm0Mk8AF8y1iAQro4= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= @@ -140,6 +142,8 @@ github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXY github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA= +github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= @@ -204,8 +208,9 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -657,8 +662,9 @@ golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602 h1:0Ja1LBD+yisY6RWM/BH7TJVXWsSjs2VwBSmvSX4HdBc= golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= +golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -754,6 +760,7 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= @@ -855,8 +862,9 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -930,8 +938,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= -google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/backend/helpers/oidchelper/config.go b/backend/helpers/oidchelper/config.go new file mode 100644 index 00000000000..4b45dfa3b43 --- /dev/null +++ b/backend/helpers/oidchelper/config.go @@ -0,0 +1,223 @@ +/* +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 ( + "fmt" + "sort" + "strings" + "time" + + "github.com/apache/incubator-devlake/core/config" + "github.com/apache/incubator-devlake/core/context" +) + +const ( + SessionCookieName = "devlake_session" + StateCookieName = "devlake_oauth_state" + // CSRFCookieName is intentionally readable by JS so axios can echo it + // back as the X-CSRF-Token header (double-submit cookie pattern). + CSRFCookieName = "devlake_csrf" + CSRFHeaderName = "X-CSRF-Token" + + // State cookie max-age. Covers a slow user typing 2FA at the IdP. + StateCookieMaxAge = 10 * time.Minute + + defaultSessionTTL = 8 * time.Hour + defaultScopes = "openid,profile,email" +) + +// ProviderConfig holds everything specific to one OIDC IdP. Multiple +// instances live in Config.Providers, keyed by Name. +type ProviderConfig struct { + Name string + IssuerURL string + ClientID string + ClientSecret string + RedirectURL string + Scopes []string + DisplayName string + + // UseWorkloadIdentity authenticates the code exchange with an Azure + // Workload Identity federated assertion (read from the SA token file) + // instead of ClientSecret. Entra-specific. + UseWorkloadIdentity bool +} + +// Config is the typed view of the auth-related env vars. Build it once at boot. +type Config struct { + AuthEnabled bool + + OIDCEnabled bool + Providers map[string]*ProviderConfig + LogoutRedirect bool + + SessionSecret []byte + SessionTTL time.Duration + + CookieDomain string + CookieSecure bool +} + +// ProviderNames returns the configured provider names in stable order. +func (c *Config) ProviderNames() []string { + if c == nil { + return nil + } + out := make([]string, 0, len(c.Providers)) + for name := range c.Providers { + out = append(out, name) + } + sort.Strings(out) + return out +} + +// LoadConfig reads auth env vars via Viper and validates required fields. +// Returns Config{AuthEnabled:false} when AUTH_ENABLED=false (the default, +// preserves historical behavior). +func LoadConfig(basicRes context.BasicRes) (*Config, error) { + cfg := basicRes.GetConfigReader() + + if !cfg.GetBool("AUTH_ENABLED") { + return &Config{AuthEnabled: false}, nil + } + + sessionSecret := strings.TrimSpace(cfg.GetString("SESSION_SECRET")) + if sessionSecret == "" { + return nil, fmt.Errorf("AUTH_ENABLED=true but SESSION_SECRET is not set") + } + if len(sessionSecret) < 32 { + return nil, fmt.Errorf("SESSION_SECRET must be at least 32 bytes") + } + + ttl := defaultSessionTTL + if v := strings.TrimSpace(cfg.GetString("SESSION_TTL")); v != "" { + parsed, err := time.ParseDuration(v) + if err != nil { + return nil, fmt.Errorf("invalid SESSION_TTL %q: %w", v, err) + } + ttl = parsed + } + + cookieSecure := true + if cfg.IsSet("COOKIE_SECURE") { + cookieSecure = cfg.GetBool("COOKIE_SECURE") + } + + out := &Config{ + AuthEnabled: true, + OIDCEnabled: cfg.GetBool("OIDC_ENABLED"), + Providers: map[string]*ProviderConfig{}, + LogoutRedirect: cfg.GetBool("OIDC_LOGOUT_REDIRECT"), + SessionSecret: []byte(sessionSecret), + SessionTTL: ttl, + CookieDomain: strings.TrimSpace(cfg.GetString("COOKIE_DOMAIN")), + CookieSecure: cookieSecure, + } + + if !out.OIDCEnabled { + return out, nil + } + + names := parseProviderNames(cfg.GetString("OIDC_PROVIDERS")) + if len(names) == 0 { + return nil, fmt.Errorf("OIDC_ENABLED=true but OIDC_PROVIDERS is empty") + } + for _, name := range names { + prefix := "OIDC_" + strings.ToUpper(name) + "_" + p, err := loadProviderConfig(cfg, name, prefix) + if err != nil { + return nil, err + } + out.Providers[name] = p + } + return out, nil +} + +func loadProviderConfig(cfg config.ConfigReader, name, prefix string) (*ProviderConfig, error) { + p := &ProviderConfig{ + Name: name, + IssuerURL: strings.TrimSpace(cfg.GetString(prefix + "ISSUER_URL")), + ClientID: strings.TrimSpace(cfg.GetString(prefix + "CLIENT_ID")), + ClientSecret: strings.TrimSpace(cfg.GetString(prefix + "CLIENT_SECRET")), + RedirectURL: strings.TrimSpace(cfg.GetString(prefix + "REDIRECT_URL")), + Scopes: parseScopes(cfg.GetString(prefix + "SCOPES")), + DisplayName: valueOr(cfg.GetString(prefix+"DISPLAY_NAME"), name), + UseWorkloadIdentity: cfg.GetBool(prefix + "USE_WORKLOAD_IDENTITY"), + } + + missing := []string{} + if p.IssuerURL == "" { + missing = append(missing, prefix+"ISSUER_URL") + } + if p.ClientID == "" { + missing = append(missing, prefix+"CLIENT_ID") + } + // Workload Identity replaces the static client secret with a federated + // assertion read at exchange time, so the secret is optional in that mode. + if p.ClientSecret == "" && !p.UseWorkloadIdentity { + missing = append(missing, prefix+"CLIENT_SECRET") + } + if p.RedirectURL == "" { + missing = append(missing, prefix+"REDIRECT_URL") + } + if len(missing) > 0 { + return nil, fmt.Errorf("provider %q missing required env vars: %s", name, strings.Join(missing, ", ")) + } + return p, nil +} + +func parseProviderNames(raw string) []string { + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + seen := map[string]struct{}{} + for _, p := range parts { + n := strings.ToLower(strings.TrimSpace(p)) + if n == "" { + continue + } + if _, dup := seen[n]; dup { + continue + } + seen[n] = struct{}{} + out = append(out, n) + } + return out +} + +func parseScopes(raw string) []string { + if strings.TrimSpace(raw) == "" { + raw = defaultScopes + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + s := strings.TrimSpace(p) + if s != "" { + out = append(out, s) + } + } + return out +} + +func valueOr(s, fallback string) string { + if strings.TrimSpace(s) == "" { + return fallback + } + return s +} diff --git a/backend/helpers/oidchelper/config_test.go b/backend/helpers/oidchelper/config_test.go new file mode 100644 index 00000000000..1924fbb5397 --- /dev/null +++ b/backend/helpers/oidchelper/config_test.go @@ -0,0 +1,86 @@ +/* +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 ( + "reflect" + "testing" +) + +func TestParseScopes(t *testing.T) { + cases := map[string][]string{ + "": {"openid", "profile", "email"}, + " ": {"openid", "profile", "email"}, + "openid": {"openid"}, + "openid,profile": {"openid", "profile"}, + " openid , profile , email ": {"openid", "profile", "email"}, + "openid,,email": {"openid", "email"}, + } + for input, want := range cases { + got := parseScopes(input) + if !reflect.DeepEqual(got, want) { + t.Errorf("parseScopes(%q) = %v, want %v", input, got, want) + } + } +} + +func TestValueOr(t *testing.T) { + if got := valueOr("hello", "fallback"); got != "hello" { + t.Errorf("valueOr(hello) = %q", got) + } + if got := valueOr("", "fallback"); got != "fallback" { + t.Errorf("valueOr(empty) = %q", got) + } + if got := valueOr(" ", "fallback"); got != "fallback" { + t.Errorf("valueOr(whitespace) = %q", got) + } +} + +func TestParseProviderNames(t *testing.T) { + cases := map[string][]string{ + "": {}, + " ": {}, + "entra": {"entra"}, + "entra,google": {"entra", "google"}, + " Entra , GOOGLE": {"entra", "google"}, + "entra,,google": {"entra", "google"}, + "entra,entra": {"entra"}, + "Entra,entra": {"entra"}, + } + for input, want := range cases { + got := parseProviderNames(input) + if len(got) == 0 && len(want) == 0 { + continue + } + if !reflect.DeepEqual(got, want) { + t.Errorf("parseProviderNames(%q) = %v, want %v", input, got, want) + } + } +} + +func TestProviderNamesSorted(t *testing.T) { + c := &Config{ + Providers: map[string]*ProviderConfig{ + "google": {Name: "google"}, + "entra": {Name: "entra"}, + }, + } + if names := c.ProviderNames(); !reflect.DeepEqual(names, []string{"entra", "google"}) { + t.Errorf("ProviderNames = %v, want sorted [entra google]", names) + } +} diff --git a/backend/helpers/oidchelper/cookie.go b/backend/helpers/oidchelper/cookie.go new file mode 100644 index 00000000000..c628d07e864 --- /dev/null +++ b/backend/helpers/oidchelper/cookie.go @@ -0,0 +1,63 @@ +/* +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 ( + "net/http" + + "github.com/gin-gonic/gin" +) + +func writeCookie(c *gin.Context, cfg *Config, name, value string, maxAge int, httpOnly bool) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: name, + Value: value, + Path: "/", + Domain: cfg.CookieDomain, + MaxAge: maxAge, + HttpOnly: httpOnly, + Secure: cfg.CookieSecure, + SameSite: http.SameSiteLaxMode, + }) +} + +func SetSessionCookie(c *gin.Context, cfg *Config, jwt string) { + writeCookie(c, cfg, SessionCookieName, jwt, int(cfg.SessionTTL.Seconds()), true) +} + +func ClearSessionCookie(c *gin.Context, cfg *Config) { + writeCookie(c, cfg, SessionCookieName, "", -1, true) +} + +func SetStateCookie(c *gin.Context, cfg *Config, value string) { + writeCookie(c, cfg, StateCookieName, value, int(StateCookieMaxAge.Seconds()), true) +} + +func ClearStateCookie(c *gin.Context, cfg *Config) { + writeCookie(c, cfg, StateCookieName, "", -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) { + writeCookie(c, cfg, CSRFCookieName, token, int(cfg.SessionTTL.Seconds()), false) +} + +func ClearCSRFCookie(c *gin.Context, cfg *Config) { + writeCookie(c, cfg, CSRFCookieName, "", -1, false) +} diff --git a/backend/helpers/oidchelper/provider.go b/backend/helpers/oidchelper/provider.go new file mode 100644 index 00000000000..bf2b10ac55b --- /dev/null +++ b/backend/helpers/oidchelper/provider.go @@ -0,0 +1,126 @@ +/* +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" + "fmt" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// minRefreshInterval rate-limits provider re-discovery so a flood of bad +// tokens cannot stampede the IdP's well-known endpoint. +const minRefreshInterval = 30 * time.Second + +// Provider lazily resolves the OIDC discovery document for one IdP and +// caches the resulting *oidc.Provider. Re-initializes on signature- +// verification failure to handle JWKS key rotation. +type Provider struct { + cfg *ProviderConfig + + mu sync.RWMutex + provider *oidc.Provider + lastRefresh time.Time +} + +func NewProvider(cfg *ProviderConfig) *Provider { + return &Provider{cfg: cfg} +} + +func (p *Provider) Name() string { return p.cfg.Name } + +func (p *Provider) OIDC(ctx context.Context) (*oidc.Provider, error) { + p.mu.RLock() + cached := p.provider + p.mu.RUnlock() + if cached != nil { + return cached, nil + } + return p.refresh(ctx) +} + +// refresh re-initializes the provider, but no more often than +// minRefreshInterval to prevent a stampede of failed verifications from +// hammering the IdP. Callers receive the cached provider when rate-limited. +func (p *Provider) refresh(ctx context.Context) (*oidc.Provider, error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.provider != nil && time.Since(p.lastRefresh) < minRefreshInterval { + return p.provider, nil + } + prov, err := oidc.NewProvider(ctx, p.cfg.IssuerURL) + if err != nil { + return nil, fmt.Errorf("oidc discovery (%s): %w", p.cfg.IssuerURL, err) + } + p.provider = prov + p.lastRefresh = time.Now() + return prov, nil +} + +func (p *Provider) OAuth2Config(ctx context.Context) (*oauth2.Config, error) { + prov, err := p.OIDC(ctx) + if err != nil { + return nil, err + } + return &oauth2.Config{ + ClientID: p.cfg.ClientID, + ClientSecret: p.cfg.ClientSecret, + RedirectURL: p.cfg.RedirectURL, + Endpoint: prov.Endpoint(), + Scopes: p.cfg.Scopes, + }, nil +} + +// VerifyIDToken validates the raw ID token against the provider's JWKS. +// On signature-verification failure it forces a JWKS refresh (subject to +// minRefreshInterval) and retries once, transparently handling key rotation. +func (p *Provider) VerifyIDToken(ctx context.Context, raw string) (*oidc.IDToken, error) { + prov, err := p.OIDC(ctx) + if err != nil { + return nil, err + } + verifier := prov.Verifier(&oidc.Config{ClientID: p.cfg.ClientID}) + tok, err := verifier.Verify(ctx, raw) + if err == nil { + return tok, nil + } + prov, refreshErr := p.refresh(ctx) + if refreshErr != nil { + return nil, fmt.Errorf("verify id_token: %w (refresh also failed: %v)", err, refreshErr) + } + verifier = prov.Verifier(&oidc.Config{ClientID: p.cfg.ClientID}) + return verifier.Verify(ctx, raw) +} + +func (p *Provider) EndSessionURL(ctx context.Context) (string, error) { + prov, err := p.OIDC(ctx) + if err != nil { + return "", err + } + var claims struct { + EndSessionEndpoint string `json:"end_session_endpoint"` + } + if err := prov.Claims(&claims); err != nil { + return "", err + } + return claims.EndSessionEndpoint, nil +} diff --git a/backend/helpers/oidchelper/session.go b/backend/helpers/oidchelper/session.go new file mode 100644 index 00000000000..3421cb99b2a --- /dev/null +++ b/backend/helpers/oidchelper/session.go @@ -0,0 +1,85 @@ +/* +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 ( + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const sessionIssuer = "devlake" + +// sessionParser is reused across the per-request session-validation path to +// avoid re-allocating the validator options on every call. +var sessionParser = jwt.NewParser( + jwt.WithIssuer(sessionIssuer), + jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), +) + +type SessionClaims struct { + Provider string `json:"prv,omitempty"` + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` + jwt.RegisteredClaims +} + +// IssueSession signs a session JWT carrying the jti, the provider name (so +// /auth/logout can find the right end_session_endpoint), and the user-facing +// claims. The jti lets the server-side revocation table address one session. +func IssueSession(cfg *Config, jti, provider, sub, email, name string) (string, time.Time, error) { + now := time.Now() + expiresAt := now.Add(cfg.SessionTTL) + claims := SessionClaims{ + Provider: provider, + Email: email, + Name: name, + RegisteredClaims: jwt.RegisteredClaims{ + ID: jti, + Issuer: sessionIssuer, + Subject: sub, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(expiresAt), + NotBefore: jwt.NewNumericDate(now), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString(cfg.SessionSecret) + if err != nil { + return "", time.Time{}, err + } + return signed, expiresAt, nil +} + +func ParseSession(secret []byte, raw string) (*SessionClaims, error) { + parsed, err := sessionParser.ParseWithClaims(raw, &SessionClaims{}, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return secret, nil + }) + if err != nil { + return nil, err + } + claims, ok := parsed.Claims.(*SessionClaims) + if !ok || !parsed.Valid { + return nil, fmt.Errorf("invalid session token") + } + return claims, nil +} diff --git a/backend/helpers/oidchelper/session_test.go b/backend/helpers/oidchelper/session_test.go new file mode 100644 index 00000000000..588210e88b0 --- /dev/null +++ b/backend/helpers/oidchelper/session_test.go @@ -0,0 +1,86 @@ +/* +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 ( + "testing" + "time" +) + +func newTestCfg(secret string, ttl time.Duration) *Config { + return &Config{SessionSecret: []byte(secret), SessionTTL: ttl} +} + +func TestSessionRoundTrip(t *testing.T) { + cfg := newTestCfg("session-test-secret-32-bytes!!", time.Hour) + jwt, exp, err := IssueSession(cfg, "jti-1", "entra", "user-1", "u@example.com", "Alice") + if err != nil { + t.Fatalf("issue: %v", err) + } + if exp.IsZero() { + t.Fatalf("expected non-zero expiry") + } + claims, err := ParseSession(cfg.SessionSecret, jwt) + if err != nil { + t.Fatalf("parse: %v", err) + } + if claims.Subject != "user-1" || claims.Email != "u@example.com" || claims.Name != "Alice" { + t.Fatalf("unexpected claims: %+v", claims) + } + if claims.ID != "jti-1" { + t.Fatalf("expected jti claim, got %q", claims.ID) + } + if claims.Provider != "entra" { + t.Fatalf("expected provider claim, got %q", claims.Provider) + } +} + +func TestSessionRejectsExpired(t *testing.T) { + cfg := newTestCfg("session-test-secret-32-bytes!!", -1*time.Second) + jwt, _, err := IssueSession(cfg, "jti-1", "entra", "user-1", "u@example.com", "Alice") + if err != nil { + t.Fatalf("issue: %v", err) + } + if _, err := ParseSession(cfg.SessionSecret, jwt); err == nil { + t.Fatal("expected expired token to fail validation") + } +} + +func TestSessionRejectsWrongSecret(t *testing.T) { + a := newTestCfg("session-test-secret-32-bytes!a", time.Hour) + b := newTestCfg("session-test-secret-32-bytes!b", time.Hour) + jwt, _, err := IssueSession(a, "jti-1", "entra", "user-1", "", "") + if err != nil { + t.Fatalf("issue: %v", err) + } + if _, err := ParseSession(b.SessionSecret, jwt); err == nil { + t.Fatal("expected wrong-secret to fail validation") + } +} + +func TestSessionRejectsTampered(t *testing.T) { + cfg := newTestCfg("session-test-secret-32-bytes!!", time.Hour) + jwt, _, err := IssueSession(cfg, "jti-1", "entra", "user-1", "", "") + if err != nil { + t.Fatalf("issue: %v", err) + } + tampered := jwt[:len(jwt)-2] + "AA" + if _, err := ParseSession(cfg.SessionSecret, tampered); err == nil { + t.Fatal("expected tampered token to fail validation") + } +} diff --git a/backend/helpers/oidchelper/state.go b/backend/helpers/oidchelper/state.go new file mode 100644 index 00000000000..d43b82fdf00 --- /dev/null +++ b/backend/helpers/oidchelper/state.go @@ -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. +*/ + +package oidchelper + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "time" +) + +// StatePayload is encrypted into the state cookie. The Nonce field is also +// echoed as the OIDC `state` query parameter so we can verify the user's +// browser is the one that initiated the flow. Provider names which IdP +// minted this flow so the callback handler picks the right token endpoint. +type StatePayload struct { + Provider string `json:"v"` + Nonce string `json:"n"` + ReturnURL string `json:"r"` + PKCEVerifier string `json:"p"` + IssuedAt time.Time `json:"t"` +} + +// 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) { + gcm, err := newGCM(secret) + if err != nil { + return "", err + } + plaintext, err := json.Marshal(p) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) + return base64.RawURLEncoding.EncodeToString(ciphertext), nil +} + +// DecodeState reverses EncodeState and rejects payloads older than StateCookieMaxAge. +func DecodeState(secret []byte, encoded string) (*StatePayload, error) { + gcm, err := newGCM(secret) + if err != nil { + return nil, err + } + raw, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("state decode: %w", err) + } + if len(raw) < gcm.NonceSize() { + return nil, fmt.Errorf("state 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) + } + var p StatePayload + if err := json.Unmarshal(plaintext, &p); err != nil { + return nil, fmt.Errorf("state unmarshal: %w", err) + } + if time.Since(p.IssuedAt) > StateCookieMaxAge { + return nil, fmt.Errorf("state expired") + } + return &p, nil +} + +// NewNonce returns a 24-char base64 random string suitable for the OIDC +// `state` parameter (~144 bits of entropy). +func NewNonce() (string, error) { + b := make([]byte, 18) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// NewCSRFToken returns a 256-bit random URL-safe string for double-submit CSRF. +func NewCSRFToken() (string, error) { + b := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func newGCM(secret []byte) (cipher.AEAD, error) { + // AES-256 needs a 32-byte key; SHA-256 of the secret gives one. + sum := sha256.Sum256(secret) + block, err := aes.NewCipher(sum[:]) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} diff --git a/backend/helpers/oidchelper/state_test.go b/backend/helpers/oidchelper/state_test.go new file mode 100644 index 00000000000..3401cd372a1 --- /dev/null +++ b/backend/helpers/oidchelper/state_test.go @@ -0,0 +1,110 @@ +/* +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 ( + "strings" + "testing" + "time" +) + +func TestEncodeDecodeStateRoundTrip(t *testing.T) { + secret := []byte("a-test-secret-with-at-least-32-bytes!") + in := &StatePayload{ + Nonce: "abc123", + ReturnURL: "/projects", + PKCEVerifier: "verifier", + IssuedAt: time.Now(), + } + encoded, err := EncodeState(secret, in) + if err != nil { + t.Fatalf("encode: %v", err) + } + out, err := DecodeState(secret, encoded) + if err != nil { + t.Fatalf("decode: %v", err) + } + if out.Nonce != in.Nonce || out.ReturnURL != in.ReturnURL || out.PKCEVerifier != in.PKCEVerifier { + t.Fatalf("round-trip mismatch: %+v vs %+v", in, out) + } +} + +func TestDecodeStateRejectsTamperedCiphertext(t *testing.T) { + secret := []byte("a-test-secret-with-at-least-32-bytes!") + encoded, err := EncodeState(secret, &StatePayload{Nonce: "n", IssuedAt: time.Now()}) + if err != nil { + t.Fatalf("encode: %v", err) + } + // Flip a char in the middle of the ciphertext (past the AES-GCM nonce + // prefix); flipping the very last char of base64 can be a no-op when + // the trailing bits are unused, defeating the test. + mid := len(encoded) / 2 + tampered := encoded[:mid] + flipChar(encoded[mid]) + encoded[mid+1:] + if _, err := DecodeState(secret, tampered); err == nil { + t.Fatal("expected decode to fail on tampered ciphertext") + } +} + +func TestDecodeStateRejectsWrongSecret(t *testing.T) { + encoded, err := EncodeState([]byte("first-secret-with-at-least-32-bytes!"), &StatePayload{Nonce: "n", IssuedAt: time.Now()}) + if err != nil { + t.Fatalf("encode: %v", err) + } + if _, err := DecodeState([]byte("other-secret-with-at-least-32-bytes!"), encoded); err == nil { + t.Fatal("expected decode to fail with a different secret") + } +} + +func TestDecodeStateRejectsExpiredPayload(t *testing.T) { + secret := []byte("a-test-secret-with-at-least-32-bytes!") + encoded, err := EncodeState(secret, &StatePayload{ + Nonce: "n", + IssuedAt: time.Now().Add(-30 * time.Minute), + }) + if err != nil { + t.Fatalf("encode: %v", err) + } + _, err = DecodeState(secret, encoded) + if err == nil || !strings.Contains(err.Error(), "expired") { + t.Fatalf("expected expired error, got: %v", err) + } +} + +func TestNewNonceUniqueness(t *testing.T) { + seen := map[string]struct{}{} + for i := 0; i < 50; i++ { + n, err := NewNonce() + if err != nil { + t.Fatalf("NewNonce: %v", err) + } + if len(n) < 20 { + t.Fatalf("nonce too short: %q", n) + } + if _, dup := seen[n]; dup { + t.Fatalf("duplicate nonce: %q", n) + } + seen[n] = struct{}{} + } +} + +func flipChar(b byte) string { + if b == 'A' { + return "B" + } + return "A" +} diff --git a/backend/helpers/oidchelper/wif.go b/backend/helpers/oidchelper/wif.go new file mode 100644 index 00000000000..0b03c4345b6 --- /dev/null +++ b/backend/helpers/oidchelper/wif.go @@ -0,0 +1,77 @@ +/* +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 ( + "fmt" + "os" + "sync" + "time" +) + +// AzureFederatedTokenFileEnv is the env var the azure-workload-identity +// webhook sets on the pod, pointing at the projected SA token file. +const AzureFederatedTokenFileEnv = "AZURE_FEDERATED_TOKEN_FILE" + +// wifCacheTTL is the read-cache lifetime for the federated SA token. +// Kubernetes rotates SA tokens at ~80% of their TTL (1h minimum), so 10 +// minutes is well inside the safe window. +const wifCacheTTL = 10 * time.Minute + +var ( + wifMu sync.RWMutex + wifAssertion string + wifExpires time.Time + // wifReadFile is overridable from tests so they can stub the file read + // without touching the real filesystem. + wifReadFile = os.ReadFile +) + +// FederatedAssertion returns the workload-identity SA token to use as the +// `client_assertion` in an OIDC code exchange against Microsoft Entra. +// Reads the file pointed to by AZURE_FEDERATED_TOKEN_FILE and caches the +// result for wifCacheTTL. +func FederatedAssertion() (string, error) { + file := os.Getenv(AzureFederatedTokenFileEnv) + if file == "" { + return "", fmt.Errorf("%s env var not set (is the azure-workload-identity webhook installed and the pod labeled)", AzureFederatedTokenFileEnv) + } + + wifMu.RLock() + if wifExpires.After(time.Now()) { + assertion := wifAssertion + wifMu.RUnlock() + return assertion, nil + } + wifMu.RUnlock() + + wifMu.Lock() + defer wifMu.Unlock() + // Double-check under the write lock in case a peer goroutine already + // refreshed the assertion. + if wifExpires.After(time.Now()) { + return wifAssertion, nil + } + content, err := wifReadFile(file) + if err != nil { + return "", fmt.Errorf("read %s: %w", file, err) + } + wifAssertion = string(content) + wifExpires = time.Now().Add(wifCacheTTL) + return wifAssertion, nil +} diff --git a/backend/helpers/oidchelper/wif_test.go b/backend/helpers/oidchelper/wif_test.go new file mode 100644 index 00000000000..89c32614454 --- /dev/null +++ b/backend/helpers/oidchelper/wif_test.go @@ -0,0 +1,109 @@ +/* +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 ( + "errors" + "sync/atomic" + "testing" + "time" +) + +func resetFederatedAssertionCache() { + wifMu.Lock() + defer wifMu.Unlock() + wifAssertion = "" + wifExpires = time.Time{} +} + +func TestFederatedAssertionMissingEnv(t *testing.T) { + resetFederatedAssertionCache() + t.Setenv(AzureFederatedTokenFileEnv, "") + if _, err := FederatedAssertion(); err == nil { + t.Fatal("expected error when env var is unset") + } +} + +func TestFederatedAssertionReadsAndCaches(t *testing.T) { + resetFederatedAssertionCache() + var reads atomic.Int32 + wifReadFile = func(string) ([]byte, error) { + reads.Add(1) + return []byte("token-content"), nil + } + t.Cleanup(func() { wifReadFile = osReadFileForTests }) + + t.Setenv(AzureFederatedTokenFileEnv, "/fake/path") + + for i := 0; i < 5; i++ { + got, err := FederatedAssertion() + if err != nil { + t.Fatalf("call %d: %v", i, err) + } + if got != "token-content" { + t.Fatalf("got %q", got) + } + } + if got := reads.Load(); got != 1 { + t.Fatalf("expected 1 file read across 5 calls, got %d", got) + } +} + +func TestFederatedAssertionRefreshesAfterTTL(t *testing.T) { + resetFederatedAssertionCache() + var counter atomic.Int32 + wifReadFile = func(string) ([]byte, error) { + counter.Add(1) + return []byte("v"), nil + } + t.Cleanup(func() { wifReadFile = osReadFileForTests }) + + t.Setenv(AzureFederatedTokenFileEnv, "/fake/path") + + if _, err := FederatedAssertion(); err != nil { + t.Fatalf("first read: %v", err) + } + // Force the cache stale. + wifMu.Lock() + wifExpires = time.Now().Add(-time.Second) + wifMu.Unlock() + + if _, err := FederatedAssertion(); err != nil { + t.Fatalf("second read: %v", err) + } + if got := counter.Load(); got != 2 { + t.Fatalf("expected 2 file reads, got %d", got) + } +} + +func TestFederatedAssertionPropagatesReadError(t *testing.T) { + resetFederatedAssertionCache() + wifReadFile = func(string) ([]byte, error) { return nil, errors.New("boom") } + t.Cleanup(func() { wifReadFile = osReadFileForTests }) + + t.Setenv(AzureFederatedTokenFileEnv, "/fake/path") + if _, err := FederatedAssertion(); err == nil { + t.Fatal("expected error from underlying read") + } +} + +// osReadFileForTests restores the real os.ReadFile after each test that +// monkey-patches wifReadFile. Defined here to avoid an import-cycle. +var osReadFileForTests = func(name string) ([]byte, error) { + return nil, errors.New("real os.ReadFile not stubbed in this test") +} diff --git a/backend/server/api/api.go b/backend/server/api/api.go index d7a39071d35..8834c4ecfeb 100644 --- a/backend/server/api/api.go +++ b/backend/server/api/api.go @@ -35,6 +35,7 @@ import ( "github.com/apache/incubator-devlake/core/errors" "github.com/apache/incubator-devlake/core/plugin" "github.com/apache/incubator-devlake/impls/logruslog" + "github.com/apache/incubator-devlake/server/api/auth" _ "github.com/apache/incubator-devlake/server/api/docs" "github.com/apache/incubator-devlake/server/api/ping" "github.com/apache/incubator-devlake/server/api/shared" @@ -53,9 +54,9 @@ const DB_MIGRATING = `Database migration is in progress. Please wait until it is var basicRes context.BasicRes func Init() { - // Initialize services services.Init() basicRes = services.GetBasicRes() + auth.Init(basicRes) } func InjectCustomService(pipelineNotifier services.PipelineNotificationService, projectService services.ProjectService) errors.Error { @@ -104,9 +105,14 @@ func CreateApiServer() *gin.Engine { router.GET("/health", ping.Health) router.GET("/version", version.Get) - // Api keys + // Auth chain order matters: REST API key first (its own short-circuit), + // then OIDC session, then oauth2-proxy header (only sets USER if not yet + // set), then the terminal 401 gate, finally CSRF on unsafe methods. router.Use(RestAuthentication(router, basicRes)) + router.Use(auth.OIDCAuthentication()) router.Use(OAuth2ProxyAuthentication(basicRes)) + router.Use(auth.RequireAuth()) + router.Use(auth.CSRFProtect()) return router } diff --git a/backend/server/api/auth/auth.go b/backend/server/api/auth/auth.go new file mode 100644 index 00000000000..e1012083ee8 --- /dev/null +++ b/backend/server/api/auth/auth.go @@ -0,0 +1,528 @@ +/* +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 auth implements user-facing OIDC login for DevLake. Supports any +// number of OIDC providers configured via OIDC_PROVIDERS + per-provider env +// vars; routes /auth/login?provider= to the corresponding IdP. +package auth + +import ( + stdctx "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "golang.org/x/oauth2" + + corectx "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/dal" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/log" + "github.com/apache/incubator-devlake/helpers/oidchelper" + "github.com/apache/incubator-devlake/server/api/shared" +) + +// 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" +) + +// lastSeenThrottle bounds DB writes to one per-jti per window. Tracking +// "last activity" doesn't need per-request precision. +const lastSeenThrottle = 5 * time.Minute + +// Service holds everything the auth handlers and middlewares need. It is the +// unit of testability for this package: tests build one with stub deps and +// drive the gin handlers directly. +type Service struct { + cfg *oidchelper.Config + providers map[string]*oidchelper.Provider + logger log.Logger + db dal.Dal + revoked *revocationCache + + lastSeenMu sync.Mutex + lastSeen map[string]time.Time +} + +// defaultService is populated by Init and backs the package-level handler / +// middleware wrappers. Tests that need isolation should construct their own +// *Service via NewService. +var ( + defaultService *Service + initOnce sync.Once +) + +// Init builds the default Service from env config and starts the background +// loops. Panics if AUTH_ENABLED=true but the config is incomplete. +func Init(basicRes corectx.BasicRes) { + initOnce.Do(func() { + s, err := NewService(stdctx.Background(), basicRes) + if err != nil { + panic(fmt.Errorf("auth init: %w", err)) + } + defaultService = s + }) +} + +// NewService is the testable constructor. ctx governs the lifetime of the +// revocation refresher and session cleanup goroutines. +func NewService(ctx stdctx.Context, basicRes corectx.BasicRes) (*Service, error) { + cfg, err := oidchelper.LoadConfig(basicRes) + if err != nil { + return nil, err + } + s := &Service{ + cfg: cfg, + providers: map[string]*oidchelper.Provider{}, + logger: basicRes.GetLogger(), + db: basicRes.GetDal(), + revoked: newRevocationCache(), + lastSeen: map[string]time.Time{}, + } + if cfg.AuthEnabled { + startRefresher(ctx, s.revoked, s.db, s.logger) + startSessionCleanup(ctx, s.db, s.logger) + } + if cfg.OIDCEnabled { + for name, pc := range cfg.Providers { + s.providers[name] = oidchelper.NewProvider(pc) + s.logger.Info("OIDC provider %q enabled (issuer=%s, client=%s)", name, pc.IssuerURL, pc.ClientID) + } + } else if cfg.AuthEnabled { + s.logger.Info("AUTH_ENABLED but OIDC_ENABLED=false: only API-key/proxy auth will work") + } + return s, nil +} + +func (s *Service) Config() *oidchelper.Config { return s.cfg } + +// Config returns the default service's config. Nil before Init has run. +func Config() *oidchelper.Config { + if defaultService == nil { + return nil + } + return defaultService.cfg +} + +type ProviderInfo struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + LoginURL string `json:"loginUrl"` +} + +type Methods struct { + Providers []ProviderInfo `json:"providers,omitempty"` + APIKey *APIKey `json:"apiKey,omitempty"` +} + +type APIKey struct { + Enabled bool `json:"enabled"` +} + +func GetMethods(c *gin.Context) { defaultService.GetMethods(c) } + +// @Summary List enabled login methods +// @Tags framework/auth +// @Success 200 {object} Methods +// @Router /auth/methods [get] +func (s *Service) GetMethods(c *gin.Context) { + out := Methods{APIKey: &APIKey{Enabled: true}} + if s.cfg != nil && s.cfg.OIDCEnabled { + for _, name := range s.cfg.ProviderNames() { + pc := s.cfg.Providers[name] + out.Providers = append(out.Providers, ProviderInfo{ + Name: name, + DisplayName: pc.DisplayName, + LoginURL: PathLogin + "?provider=" + url.QueryEscape(name), + }) + } + } + shared.ApiOutputSuccess(c, out, http.StatusOK) +} + +func LoginInit(c *gin.Context) { defaultService.LoginInit(c) } + +// @Summary Begin OIDC login +// @Tags framework/auth +// @Param provider query string false "provider name (required when more than one is configured)" +// @Param return_url query string false "where to redirect after login" +// @Success 303 +// @Router /auth/login [get] +func (s *Service) LoginInit(c *gin.Context) { + if !s.ensureOIDC(c) { + return + } + name, p, ok := s.pickProvider(c, c.Query("provider")) + if !ok { + return + } + returnURL := safeReturnURL(c.Query("return_url")) + + verifier, err := newPKCEVerifier() + if err != nil { + fail(c, http.StatusInternalServerError, "pkce generate", err) + return + } + nonce, err := oidchelper.NewNonce() + if err != nil { + fail(c, http.StatusInternalServerError, "state nonce", err) + return + } + encoded, err := oidchelper.EncodeState(s.cfg.SessionSecret, &oidchelper.StatePayload{ + Provider: name, + Nonce: nonce, + ReturnURL: returnURL, + PKCEVerifier: verifier, + IssuedAt: time.Now(), + }) + if err != nil { + fail(c, http.StatusInternalServerError, "state encode", err) + return + } + oidchelper.SetStateCookie(c, s.cfg, encoded) + + oa, err := p.OAuth2Config(c.Request.Context()) + if err != nil { + fail(c, http.StatusBadGateway, "oauth2 config", err) + return + } + authURL := oa.AuthCodeURL(nonce, + oauth2.SetAuthURLParam("code_challenge", pkceChallenge(verifier)), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + ) + c.Redirect(http.StatusSeeOther, authURL) +} + +func Callback(c *gin.Context) { defaultService.Callback(c) } + +// @Summary OIDC callback +// @Tags framework/auth +// @Success 303 +// @Router /auth/callback [get] +func (s *Service) Callback(c *gin.Context) { + if !s.ensureOIDC(c) { + return + } + + encoded, err := c.Cookie(oidchelper.StateCookieName) + if err != nil || encoded == "" { + fail(c, http.StatusBadRequest, "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) + 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) + return + } + p, ok := s.providers[state.Provider] + if !ok { + fail(c, http.StatusBadRequest, "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) + return + } + fail(c, http.StatusBadRequest, "missing authorization code", nil) + return + } + + oa, err := p.OAuth2Config(c.Request.Context()) + if err != nil { + fail(c, http.StatusBadGateway, "oauth2 config", err) + return + } + exchangeOpts := []oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("code_verifier", state.PKCEVerifier), + } + 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) + return + } + exchangeOpts = append(exchangeOpts, + oauth2.SetAuthURLParam("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"), + oauth2.SetAuthURLParam("client_assertion", assertion), + ) + } + tok, err := oa.Exchange(c.Request.Context(), code, exchangeOpts...) + if err != nil { + fail(c, http.StatusBadGateway, "code exchange", err) + return + } + rawID, _ := tok.Extra("id_token").(string) + if rawID == "" { + fail(c, http.StatusBadGateway, "id_token missing from token response", nil) + return + } + idTok, err := p.VerifyIDToken(c.Request.Context(), rawID) + if err != nil { + fail(c, http.StatusUnauthorized, "id_token verify", err) + return + } + + sub, email, name, err := extractUser(idTok) + if err != nil { + fail(c, http.StatusBadGateway, "extract claims", err) + 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) + return + } + now := time.Now() + if dbErr := CreateSession(s.db, &AuthSession{ + Jti: jti, + Sub: sub, + Email: email, + Name: name, + IssuedAt: now, + ExpiresAt: expiresAt, + LastSeenAt: now, + }); dbErr != nil { + fail(c, http.StatusInternalServerError, "persist session", dbErr) + return + } + csrf, err := oidchelper.NewCSRFToken() + if err != nil { + fail(c, http.StatusInternalServerError, "csrf token", err) + return + } + oidchelper.SetSessionCookie(c, s.cfg, jwt) + oidchelper.SetCSRFCookie(c, s.cfg, csrf) + s.logger.Info("oidc login: provider=%s sub=%s email=%s jti=%s", state.Provider, sub, email, jti) + + c.Redirect(http.StatusSeeOther, state.ReturnURL) +} + +type logoutResponse struct { + OK bool `json:"ok"` + LogoutURL string `json:"logoutUrl,omitempty"` +} + +func Logout(c *gin.Context) { defaultService.Logout(c) } + +// @Summary Logout +// @Tags framework/auth +// @Success 200 {object} logoutResponse +// @Router /auth/logout [post] +func (s *Service) Logout(c *gin.Context) { + if s.cfg == nil || !s.cfg.AuthEnabled { + shared.ApiOutputSuccess(c, logoutResponse{OK: true}, http.StatusOK) + return + } + + var sessionProvider string + 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 err := RevokeSession(s.db, claims.ID); err != nil { + s.logger.Error(err, "auth: revoke session row") + } + s.revoked.Add(claims.ID) + } + } + oidchelper.ClearSessionCookie(c, s.cfg) + oidchelper.ClearCSRFCookie(c, s.cfg) + + 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 != "" { + out.LogoutURL = u + } + } + } + shared.ApiOutputSuccess(c, out, http.StatusOK) +} + +type userInfoResponse struct { + Authenticated bool `json:"authenticated"` + Name string `json:"name"` + Email string `json:"email"` +} + +func UserInfo(c *gin.Context) { defaultService.UserInfo(c) } + +// UserInfo always returns 200 so calling it does not trigger the UI's +// 401-redirect loop; the `authenticated` field discriminates. +// +// @Summary Current user +// @Tags framework/auth +// @Success 200 {object} userInfoResponse +// @Router /auth/userinfo [get] +func (s *Service) UserInfo(c *gin.Context) { + u, ok := shared.GetUser(c) + if !ok || u == nil { + shared.ApiOutputSuccess(c, userInfoResponse{Authenticated: false}, http.StatusOK) + return + } + shared.ApiOutputSuccess(c, userInfoResponse{ + Authenticated: true, + Name: u.Name, + Email: u.Email, + }, http.StatusOK) +} + +// 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) { + 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 + } + } + p, ok := s.providers[name] + if !ok { + fail(c, http.StatusBadRequest, "unknown provider: "+name, nil) + return "", nil, false + } + return name, p, true +} + +func (s *Service) ensureOIDC(c *gin.Context) bool { + if s.cfg == nil || !s.cfg.OIDCEnabled || len(s.providers) == 0 { + shared.ApiOutputError(c, errors.HttpStatus(http.StatusServiceUnavailable).New("OIDC is not enabled")) + return false + } + return true +} + +// bumpLastSeen records that jti was used. The DB write happens at most once +// per lastSeenThrottle per jti, off the request path. +func (s *Service) bumpLastSeen(jti string) { + now := time.Now() + s.lastSeenMu.Lock() + if last, ok := s.lastSeen[jti]; ok && now.Sub(last) < lastSeenThrottle { + s.lastSeenMu.Unlock() + return + } + s.lastSeen[jti] = now + s.lastSeenMu.Unlock() + + go func() { + if err := UpdateLastSeen(s.db, jti, now); err != nil { + s.logger.Debug("auth: bump last_seen jti=%s: %v", jti, err) + } + }() +} + +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) + } + shared.ApiOutputError(c, wrapped) +} + +// safeReturnURL prevents open-redirect by stripping anything that doesn't +// look like a same-origin path. The // and /\ prefixes get a fast path +// because some browsers normalize backslash → slash and would treat +// /\evil.com as a protocol-relative URL. +func safeReturnURL(raw string) string { + if raw == "" { + return "/" + } + if strings.HasPrefix(raw, "//") || strings.HasPrefix(raw, `/\`) { + return "/" + } + u, err := url.Parse(raw) + if err != nil { + return "/" + } + if u.IsAbs() || u.Host != "" || !strings.HasPrefix(u.Path, "/") { + return "/" + } + return u.RequestURI() +} + +// extractUser pulls user identity out of the verified ID token. Email is +// taken strictly from the `email` claim; we never coerce a username into +// the email column. Display name falls back to preferred_username, then +// email, so the UI always has *something* to render. +func extractUser(tok *oidc.IDToken) (sub, email, name string, err error) { + var claims struct { + Sub string `json:"sub"` + Email string `json:"email"` + PreferredUsername string `json:"preferred_username"` + Name string `json:"name"` + } + if err := tok.Claims(&claims); err != nil { + return "", "", "", err + } + if claims.Sub == "" { + return "", "", "", fmt.Errorf("id_token missing sub claim") + } + name = claims.Name + if name == "" { + name = claims.PreferredUsername + } + if name == "" { + name = claims.Email + } + return claims.Sub, claims.Email, name, nil +} + +func pkceChallenge(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +// newPKCEVerifier returns a 64-char random URL-safe string per RFC 7636. +func newPKCEVerifier() (string, error) { + b := make([]byte, 48) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/backend/server/api/auth/auth_test.go b/backend/server/api/auth/auth_test.go new file mode 100644 index 00000000000..75d09ab47d0 --- /dev/null +++ b/backend/server/api/auth/auth_test.go @@ -0,0 +1,51 @@ +/* +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 auth + +import "testing" + +func TestSafeReturnURL(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"empty", "", "/"}, + {"root", "/", "/"}, + {"normal path", "/projects", "/projects"}, + {"path with query", "/projects?tab=2", "/projects?tab=2"}, + {"path with fragment kept", "/projects#section", "/projects"}, + {"absolute http URL", "http://evil.com/x", "/"}, + {"absolute https URL", "https://evil.com/x", "/"}, + {"protocol-relative", "//evil.com", "/"}, + {"protocol-relative with path", "//evil.com/x", "/"}, + {"backslash variant", `/\evil.com`, "/"}, + {"backslash variant with path", `/\evil.com/x`, "/"}, + {"missing leading slash", "projects", "/"}, + {"javascript scheme", "javascript:alert(1)", "/"}, + {"data scheme", "data:text/html,x", "/"}, + {"unparseable", "://", "/"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := safeReturnURL(tc.in); got != tc.want { + t.Errorf("safeReturnURL(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} diff --git a/backend/server/api/auth/cleanup.go b/backend/server/api/auth/cleanup.go new file mode 100644 index 00000000000..3dcaf7421e6 --- /dev/null +++ b/backend/server/api/auth/cleanup.go @@ -0,0 +1,57 @@ +/* +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 auth + +import ( + "context" + "time" + + "github.com/apache/incubator-devlake/core/dal" + "github.com/apache/incubator-devlake/core/log" +) + +const ( + sessionCleanupInterval = 24 * time.Hour + // Keep expired rows around as a soft audit trail before pruning. 30d + // covers any reasonable "who logged in last month" question without + // letting the table grow unbounded. + sessionRetentionAfterExpiry = 30 * 24 * time.Hour +) + +// startSessionCleanup deletes long-expired session rows on a daily timer. +// First sweep runs after one interval, not at boot, so deploys don't pay +// the cleanup cost during the noisy startup window. +func startSessionCleanup(ctx context.Context, db dal.Dal, logger log.Logger) { + go func() { + t := time.NewTicker(sessionCleanupInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + cutoff := time.Now().Add(-sessionRetentionAfterExpiry) + if err := db.Delete(&AuthSession{}, dal.Where("expires_at < ?", cutoff)); err != nil { + logger.Error(err, "auth: session cleanup") + continue + } + logger.Info("auth: pruned auth_sessions rows expired before %s", cutoff.Format(time.RFC3339)) + } + } + }() +} diff --git a/backend/server/api/auth/handlers_test.go b/backend/server/api/auth/handlers_test.go new file mode 100644 index 00000000000..7067fa97876 --- /dev/null +++ b/backend/server/api/auth/handlers_test.go @@ -0,0 +1,426 @@ +/* +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 auth + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/mock" + + "github.com/apache/incubator-devlake/core/dal" + "github.com/apache/incubator-devlake/helpers/oidchelper" + "github.com/apache/incubator-devlake/impls/logruslog" + mockdal "github.com/apache/incubator-devlake/mocks/core/dal" +) + +// fakeIdP is a minimal OIDC provider sufficient for go-oidc discovery, +// token exchange, and JWKS verification. +type fakeIdP struct { + server *httptest.Server + key *rsa.PrivateKey + keyID string + issuer string + + mu sync.Mutex + lastCode string + subject string + email string + name string +} + +func newFakeIdP(t *testing.T) *fakeIdP { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa.GenerateKey: %v", err) + } + idp := &fakeIdP{ + key: key, + keyID: "test-key", + subject: "user-123", + email: "alice@example.com", + name: "Alice", + } + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", idp.handleDiscovery) + mux.HandleFunc("/jwks", idp.handleJWKS) + mux.HandleFunc("/token", idp.handleToken) + mux.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "authorize endpoint not exercised by these tests", http.StatusNotImplemented) + }) + mux.HandleFunc("/logout", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + idp.server = httptest.NewServer(mux) + idp.issuer = idp.server.URL + t.Cleanup(idp.server.Close) + return idp +} + +func (f *fakeIdP) handleDiscovery(w http.ResponseWriter, _ *http.Request) { + doc := map[string]any{ + "issuer": f.issuer, + "authorization_endpoint": f.issuer + "/authorize", + "token_endpoint": f.issuer + "/token", + "jwks_uri": f.issuer + "/jwks", + "end_session_endpoint": f.issuer + "/logout", + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + "id_token_signing_alg_values_supported": []string{"RS256"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) +} + +func (f *fakeIdP) handleJWKS(w http.ResponseWriter, _ *http.Request) { + pub := f.key.PublicKey + doc := map[string]any{ + "keys": []map[string]string{{ + "kty": "RSA", + "use": "sig", + "kid": f.keyID, + "alg": "RS256", + "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()), + }}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) +} + +func (f *fakeIdP) handleToken(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + code := r.FormValue("code") + f.mu.Lock() + f.lastCode = code + f.mu.Unlock() + + // oauth2.Config.Exchange sends client credentials via HTTP Basic by + // default. Fall back to the form for clients that send them there. + clientID := r.FormValue("client_id") + if u, _, ok := r.BasicAuth(); ok { + clientID = u + } + + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": f.issuer, + "aud": clientID, + "sub": f.subject, + "email": f.email, + "name": f.name, + "iat": time.Now().Unix(), + "exp": time.Now().Add(5 * time.Minute).Unix(), + }) + tok.Header["kid"] = f.keyID + signed, err := tok.SignedString(f.key) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "dummy-access", + "token_type": "Bearer", + "expires_in": 300, + "id_token": signed, + }) +} + +// newTestService builds a Service backed by the fake IdP and a permissive +// dal mock that accepts the auth-store writes the handlers issue. +func newTestService(t *testing.T, idp *fakeIdP) (*Service, *mockdal.Dal) { + t.Helper() + pc := &oidchelper.ProviderConfig{ + Name: "test", + IssuerURL: idp.issuer, + ClientID: "test-client", + ClientSecret: "test-secret", + RedirectURL: "http://localhost/auth/callback", + Scopes: []string{"openid", "profile", "email"}, + DisplayName: "Test IdP", + } + cfg := &oidchelper.Config{ + AuthEnabled: true, + OIDCEnabled: true, + Providers: map[string]*oidchelper.ProviderConfig{"test": pc}, + LogoutRedirect: true, + SessionSecret: []byte("test-secret-with-at-least-32-bytes!"), + SessionTTL: time.Hour, + CookieSecure: false, + } + db := &mockdal.Dal{} + db.On("Create", mock.Anything, mock.Anything).Return(nil) + db.On("UpdateColumn", mock.Anything, 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) + + s := &Service{ + cfg: cfg, + providers: map[string]*oidchelper.Provider{"test": oidchelper.NewProvider(pc)}, + logger: logruslog.Global, + db: db, + revoked: newRevocationCache(), + lastSeen: map[string]time.Time{}, + } + return s, db +} + +func newTestRouter(s *Service) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(s.OIDCAuthentication()) + r.Use(s.RequireAuth()) + r.Use(s.CSRFProtect()) + r.GET(PathLogin, s.LoginInit) + r.GET(PathCallback, s.Callback) + r.GET(PathUserInfo, s.UserInfo) + r.POST(PathLogout, s.Logout) + return r +} + +// extractCookie pulls a Set-Cookie value by name from a recorded response. +func extractCookie(t *testing.T, resp *http.Response, name string) *http.Cookie { + t.Helper() + for _, c := range resp.Cookies() { + if c.Name == name { + return c + } + } + t.Fatalf("cookie %q not found in response", name) + return nil +} + +// stateNonceFromCookie decodes the encrypted state cookie back into the +// nonce, which the test then echoes as the OIDC `state` query parameter. +func stateNonceFromCookie(t *testing.T, secret []byte, cookieValue string) string { + t.Helper() + state, err := oidchelper.DecodeState(secret, cookieValue) + if err != nil { + t.Fatalf("decode state cookie: %v", err) + } + return state.Nonce +} + +func TestLoginInitSetsStateCookieAndRedirects(t *testing.T) { + idp := newFakeIdP(t) + s, _ := newTestService(t, idp) + r := newTestRouter(s) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, PathLogin+"?provider=test", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d: %s", w.Code, w.Body.String()) + } + loc := w.Header().Get("Location") + if !strings.Contains(loc, "code_challenge=") || !strings.Contains(loc, "code_challenge_method=S256") { + t.Fatalf("expected PKCE params on auth URL, got %q", loc) + } + resp := w.Result() + defer resp.Body.Close() + if c := extractCookie(t, resp, oidchelper.StateCookieName); c.Value == "" { + t.Fatal("state cookie was empty") + } +} + +func TestFullLoginCallbackFlow(t *testing.T) { + idp := newFakeIdP(t) + s, db := newTestService(t, idp) + r := newTestRouter(s) + + // 1. /auth/login: capture the state cookie. + loginW := httptest.NewRecorder() + r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, PathLogin+"?provider=test", nil)) + if loginW.Code != http.StatusSeeOther { + t.Fatalf("login: expected 303, got %d", loginW.Code) + } + stateCookie := extractCookie(t, loginW.Result(), oidchelper.StateCookieName) + nonce := stateNonceFromCookie(t, s.cfg.SessionSecret, stateCookie.Value) + + // 2. /auth/callback: must succeed and set session + csrf cookies. + cbReq := httptest.NewRequest(http.MethodGet, + PathCallback+"?code=fake-code&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 body=%s", cbW.Code, cbW.Body.String()) + } + sessionCookie := extractCookie(t, cbW.Result(), oidchelper.SessionCookieName) + csrfCookie := extractCookie(t, cbW.Result(), oidchelper.CSRFCookieName) + if sessionCookie.Value == "" || csrfCookie.Value == "" { + t.Fatal("expected session and csrf cookies to be set") + } + db.AssertCalled(t, "Create", mock.Anything, mock.Anything) + + // 3. /auth/userinfo with the session cookie should report authenticated. + uiReq := httptest.NewRequest(http.MethodGet, PathUserInfo, nil) + uiReq.AddCookie(sessionCookie) + uiW := httptest.NewRecorder() + r.ServeHTTP(uiW, uiReq) + if uiW.Code != http.StatusOK { + t.Fatalf("userinfo: expected 200, got %d", uiW.Code) + } + var userResp userInfoResponse + if err := json.Unmarshal(uiW.Body.Bytes(), &userResp); err != nil { + t.Fatalf("decode userinfo: %v: %s", err, uiW.Body.String()) + } + if !userResp.Authenticated || userResp.Email != idp.email { + t.Fatalf("unexpected userinfo: %+v body=%s", userResp, uiW.Body.String()) + } + + // 4. /auth/logout with session + csrf header should succeed and revoke. + 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) + 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) + + // 5. The same session cookie now belongs to a revoked jti; userinfo + // should report not authenticated. + uiReq2 := httptest.NewRequest(http.MethodGet, PathUserInfo, nil) + uiReq2.AddCookie(sessionCookie) + uiW2 := httptest.NewRecorder() + r.ServeHTTP(uiW2, uiReq2) + var userResp2 userInfoResponse + if err := json.Unmarshal(uiW2.Body.Bytes(), &userResp2); err != nil { + t.Fatalf("decode userinfo (revoked): %v", err) + } + if userResp2.Authenticated { + t.Fatal("expected revoked session to no longer be authenticated") + } +} + +func TestCallbackRejectsStateMismatch(t *testing.T) { + idp := newFakeIdP(t) + s, _ := newTestService(t, idp) + r := newTestRouter(s) + + loginW := httptest.NewRecorder() + r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, PathLogin+"?provider=test", nil)) + stateCookie := extractCookie(t, loginW.Result(), oidchelper.StateCookieName) + + cbReq := httptest.NewRequest(http.MethodGet, PathCallback+"?code=x&state=not-the-real-nonce", nil) + 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()) + } +} + +func TestCallbackRejectsMissingStateCookie(t *testing.T) { + idp := newFakeIdP(t) + s, _ := newTestService(t, idp) + r := newTestRouter(s) + + 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()) + } +} + +func TestCSRFRequiredOnUnsafeMethod(t *testing.T) { + idp := newFakeIdP(t) + s, _ := newTestService(t, idp) + r := newTestRouter(s) + r.POST("/some-mutation", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + + // Issue a session via the fake-callback so we have valid cookies. + loginW := httptest.NewRecorder() + r.ServeHTTP(loginW, httptest.NewRequest(http.MethodGet, PathLogin+"?provider=test", nil)) + stateCookie := extractCookie(t, loginW.Result(), oidchelper.StateCookieName) + nonce := stateNonceFromCookie(t, s.cfg.SessionSecret, stateCookie.Value) + + cbReq := httptest.NewRequest(http.MethodGet, + PathCallback+"?code=c&state="+url.QueryEscape(nonce), nil) + cbReq.AddCookie(stateCookie) + cbW := httptest.NewRecorder() + r.ServeHTTP(cbW, cbReq) + sessionCookie := extractCookie(t, cbW.Result(), oidchelper.SessionCookieName) + csrfCookie := extractCookie(t, cbW.Result(), oidchelper.CSRFCookieName) + + // POST without the CSRF header is rejected. + bad := httptest.NewRequest(http.MethodPost, "/some-mutation", nil) + bad.AddCookie(sessionCookie) + bad.AddCookie(csrfCookie) + badW := httptest.NewRecorder() + r.ServeHTTP(badW, bad) + if badW.Code != http.StatusForbidden { + t.Fatalf("expected 403 without csrf header, got %d", badW.Code) + } + + // POST with a matching header passes. + good := httptest.NewRequest(http.MethodPost, "/some-mutation", nil) + good.AddCookie(sessionCookie) + good.AddCookie(csrfCookie) + good.Header.Set(oidchelper.CSRFHeaderName, csrfCookie.Value) + goodW := httptest.NewRecorder() + r.ServeHTTP(goodW, good) + if goodW.Code != http.StatusNoContent { + t.Fatalf("expected 204 with csrf header, got %d body=%s", goodW.Code, goodW.Body.String()) + } +} + +func TestLogoutPublicWhenNoSession(t *testing.T) { + idp := newFakeIdP(t) + s, _ := newTestService(t, idp) + r := newTestRouter(s) + + // No session cookie, no csrf header. publicPaths should let it through + // and the handler should respond 200. + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, PathLogout, nil)) + if w.Code != http.StatusOK { + t.Fatalf("expected logout-without-session to return 200, got %d body=%s", w.Code, w.Body.String()) + } +} + +// 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 new file mode 100644 index 00000000000..ef39cabf72d --- /dev/null +++ b/backend/server/api/auth/middleware.go @@ -0,0 +1,165 @@ +/* +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 auth + +import ( + "crypto/subtle" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/apache/incubator-devlake/core/models/common" + "github.com/apache/incubator-devlake/helpers/oidchelper" + "github.com/apache/incubator-devlake/server/api/shared" +) + +// publicPaths is the set of routes reachable without authentication. +// /auth/userinfo and /auth/logout are public so the UI can poll identity +// and clear its session even when the cookie has lapsed; both handlers +// short-circuit gracefully when no user is set. +var publicPaths = map[string]struct{}{ + "/ping": {}, + "/ready": {}, + "/health": {}, + "/version": {}, + "/proceed-db-migration": {}, + PathMethods: {}, + PathLogin: {}, + PathCallback: {}, + PathLogout: {}, + PathUserInfo: {}, +} + +func OIDCAuthentication() gin.HandlerFunc { return defaultService.OIDCAuthentication() } + +func RequireAuth() gin.HandlerFunc { return defaultService.RequireAuth() } + +func CSRFProtect() gin.HandlerFunc { return defaultService.CSRFProtect() } + +// OIDCAuthentication reads the session cookie, verifies the JWT, and sets +// common.USER on the context. Soft authenticator: invalid/missing cookies +// pass through and RequireAuth decides whether to reject. +func (s *Service) OIDCAuthentication() gin.HandlerFunc { + return func(c *gin.Context) { + if s.cfg == nil || !s.cfg.AuthEnabled { + c.Next() + return + } + if _, ok := shared.GetUser(c); ok { + c.Next() + return + } + raw, err := c.Cookie(oidchelper.SessionCookieName) + if err != nil || raw == "" { + c.Next() + return + } + claims, err := oidchelper.ParseSession(s.cfg.SessionSecret, raw) + if err != nil { + s.logger.Debug("invalid session cookie: %v", err) + oidchelper.ClearSessionCookie(c, s.cfg) + c.Next() + return + } + if s.revoked != nil && s.revoked.IsRevoked(claims.ID) { + s.logger.Debug("revoked session presented: jti=%s", claims.ID) + oidchelper.ClearSessionCookie(c, s.cfg) + c.Next() + return + } + c.Set(common.USER, &common.User{ + Name: claims.Name, + Email: claims.Email, + }) + s.bumpLastSeen(claims.ID) + c.Next() + } +} + +// RequireAuth is the terminal gate. No-op when AUTH_ENABLED=false so existing +// deployments are unaffected. +func (s *Service) RequireAuth() gin.HandlerFunc { + return func(c *gin.Context) { + if s.cfg == nil || !s.cfg.AuthEnabled { + c.Next() + return + } + if isPublicPath(c.Request.URL.Path) { + c.Next() + return + } + if _, ok := shared.GetUser(c); ok { + c.Next() + return + } + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "unauthorized", + }) + } +} + +func isPublicPath(path string) bool { + if _, ok := publicPaths[path]; ok { + return true + } + return strings.HasPrefix(path, "/swagger/") +} + +// CSRFProtect rejects unsafe (POST/PUT/DELETE/PATCH) requests authenticated +// via the session cookie unless they echo the CSRF cookie back as the +// X-CSRF-Token header. Other auth methods (API key Bearer, oauth2-proxy +// header) are not subject to CSRF; they don't ride on ambient cookies. +// +// SameSite=Lax already blocks the textbook cross-origin form-POST attack; +// this is defense-in-depth for shared-parent-domain deployments and any +// future GET endpoint that is upgraded to a mutation. +func (s *Service) CSRFProtect() gin.HandlerFunc { + return func(c *gin.Context) { + if s.cfg == nil || !s.cfg.AuthEnabled { + c.Next() + return + } + switch c.Request.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + c.Next() + return + } + if isPublicPath(c.Request.URL.Path) { + c.Next() + return + } + // CSRF only applies when the caller is authenticating via the session + // cookie. Bearer tokens and proxy headers aren't replayable cross-site. + if _, err := c.Cookie(oidchelper.SessionCookieName); err != nil { + c.Next() + return + } + cookie, err := c.Cookie(oidchelper.CSRFCookieName) + header := c.GetHeader(oidchelper.CSRFHeaderName) + if err != nil || cookie == "" || header == "" || subtle.ConstantTimeCompare([]byte(cookie), []byte(header)) != 1 { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "csrf token missing or invalid", + }) + return + } + c.Next() + } +} diff --git a/backend/server/api/auth/revocation_cache.go b/backend/server/api/auth/revocation_cache.go new file mode 100644 index 00000000000..b704d1bb4b9 --- /dev/null +++ b/backend/server/api/auth/revocation_cache.go @@ -0,0 +1,117 @@ +/* +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 auth + +import ( + "context" + "sync" + "time" + + "github.com/apache/incubator-devlake/core/dal" + "github.com/apache/incubator-devlake/core/log" +) + +// revocationRefreshInterval is how often the cache reloads the revoked-jti +// set from the database. Revocations propagate within this window. +const revocationRefreshInterval = 30 * time.Second + +// revocationCache holds the set of revoked session jtis. The MVP keeps it +// fully in memory because the set is tiny (only sessions that were +// explicitly revoked, pruned on natural expiry) and per-request DB lookups +// would otherwise dominate the auth middleware's hot path. +type revocationCache struct { + mu sync.RWMutex + revoked map[string]struct{} +} + +func newRevocationCache() *revocationCache { + return &revocationCache{revoked: map[string]struct{}{}} +} + +func (r *revocationCache) IsRevoked(jti string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + _, ok := r.revoked[jti] + return ok +} + +func (r *revocationCache) Add(jti string) { + r.mu.Lock() + r.revoked[jti] = struct{}{} + r.mu.Unlock() +} + +// Refresh reloads the revoked set from the DB while preserving any Adds +// that landed concurrently with the DB read. The race we're guarding: +// Logout calls RevokeSession then revoked.Add; if Refresh's DB read happens +// between those two steps, the new map would be missing the jti. Carrying +// forward "additions since the snapshot" plugs that gap. +func (r *revocationCache) Refresh(db dal.Dal) error { + r.mu.RLock() + before := make(map[string]struct{}, len(r.revoked)) + for j := range r.revoked { + before[j] = struct{}{} + } + r.mu.RUnlock() + + jtis, err := ListRevoked(db) + if err != nil { + return err + } + + r.mu.Lock() + defer r.mu.Unlock() + next := make(map[string]struct{}, len(jtis)) + for _, j := range jtis { + next[j] = struct{}{} + } + // Anything in the current set that wasn't in the snapshot was Added + // during the DB read; keep it. Anything in both `before` and the + // current set but absent from the DB result has expired naturally and + // is safe to drop. + for j := range r.revoked { + if _, wasBefore := before[j]; !wasBefore { + next[j] = struct{}{} + } + } + r.revoked = next + return nil +} + +// startRefresher loads the cache once synchronously, then refreshes it on a +// timer. The synchronous first load means we never accept revoked sessions +// in a startup window. +func startRefresher(ctx context.Context, cache *revocationCache, db dal.Dal, logger log.Logger) { + if err := cache.Refresh(db); err != nil { + logger.Error(err, "auth: initial revocation cache load failed") + } + go func() { + t := time.NewTicker(revocationRefreshInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := cache.Refresh(db); err != nil { + logger.Error(err, "auth: revocation cache refresh failed") + } + } + } + }() +} diff --git a/backend/server/api/auth/revocation_cache_test.go b/backend/server/api/auth/revocation_cache_test.go new file mode 100644 index 00000000000..8b66c2c8d48 --- /dev/null +++ b/backend/server/api/auth/revocation_cache_test.go @@ -0,0 +1,67 @@ +/* +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 auth + +import ( + "sync" + "testing" +) + +func TestRevocationCacheEmpty(t *testing.T) { + c := newRevocationCache() + if c.IsRevoked("anything") { + t.Fatal("empty cache should never report revoked") + } +} + +func TestRevocationCacheAdd(t *testing.T) { + c := newRevocationCache() + c.Add("jti-1") + if !c.IsRevoked("jti-1") { + t.Fatal("expected jti-1 to be revoked") + } + if c.IsRevoked("jti-2") { + t.Fatal("jti-2 was never added; should not be revoked") + } +} + +func TestRevocationCacheConcurrentReadsAndWrites(t *testing.T) { + c := newRevocationCache() + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(2) + go func(i int) { + defer wg.Done() + c.Add(jtiKey(i)) + }(i) + go func(i int) { + defer wg.Done() + _ = c.IsRevoked(jtiKey(i)) + }(i) + } + wg.Wait() + for i := 0; i < 100; i++ { + if !c.IsRevoked(jtiKey(i)) { + t.Fatalf("jti %d missing after concurrent adds", i) + } + } +} + +func jtiKey(i int) string { + return "jti-" + string(rune('a'+i%26)) + string(rune('0'+(i/26)%10)) +} diff --git a/backend/server/api/auth/store.go b/backend/server/api/auth/store.go new file mode 100644 index 00000000000..751624e82df --- /dev/null +++ b/backend/server/api/auth/store.go @@ -0,0 +1,76 @@ +/* +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 auth + +import ( + "time" + + "github.com/apache/incubator-devlake/core/dal" + "github.com/apache/incubator-devlake/core/errors" +) + +// AuthSession is the persisted record backing one signed session JWT. It is +// what makes server-side revocation possible: the JWT carries the jti, and a +// row here can be flagged revoked to block reuse before the token's natural +// expiry. +type AuthSession struct { + Jti string `gorm:"primaryKey;type:varchar(36)" json:"jti"` + Sub string `gorm:"type:varchar(255);index" json:"sub"` + Email string `gorm:"type:varchar(255)" json:"email"` + Name string `gorm:"type:varchar(255)" json:"name"` + IssuedAt time.Time `json:"issuedAt"` + ExpiresAt time.Time `gorm:"index" json:"expiresAt"` + RevokedAt *time.Time `gorm:"index" json:"revokedAt,omitempty"` + LastSeenAt time.Time `json:"lastSeenAt"` +} + +func (AuthSession) TableName() string { return "auth_sessions" } + +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. +func RevokeSession(db dal.Dal, jti string) errors.Error { + now := time.Now() + return db.UpdateColumn(&AuthSession{}, "revoked_at", now, dal.Where("jti = ?", jti)) +} + +// UpdateLastSeen records that the session was used. Throttled by the caller +// (see Service.bumpLastSeen) so this isn't a per-request DB write. +func UpdateLastSeen(db dal.Dal, jti string, at time.Time) errors.Error { + return db.UpdateColumn(&AuthSession{}, "last_seen_at", at, dal.Where("jti = ?", jti)) +} + +// ListRevoked returns the jti of every still-relevant revoked session, +// excluding ones already past their natural expiry. +func ListRevoked(db dal.Dal) ([]string, errors.Error) { + var rows []AuthSession + err := db.All(&rows, + dal.Select("jti"), + dal.Where("revoked_at IS NOT NULL AND expires_at > ?", time.Now()), + ) + if err != nil { + return nil, err + } + out := make([]string, len(rows)) + for i, r := range rows { + out[i] = r.Jti + } + return out, nil +} diff --git a/backend/server/api/router.go b/backend/server/api/router.go index 721517cf59a..a26e6b4a454 100644 --- a/backend/server/api/router.go +++ b/backend/server/api/router.go @@ -26,6 +26,7 @@ import ( "github.com/apache/incubator-devlake/core/errors" "github.com/apache/incubator-devlake/impls/logruslog" "github.com/apache/incubator-devlake/server/api/apikeys" + "github.com/apache/incubator-devlake/server/api/auth" "github.com/apache/incubator-devlake/server/api/store" "github.com/apache/incubator-devlake/core/plugin" @@ -86,6 +87,13 @@ func RegisterRouter(r *gin.Engine, basicRes context.BasicRes) { r.PUT("/api-keys/:apiKeyId", apikeys.PutApiKey) r.DELETE("/api-keys/:apiKeyId", apikeys.DeleteApiKey) + // auth (OIDC user login) + r.GET(auth.PathMethods, auth.GetMethods) + r.GET(auth.PathLogin, auth.LoginInit) + r.GET(auth.PathCallback, auth.Callback) + r.POST(auth.PathLogout, auth.Logout) + r.GET(auth.PathUserInfo, auth.UserInfo) + // mount all api resources for all plugins resources, err := services.GetPluginsApiResources() if err != nil { diff --git a/config-ui/src/api/auth/index.ts b/config-ui/src/api/auth/index.ts new file mode 100644 index 00000000000..96b3b046a65 --- /dev/null +++ b/config-ui/src/api/auth/index.ts @@ -0,0 +1,44 @@ +/* + * 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 { request } from '@/utils'; + +export type Provider = { + name: string; + displayName: string; + loginUrl: string; +}; + +export type Methods = { + providers?: Provider[]; + apiKey?: { + enabled: boolean; + }; +}; + +export type UserInfo = { + authenticated: boolean; + name: string; + email: string; +}; + +export const methods = (): Promise => request('/auth/methods'); + +export const userinfo = (): Promise => request('/auth/userinfo'); + +export const logout = (): Promise<{ ok: boolean; logoutUrl?: string }> => request('/auth/logout', { method: 'POST' }); diff --git a/config-ui/src/api/index.ts b/config-ui/src/api/index.ts index f211e564038..378ba14777e 100644 --- a/config-ui/src/api/index.ts +++ b/config-ui/src/api/index.ts @@ -19,6 +19,7 @@ import { request } from '@/utils'; import * as apiKey from './api-key'; +import * as auth from './auth'; import * as blueprint from './blueprint'; import * as connection from './connection'; import * as pipeline from './pipeline'; @@ -35,6 +36,7 @@ const version = (signal?: AbortSignal): Promise<{ version: string }> => request( export const API = { apiKey, + auth, blueprint, connection, pipeline, diff --git a/config-ui/src/app/routrer.tsx b/config-ui/src/app/router.tsx similarity index 97% rename from config-ui/src/app/routrer.tsx rename to config-ui/src/app/router.tsx index ac3113db789..c9ffe5f235c 100644 --- a/config-ui/src/app/routrer.tsx +++ b/config-ui/src/app/router.tsx @@ -24,6 +24,7 @@ import { Error, Layout, layoutLoader, + Login, Connections, Connection, ProjectHomePage, @@ -48,6 +49,10 @@ export const router = createBrowserRouter([ path: `${PATH_PREFIX}/db-migrate`, element: , }, + { + path: `${PATH_PREFIX}/login`, + element: , + }, { path: `${PATH_PREFIX}/onboard`, element: , diff --git a/config-ui/src/main.tsx b/config-ui/src/main.tsx index d3b35c690ac..9f33576fac9 100644 --- a/config-ui/src/main.tsx +++ b/config-ui/src/main.tsx @@ -24,7 +24,7 @@ import { ConfigProvider } from 'antd'; import { PageLoading } from '@/components'; import { store } from './app/store'; -import { router } from './app/routrer'; +import { router } from './app/router'; import './index.css'; ReactDOM.render( diff --git a/config-ui/src/plugins/register/github/transformation.tsx b/config-ui/src/plugins/register/github/transformation.tsx index 1758f7e6835..0581e931534 100644 --- a/config-ui/src/plugins/register/github/transformation.tsx +++ b/config-ui/src/plugins/register/github/transformation.tsx @@ -453,9 +453,7 @@ const renderCollapseItems = ({ value={(transformation.prSizeExcludedFileExtensions || []).join(',')} onChange={(e) => { // Don't filter during onChange to allow typing commas freely - const extensions = e.target.value - .split(',') - .map((s: string) => s.trim()); + const extensions = e.target.value.split(',').map((s: string) => s.trim()); onChangeTransformation({ ...transformation, prSizeExcludedFileExtensions: extensions, diff --git a/config-ui/src/routes/index.ts b/config-ui/src/routes/index.ts index 025a0792fc1..12e9f77bafe 100644 --- a/config-ui/src/routes/index.ts +++ b/config-ui/src/routes/index.ts @@ -22,6 +22,7 @@ export * from './connection'; export * from './db-migrate'; export * from './error'; export * from './layout'; +export * from './login'; export * from './not-found'; export * from './onboard'; export * from './pipeline'; diff --git a/config-ui/src/routes/layout/layout.tsx b/config-ui/src/routes/layout/layout.tsx index adcffc988ed..5d7a02e661f 100644 --- a/config-ui/src/routes/layout/layout.tsx +++ b/config-ui/src/routes/layout/layout.tsx @@ -19,8 +19,10 @@ import { useState, useEffect, useMemo } from 'react'; import { useLoaderData, Outlet, useNavigate, useLocation } from 'react-router-dom'; import { Helmet } from 'react-helmet'; -import { Layout as AntdLayout, Menu, Divider } from 'antd'; +import { Layout as AntdLayout, Menu, Divider, Dropdown, Button } from 'antd'; +import { UserOutlined, LogoutOutlined } from '@ant-design/icons'; +import API from '@/api'; import { PageLoading, Logo, ExternalLink } from '@/components'; import { init, selectError, selectStatus } from '@/features'; import { OnboardCard } from '@/routes/onboard/components'; @@ -36,7 +38,24 @@ export const Layout = () => { const [openKeys, setOpenKeys] = useState([]); const [selectedKeys, setSelectedKeys] = useState([]); - const { version, plugins } = useLoaderData() as { version: string; plugins: string[] }; + const { version, plugins, user } = useLoaderData() as { + version: string; + plugins: string[]; + user: { authenticated: boolean; name: string; email: string } | null; + }; + + const handleLogout = async () => { + try { + const res = await API.auth.logout(); + if (res.logoutUrl) { + window.location.href = res.logoutUrl; + return; + } + } catch (e) { + // fall through to /login regardless + } + window.location.href = '/login'; + }; const navigate = useNavigate(); const { pathname } = useLocation(); @@ -134,6 +153,28 @@ export const Layout = () => { {i !== arr.length - 1 && } ))} + {user?.authenticated && ( + <> + + , + label: 'Sign out', + onClick: handleLogout, + }, + ], + }} + placement="bottomRight" + > + + + + )}
diff --git a/config-ui/src/routes/layout/loader.ts b/config-ui/src/routes/layout/loader.ts index b52021b41c1..2aa81b23351 100644 --- a/config-ui/src/routes/layout/loader.ts +++ b/config-ui/src/routes/layout/loader.ts @@ -34,14 +34,16 @@ export const layoutLoader = async ({ request }: Props) => { } let fePlugins = getRegisterPlugins(); - const bePlugins = await API.plugin.list(); - try { const envPlugins = import.meta.env.DEVLAKE_PLUGINS.split(',').filter(Boolean); fePlugins = fePlugins.filter((plugin) => !envPlugins.length || envPlugins.includes(plugin)); } catch (err) {} - const res = await API.version(request.signal); + const [bePlugins, res, user] = await Promise.all([ + API.plugin.list(), + API.version(request.signal), + API.auth.userinfo().catch(() => null), + ]); return { version: res.version, @@ -49,5 +51,6 @@ export const layoutLoader = async ({ request }: Props) => { fePlugins, bePlugins.map((it) => it.plugin), ), + user, }; }; diff --git a/config-ui/src/routes/login/index.tsx b/config-ui/src/routes/login/index.tsx new file mode 100644 index 00000000000..cfa9aa2ae91 --- /dev/null +++ b/config-ui/src/routes/login/index.tsx @@ -0,0 +1,84 @@ +/* + * 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 { Card, Button, Typography, Alert, Space } from 'antd'; + +import API from '@/api'; +import type { Methods, Provider } from '@/api/auth'; +import { TipLayout } from '@/components'; +import { DEVLAKE_ENDPOINT } from '@/config'; + +const { Title, Paragraph } = Typography; + +export const Login = () => { + const [methods, setMethods] = useState(null); + const [error, setError] = useState(null); + + const params = new URLSearchParams(window.location.search); + const returnUrl = params.get('return_url') || '/'; + + useEffect(() => { + API.auth + .methods() + .then(setMethods) + .catch((e) => setError(e?.message ?? 'Failed to load login methods')); + }, []); + + const startOIDC = (p: Provider) => { + const sep = p.loginUrl.includes('?') ? '&' : '?'; + window.location.href = `${DEVLAKE_ENDPOINT}${p.loginUrl}${sep}return_url=${encodeURIComponent(returnUrl)}`; + }; + + const providers = methods?.providers ?? []; + const apiKey = methods?.apiKey; + const noProviders = providers.length === 0 && !apiKey?.enabled; + + return ( + + + + Sign in to DevLake + + {error && } + {providers.length > 0 && ( + + {providers.map((p) => ( + + ))} + + )} + {providers.length === 0 && apiKey?.enabled && ( + + Single Sign-On is not configured. Use an API key (Authorization: Bearer ...) to access /rest endpoints, or + ask your administrator to enable OIDC. + + )} + {noProviders && methods !== null && ( + + )} + + + ); +}; diff --git a/config-ui/src/utils/request.ts b/config-ui/src/utils/request.ts index a3d2e5275ef..e91739f7242 100644 --- a/config-ui/src/utils/request.ts +++ b/config-ui/src/utils/request.ts @@ -23,6 +23,11 @@ import { DEVLAKE_ENDPOINT } from '@/config'; const instance = axios.create({ baseURL: DEVLAKE_ENDPOINT, + withCredentials: true, + // Double-submit CSRF: axios reads `devlake_csrf` and echoes it as + // `X-CSRF-Token` on unsafe methods. The cookie is set by /auth/callback. + xsrfCookieName: 'devlake_csrf', + xsrfHeaderName: 'X-CSRF-Token', }); export type RequestConfig = { @@ -34,6 +39,10 @@ export type RequestConfig = { headers?: Record; }; +const isLoginRoute = () => window.location.pathname.replace(/\/+$/, '').endsWith('/login'); + +let redirectingToLogin = false; + instance.interceptors.response.use( (response) => response, (error) => { @@ -43,6 +52,12 @@ instance.interceptors.response.use( window.location.replace('/db-migrate'); } + if (status === 401 && !isLoginRoute() && !redirectingToLogin) { + redirectingToLogin = true; + const returnUrl = encodeURIComponent(window.location.pathname + window.location.search); + window.location.replace(`/login?return_url=${returnUrl}`); + } + return Promise.reject(error); }, ); diff --git a/config-ui/vite.config.ts b/config-ui/vite.config.ts index d7134060680..5bf4760af99 100644 --- a/config-ui/vite.config.ts +++ b/config-ui/vite.config.ts @@ -21,7 +21,7 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import svgr from 'vite-plugin-svgr'; -// Allow Grafana access from the dev server when using Dev Container +// Allow Grafana access from the dev server when using Dev Container const grafanaOrigin = process.env.VITE_GRAFANA_URL || 'http://localhost:3002'; const grafanaChangeOrigin = envBool('VITE_GRAFANA_CHANGE_ORIGIN', true); @@ -42,7 +42,7 @@ export default defineConfig({ '/grafana': { target: grafanaOrigin, changeOrigin: grafanaChangeOrigin, - ws: true // Proxying websockets to allow features like query auto-complete + ws: true, // Proxying websockets to allow features like query auto-complete }, }, }, diff --git a/env.example b/env.example index 19acb7c94af..1cfd04a6b4c 100755 --- a/env.example +++ b/env.example @@ -88,4 +88,68 @@ SKIP_COMMIT_FILES=true WRAP_RESPONSE_ERROR= # Enable subtasks by default: plugin_name:subtask_name:enabled -ENABLE_SUBTASKS_BY_DEFAULT="jira:collectIssueChangelogs:true,jira:extractIssueChangelogs:true,jira:convertIssueChangelogs:true,tapd:collectBugChangelogs:true,tapd:extractBugChangelogs:true,tapd:convertBugChangelogs:true,zentao:collectBugRepoCommits:true,zentao:extractBugRepoCommits:true,zentao:convertBugRepoCommits:true,zentao:collectStoryRepoCommits:true,zentao:extractStoryRepoCommits:true,zentao:convertStoryRepoCommits:true,zentao:collectTaskRepoCommits:true,zentao:extractTaskRepoCommits:true,zentao:convertTaskRepoCommits:true" \ No newline at end of file +ENABLE_SUBTASKS_BY_DEFAULT="jira:collectIssueChangelogs:true,jira:extractIssueChangelogs:true,jira:convertIssueChangelogs:true,tapd:collectBugChangelogs:true,tapd:extractBugChangelogs:true,tapd:convertBugChangelogs:true,zentao:collectBugRepoCommits:true,zentao:extractBugRepoCommits:true,zentao:convertBugRepoCommits:true,zentao:collectStoryRepoCommits:true,zentao:extractStoryRepoCommits:true,zentao:convertStoryRepoCommits:true,zentao:collectTaskRepoCommits:true,zentao:extractTaskRepoCommits:true,zentao:convertTaskRepoCommits:true" + +########################## +# OIDC / Authentication +########################## +# Master switch. When false (default) DevLake behaves as before: API keys for +# /rest/* and trust X-Forwarded-User from an upstream proxy. Set true to +# require authentication on all non-whitelisted routes. +AUTH_ENABLED=false + +# OIDC user login. Requires AUTH_ENABLED=true. +OIDC_ENABLED=false + +# Comma-separated provider identifiers. Each name binds to the env +# vars OIDC__ISSUER_URL, OIDC__CLIENT_ID, etc. Add a name and a +# matching block of vars to onboard another IdP. +# Example: OIDC_PROVIDERS=entra,google +OIDC_PROVIDERS= + +# Per-provider config. Replicate the OIDC_ENTRA_* block under a different +# prefix for each name listed in OIDC_PROVIDERS. +# Microsoft Entra ID example: https://login.microsoftonline.com//v2.0 +OIDC_ENTRA_ISSUER_URL= +OIDC_ENTRA_CLIENT_ID= +OIDC_ENTRA_CLIENT_SECRET= +# Must match the redirect URI registered with the IdP. The path is the same +# for every provider; the state cookie disambiguates which one comes back. +# Devcontainer dev: http://localhost:4000/api/auth/callback +OIDC_ENTRA_REDIRECT_URL= +# Comma-separated. `openid` is required. +OIDC_ENTRA_SCOPES=openid,profile,email +# Label rendered on the UI login button. +OIDC_ENTRA_DISPLAY_NAME=Entra ID +# 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 +# `azure.workload.identity/use: "true"` and a federated credential on the +# Entra App Registration. Entra-only. +OIDC_ENTRA_USE_WORKLOAD_IDENTITY=false + +# Google example — create an OAuth 2.0 Web client at console.cloud.google.com +# (APIs & Services → Credentials). Configure the OAuth consent screen first +# and add yourself as a test user while the app is in Testing status. +OIDC_GOOGLE_ISSUER_URL=https://accounts.google.com +OIDC_GOOGLE_CLIENT_ID= +OIDC_GOOGLE_CLIENT_SECRET= +OIDC_GOOGLE_REDIRECT_URL= +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. +OIDC_LOGOUT_REDIRECT=false + +# Required when AUTH_ENABLED=true. At least 32 bytes of high-entropy data. +# Used to sign session JWTs (HS256) and to derive the AES-GCM key that +# encrypts the OIDC state cookie. Rotating this invalidates all sessions. +SESSION_SECRET= +# How long a session cookie is valid. Format: any time.ParseDuration value. +SESSION_TTL=8h +# Leave empty for host-only cookies. Set when serving the API and UI from +# different subdomains of the same parent (e.g. .example.com). +COOKIE_DOMAIN= +# Set to false ONLY for local HTTP development. +COOKIE_SECURE=true