From b5ab887d7455cfbd35c49c1a889153fc54c3111e Mon Sep 17 00:00:00 2001 From: Richard Boisvert Date: Fri, 27 Mar 2026 09:44:56 -0400 Subject: [PATCH] feat(oidc): implement Azure workload identity support for OIDC auth Signed-off-by: Richard Boisvert --- cmd/server.go | 76 ++++- cmd/server_test.go | 15 +- go.mod | 4 +- go.sum | 8 +- runatlantis.io/docs/server-configuration.md | 70 +++++ server/controllers/oidc_controller.go | 112 ++++++++ server/controllers/oidc_controller_test.go | 117 ++++++++ .../web_templates/templates/index.html.tmpl | 6 + .../web_templates/templates/login.html.tmpl | 31 ++ .../web_templates/web_templates.go | 5 + server/middleware.go | 106 +++++-- server/middleware_test.go | 161 +++++++++++ server/oidc/azure_workload_identity.go | 85 ++++++ server/oidc/azure_workload_identity_test.go | 72 +++++ server/oidc/provider.go | 156 ++++++++++ server/oidc/session.go | 270 ++++++++++++++++++ server/oidc/session_test.go | 183 ++++++++++++ server/server.go | 83 +++++- server/user_config.go | 19 +- 19 files changed, 1532 insertions(+), 47 deletions(-) create mode 100644 server/controllers/oidc_controller.go create mode 100644 server/controllers/oidc_controller_test.go create mode 100644 server/controllers/web_templates/templates/login.html.tmpl create mode 100644 server/middleware_test.go create mode 100644 server/oidc/azure_workload_identity.go create mode 100644 server/oidc/azure_workload_identity_test.go create mode 100644 server/oidc/provider.go create mode 100644 server/oidc/session.go create mode 100644 server/oidc/session_test.go diff --git a/cmd/server.go b/cmd/server.go index c0d5db4f8f..0230affc29 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -29,6 +29,7 @@ import ( "github.com/runatlantis/atlantis/server" "github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud" "github.com/runatlantis/atlantis/server/logging" + "github.com/runatlantis/atlantis/server/oidc" ) // checkout strategies @@ -159,10 +160,17 @@ const ( TFETokenFlag = "tfe-token" WriteGitCredsFlag = "write-git-creds" // nolint: gosec WebhookHttpHeaders = "webhook-http-headers" - WebBasicAuthFlag = "web-basic-auth" - WebUsernameFlag = "web-username" - WebPasswordFlag = "web-password" - WebsocketCheckOrigin = "websocket-check-origin" + WebBasicAuthFlag = "web-basic-auth" + WebUsernameFlag = "web-username" + WebPasswordFlag = "web-password" + WebOIDCAuthFlag = "web-oidc-auth" + WebOIDCIssuerURLFlag = "web-oidc-issuer-url" + WebOIDCClientIDFlag = "web-oidc-client-id" + WebOIDCClientSecretFlag = "web-oidc-client-secret" // nolint: gosec + WebOIDCScopesFlag = "web-oidc-scopes" + WebOIDCCookieSecretFlag = "web-oidc-cookie-secret" // nolint: gosec + WebOIDCAzureUseWorkloadIdentityFlag = "web-oidc-azure-use-workload-identity" + WebsocketCheckOrigin = "websocket-check-origin" // NOTE: Must manually set these as defaults in the setDefaults function. DefaultADBasicUser = "" @@ -201,6 +209,7 @@ const ( DefaultWebBasicAuth = false DefaultWebUsername = "atlantis" DefaultWebPassword = "atlantis" + DefaultWebOIDCScopes = "openid,profile,email" ) var stringFlags = map[string]stringFlag{ @@ -494,6 +503,30 @@ var stringFlags = map[string]stringFlag{ description: "Password used for Web Basic Authentication on Atlantis HTTP Middleware", defaultValue: DefaultWebPassword, }, + WebOIDCAuthFlag: { + description: "OIDC auth provider for the web UI. Set to 'entraid' to enable Entra ID authentication. " + + "Can coexist with basic auth. Empty means disabled.", + }, + WebOIDCIssuerURLFlag: { + description: "OIDC issuer URL for discovery (e.g. https://login.microsoftonline.com/{tenant-id}/v2.0). " + + "Required when --" + WebOIDCAuthFlag + " is set.", + }, + WebOIDCClientIDFlag: { + description: "OIDC application (client) ID. Required when --" + WebOIDCAuthFlag + " is set.", + }, + WebOIDCClientSecretFlag: { + description: "OIDC client secret. Not needed when using Azure workload identity. " + + "Can also be specified via the ATLANTIS_WEB_OIDC_CLIENT_SECRET environment variable.", + }, + WebOIDCScopesFlag: { + description: "Comma-separated OIDC scopes to request.", + defaultValue: DefaultWebOIDCScopes, + }, + WebOIDCCookieSecretFlag: { + description: "Secret key for signing OIDC session cookies (32+ characters recommended). " + + "Auto-generated if not set (sessions will not survive restarts). " + + "Can also be specified via the ATLANTIS_WEB_OIDC_COOKIE_SECRET environment variable.", + }, } var boolFlags = map[string]boolFlag{ @@ -642,6 +675,11 @@ var boolFlags = map[string]boolFlag{ description: "Block plan requests from projects outside the files modified in the pull request.", defaultValue: false, }, + WebOIDCAzureUseWorkloadIdentityFlag: { + description: "Use Azure workload identity (federated service account token) as client_assertion " + + "for OIDC token exchange instead of a client secret. Requires --" + WebOIDCAuthFlag + "=entraid.", + defaultValue: false, + }, WebsocketCheckOrigin: { description: "Enable websocket origin check", defaultValue: false, @@ -989,6 +1027,9 @@ func (s *ServerCmd) setDefaults(c *server.UserConfig, v *viper.Viper) { if c.WebPassword == "" { c.WebPassword = DefaultWebPassword } + if c.WebOIDCScopes == "" { + c.WebOIDCScopes = DefaultWebOIDCScopes + } if c.AutoDiscoverModeFlag == "" { c.AutoDiscoverModeFlag = DefaultAutoDiscoverMode } @@ -1107,6 +1148,33 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error { return fmt.Errorf("invalid --%s: %w", WebhookHttpHeaders, err) } + // Validate OIDC configuration. + if userConfig.WebOIDCAuth != "" { + if !oidc.IsValidOIDCAuthProvider(userConfig.WebOIDCAuth) { + return fmt.Errorf("invalid --%s: must be one of %v, got %q", WebOIDCAuthFlag, oidc.ValidOIDCAuthProviders, userConfig.WebOIDCAuth) + } + if userConfig.WebOIDCIssuerURL == "" { + return fmt.Errorf("--%s is required when --%s is set", WebOIDCIssuerURLFlag, WebOIDCAuthFlag) + } + u, err := url.Parse(userConfig.WebOIDCIssuerURL) + if err != nil { + return fmt.Errorf("invalid --%s: %w", WebOIDCIssuerURLFlag, err) + } + if u.Scheme == "" || u.Host == "" { + return fmt.Errorf("invalid --%s: must include scheme and host, got %q", WebOIDCIssuerURLFlag, userConfig.WebOIDCIssuerURL) + } + if u.Scheme != "https" { + return fmt.Errorf("invalid --%s: scheme must be https, got %q", WebOIDCIssuerURLFlag, u.Scheme) + } + if userConfig.WebOIDCClientID == "" { + return fmt.Errorf("--%s is required when --%s is set", WebOIDCClientIDFlag, WebOIDCAuthFlag) + } + if userConfig.WebOIDCClientSecret == "" && !userConfig.WebOIDCAzureUseWorkloadIdentity { + return fmt.Errorf("either --%s or --%s must be set when --%s is set", + WebOIDCClientSecretFlag, WebOIDCAzureUseWorkloadIdentityFlag, WebOIDCAuthFlag) + } + } + return nil } diff --git a/cmd/server_test.go b/cmd/server_test.go index d8c2f6e5d9..fbd6a8afc1 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -156,10 +156,17 @@ var testFlags = map[string]any{ VCSStatusName: "my-status", IgnoreVCSStatusNames: "", WebhookHttpHeaders: `{"Authorization":"Bearer some-token","X-Custom-Header":["value1","value2"]}`, - WebBasicAuthFlag: false, - WebPasswordFlag: "atlantis", - WebUsernameFlag: "atlantis", - WebsocketCheckOrigin: false, + WebBasicAuthFlag: false, + WebPasswordFlag: "atlantis", + WebUsernameFlag: "atlantis", + WebOIDCAuthFlag: "", + WebOIDCIssuerURLFlag: "", + WebOIDCClientIDFlag: "", + WebOIDCClientSecretFlag: "", + WebOIDCScopesFlag: "openid,profile,email", + WebOIDCCookieSecretFlag: "", + WebOIDCAzureUseWorkloadIdentityFlag: false, + WebsocketCheckOrigin: false, WriteGitCredsFlag: true, DisableAutoplanFlag: true, DisableAutoplanLabelFlag: "no-auto-plan", diff --git a/go.mod b/go.mod index ed99b7ab65..262d4a6dd4 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/bradleyfalzon/ghinstallation/v2 v2.17.0 github.com/briandowns/spinner v1.23.2 github.com/cactus/go-statsd-client/v5 v5.1.0 + github.com/coreos/go-oidc/v3 v3.17.0 github.com/drmaxgit/go-azuredevops v0.13.2 github.com/go-ozzo/ozzo-validation v3.6.0+incompatible github.com/go-playground/validator/v10 v10.30.1 @@ -50,6 +51,7 @@ require ( go.etcd.io/bbolt v1.4.3 go.uber.org/mock v0.6.0 go.uber.org/zap v1.27.1 + golang.org/x/oauth2 v0.28.0 golang.org/x/term v0.40.0 golang.org/x/text v0.34.0 gopkg.in/yaml.v3 v3.0.1 @@ -86,6 +88,7 @@ require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/go-fed/httpsig v1.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect @@ -137,7 +140,6 @@ require ( golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect - golang.org/x/oauth2 v0.27.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/time v0.8.0 // indirect diff --git a/go.sum b/go.sum index 9dc37bd93f..9bc8102f10 100644 --- a/go.sum +++ b/go.sum @@ -101,6 +101,8 @@ github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUK github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= @@ -140,6 +142,8 @@ github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj 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/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -588,8 +592,8 @@ golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4Iltr golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= -golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 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= diff --git a/runatlantis.io/docs/server-configuration.md b/runatlantis.io/docs/server-configuration.md index 32468a9207..3fc1c94890 100644 --- a/runatlantis.io/docs/server-configuration.md +++ b/runatlantis.io/docs/server-configuration.md @@ -1521,6 +1521,76 @@ ATLANTIS_WEB_BASIC_AUTH=true Enable Basic Authentication on the Atlantis web service. +### `--web-oidc-auth` + +```bash +atlantis server --web-oidc-auth="entraid" +# or +ATLANTIS_WEB_OIDC_AUTH="entraid" +``` + +OIDC auth provider for the web UI. Set to `entraid` to enable Entra ID (Azure AD) authentication. Can coexist with basic auth. Empty means disabled. + +### `--web-oidc-azure-use-workload-identity` + +```bash +atlantis server --web-oidc-azure-use-workload-identity +# or +ATLANTIS_WEB_OIDC_AZURE_USE_WORKLOAD_IDENTITY=true +``` + +Use Azure workload identity (federated service account token) as `client_assertion` for OIDC token exchange instead of a client secret. Requires `--web-oidc-auth=entraid`. The pod must have the `azure.workload.identity/use=true` label and the service account must be annotated with `azure.workload.identity/client-id`. + +### `--web-oidc-client-id` + +```bash +atlantis server --web-oidc-client-id="00000000-0000-0000-0000-000000000000" +# or +ATLANTIS_WEB_OIDC_CLIENT_ID="00000000-0000-0000-0000-000000000000" +``` + +OIDC application (client) ID. Required when `--web-oidc-auth` is set. + +### `--web-oidc-client-secret` + +```bash +atlantis server --web-oidc-client-secret="your-secret" +# or +ATLANTIS_WEB_OIDC_CLIENT_SECRET="your-secret" +``` + +OIDC client secret. Not needed when using Azure workload identity. + +### `--web-oidc-cookie-secret` + +```bash +atlantis server --web-oidc-cookie-secret="your-32-char-secret-key-here!!!" +# or +ATLANTIS_WEB_OIDC_COOKIE_SECRET="your-32-char-secret-key-here!!!" +``` + +Secret key for signing OIDC session cookies (32+ characters recommended). Auto-generated if not set (sessions will not survive restarts). + +### `--web-oidc-issuer-url` + +```bash +atlantis server --web-oidc-issuer-url="https://login.microsoftonline.com/{tenant-id}/v2.0" +# or +ATLANTIS_WEB_OIDC_ISSUER_URL="https://login.microsoftonline.com/{tenant-id}/v2.0" +``` + +OIDC issuer URL for discovery. Required when `--web-oidc-auth` is set. + +### `--web-oidc-scopes` + +```bash +atlantis server --web-oidc-scopes="openid,profile,email" +# or +ATLANTIS_WEB_OIDC_SCOPES="openid,profile,email" +``` + +Comma-separated OIDC scopes to request. Defaults to `openid,profile,email`. + ### `--web-password` ```bash diff --git a/server/controllers/oidc_controller.go b/server/controllers/oidc_controller.go new file mode 100644 index 0000000000..055a87e8bc --- /dev/null +++ b/server/controllers/oidc_controller.go @@ -0,0 +1,112 @@ +// Copyright 2025 The Atlantis Authors +// SPDX-License-Identifier: Apache-2.0 + +package controllers + +import ( + "fmt" + "net/http" + + "github.com/runatlantis/atlantis/server/logging" + "github.com/runatlantis/atlantis/server/oidc" +) + +// OIDCController handles the OIDC authentication flow for the Atlantis web UI. +type OIDCController struct { + Provider *oidc.Provider + SessionManager *oidc.SessionManager + Logger logging.SimpleLogging + BasePath string +} + +func (o *OIDCController) homeURL() string { + if o.BasePath == "" || o.BasePath == "/" { + return "/" + } + return o.BasePath + "/" +} + +// Login initiates the OIDC authorization code flow by redirecting the user +// to the identity provider's authorization endpoint. +func (o *OIDCController) Login(w http.ResponseWriter, r *http.Request) { + state, err := o.SessionManager.CreateState(w) + if err != nil { + o.Logger.Err("creating OIDC state - %s", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + authURL := o.Provider.AuthCodeURL(state) + o.Logger.Debug("OIDC login - redirecting to %q", authURL) + http.Redirect(w, r, authURL, http.StatusFound) +} + +// Callback handles the OIDC callback from the identity provider after user +// authentication. It exchanges the authorization code for tokens, verifies +// the ID token, and establishes a session cookie. +func (o *OIDCController) Callback(w http.ResponseWriter, r *http.Request) { + // Check for errors from the IDP. + if errParam := r.URL.Query().Get("error"); errParam != "" { + errDesc := r.URL.Query().Get("error_description") + o.Logger.Err("OIDC callback error from IDP - %s - %s", errParam, errDesc) + http.Error(w, fmt.Sprintf("Authentication error: %s - %s", errParam, errDesc), http.StatusBadRequest) + return + } + + // Verify state parameter. + state := r.URL.Query().Get("state") + if state == "" { + http.Error(w, "Missing state parameter", http.StatusBadRequest) + return + } + if err := o.SessionManager.VerifyState(r, state); err != nil { + o.Logger.Err("verifying OIDC state - %s", err) + http.Error(w, "Invalid state parameter", http.StatusBadRequest) + return + } + + // Exchange authorization code for tokens. + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "Missing authorization code", http.StatusBadRequest) + return + } + + _, rawIDToken, err := o.Provider.Exchange(r.Context(), code) + if err != nil { + o.Logger.Err("exchanging OIDC token - %s", err) + http.Error(w, "Authentication failed", http.StatusInternalServerError) + return + } + + // Extract the email (or preferred_username) from the ID token to store + // in the session cookie. This keeps the cookie small instead of + // embedding the full raw ID token. + email := oidc.ExtractEmail(rawIDToken) + if email == "" { + o.Logger.Err("OIDC callback - no email or preferred_username in ID token") + http.Error(w, "Authentication failed: no user identity in token", http.StatusBadRequest) + return + } + + // Set session cookie with only the email claim. + if err := o.SessionManager.SetSession(w, email); err != nil { + o.Logger.Err("setting OIDC session - %s", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Clear the state cookie. + o.SessionManager.ClearState(w) + + o.Logger.Info("OIDC login successful, redirecting to %q", o.homeURL()) + http.Redirect(w, r, o.homeURL(), http.StatusFound) +} + +// Logout clears the OIDC session cookie and redirects to the home page. +func (o *OIDCController) Logout(w http.ResponseWriter, r *http.Request) { + o.SessionManager.ClearSession(w) + + o.Logger.Info("OIDC logout, redirecting to %q", o.homeURL()) + http.Redirect(w, r, o.homeURL(), http.StatusFound) +} diff --git a/server/controllers/oidc_controller_test.go b/server/controllers/oidc_controller_test.go new file mode 100644 index 0000000000..a941eb13d1 --- /dev/null +++ b/server/controllers/oidc_controller_test.go @@ -0,0 +1,117 @@ +// Copyright 2025 The Atlantis Authors +// SPDX-License-Identifier: Apache-2.0 + +package controllers_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/runatlantis/atlantis/server/controllers" + "github.com/runatlantis/atlantis/server/logging" + "github.com/runatlantis/atlantis/server/oidc" + . "github.com/runatlantis/atlantis/testing" +) + +func TestOIDCController_Login_Redirect(t *testing.T) { + logger := logging.NewNoopLogger(t) + sm := oidc.NewSessionManager([]byte("test-secret-32-characters-long!!"), false, "", logger) + + w := httptest.NewRecorder() + _, err := sm.CreateState(w) + Ok(t, err) + + cookies := w.Result().Cookies() + var stateCookie *http.Cookie + for _, c := range cookies { + if c.Name == "atlantis_oidc_state" { + stateCookie = c + } + } + Assert(t, stateCookie != nil, "expected state cookie to be set") +} + +func TestOIDCController_Callback_MissingCode(t *testing.T) { + logger := logging.NewNoopLogger(t) + sm := oidc.NewSessionManager([]byte("test-secret-32-characters-long!!"), false, "", logger) + + ctrl := &controllers.OIDCController{ + Provider: nil, + SessionManager: sm, + Logger: logger, + } + + r := httptest.NewRequest("GET", "/auth/callback?state=test", nil) + r.AddCookie(&http.Cookie{ + Name: "atlantis_oidc_state", + Value: "test", + }) + w := httptest.NewRecorder() + + ctrl.Callback(w, r) + + Equals(t, http.StatusBadRequest, w.Code) +} + +func TestOIDCController_Callback_MissingState(t *testing.T) { + logger := logging.NewNoopLogger(t) + sm := oidc.NewSessionManager([]byte("test-secret-32-characters-long!!"), false, "", logger) + + ctrl := &controllers.OIDCController{ + Provider: nil, + SessionManager: sm, + Logger: logger, + } + + r := httptest.NewRequest("GET", "/auth/callback", nil) + w := httptest.NewRecorder() + + ctrl.Callback(w, r) + + Equals(t, http.StatusBadRequest, w.Code) +} + +func TestOIDCController_Callback_IDPError(t *testing.T) { + logger := logging.NewNoopLogger(t) + sm := oidc.NewSessionManager([]byte("test-secret-32-characters-long!!"), false, "", logger) + + ctrl := &controllers.OIDCController{ + Provider: nil, + SessionManager: sm, + Logger: logger, + } + + r := httptest.NewRequest("GET", "/auth/callback?error=access_denied&error_description=User+cancelled", nil) + w := httptest.NewRecorder() + + ctrl.Callback(w, r) + + Equals(t, http.StatusBadRequest, w.Code) +} + +func TestOIDCController_Logout(t *testing.T) { + logger := logging.NewNoopLogger(t) + sm := oidc.NewSessionManager([]byte("test-secret-32-characters-long!!"), false, "", logger) + + ctrl := &controllers.OIDCController{ + Provider: nil, + SessionManager: sm, + Logger: logger, + } + + r := httptest.NewRequest("GET", "/auth/logout", nil) + w := httptest.NewRecorder() + + ctrl.Logout(w, r) + + Equals(t, http.StatusFound, w.Code) + + var cleared bool + for _, c := range w.Result().Cookies() { + if c.Name == "atlantis_oidc" && c.MaxAge == -1 { + cleared = true + } + } + Assert(t, cleared, "expected session cookie to be cleared") +} diff --git a/server/controllers/web_templates/templates/index.html.tmpl b/server/controllers/web_templates/templates/index.html.tmpl index b9021f9b61..8ee351596b 100644 --- a/server/controllers/web_templates/templates/index.html.tmpl +++ b/server/controllers/web_templates/templates/index.html.tmpl @@ -29,6 +29,12 @@

atlantis

+ {{ if .OIDCEnabled }} +
+ {{ if .UserEmail }}{{ .UserEmail }} | {{ end }} + Sign out +
+ {{ end }}

Plan discarded and unlocked!

diff --git a/server/controllers/web_templates/templates/login.html.tmpl b/server/controllers/web_templates/templates/login.html.tmpl new file mode 100644 index 0000000000..d32d551603 --- /dev/null +++ b/server/controllers/web_templates/templates/login.html.tmpl @@ -0,0 +1,31 @@ + + + + + atlantis - Sign In + + + + + + + + + +
+
+ +

atlantis

+
+
+
+

Authentication is required to access the Atlantis web UI.

+ Sign in with {{ .ProviderName }} +
+
+
+ + + diff --git a/server/controllers/web_templates/web_templates.go b/server/controllers/web_templates/web_templates.go index 4b67cf2b2a..c867424b30 100644 --- a/server/controllers/web_templates/web_templates.go +++ b/server/controllers/web_templates/web_templates.go @@ -34,6 +34,7 @@ var templates, _ = template.New("").Funcs(sprig.TxtFuncMap()).ParseFS(templatesF var templateFileNames = map[string]string{ "index": "index.html.tmpl", "lock": "lock.html.tmpl", + "login": "login.html.tmpl", "project-jobs": "project-jobs.html.tmpl", "project-jobs-error": "project-jobs-error.html.tmpl", "github-app": "github-app.html.tmpl", @@ -78,6 +79,10 @@ type IndexData struct { // not using a path-based proxy, this will be an empty string. Never ends // in a '/' (hence "cleaned"). CleanedBasePath string + // OIDCEnabled is true when OIDC authentication is configured. + OIDCEnabled bool + // UserEmail is the authenticated user's email (from OIDC), if available. + UserEmail string } var IndexTemplate = templates.Lookup(templateFileNames["index"]) diff --git a/server/middleware.go b/server/middleware.go index dea4fd19f0..ef0ee50f7a 100644 --- a/server/middleware.go +++ b/server/middleware.go @@ -18,56 +18,104 @@ import ( "strings" "github.com/runatlantis/atlantis/server/logging" + "github.com/runatlantis/atlantis/server/oidc" "github.com/urfave/negroni/v3" ) // NewRequestLogger creates a RequestLogger. func NewRequestLogger(s *Server) *RequestLogger { - return &RequestLogger{ - s.Logger, - s.WebAuthentication, - s.WebUsername, - s.WebPassword, + rl := &RequestLogger{ + Logger: s.Logger, + WebAuthentication: s.WebAuthentication, + WebUsername: s.WebUsername, + WebPassword: s.WebPassword, } + if s.OIDCController != nil { + rl.WebOIDCAuth = s.WebOIDCAuth + rl.OIDCSessionManager = s.OIDCController.SessionManager + } + return rl } -// RequestLogger logs requests and their response codes. -// as well as handle the basicauth on the requests +// RequestLogger logs requests and their response codes, +// as well as handling authentication on the requests. type RequestLogger struct { - logger logging.SimpleLogging - WebAuthentication bool - WebUsername string - WebPassword string + Logger logging.SimpleLogging + WebAuthentication bool + WebUsername string + WebPassword string + WebOIDCAuth oidc.OIDCAuthProvider + OIDCSessionManager *oidc.SessionManager +} + +// isExemptPath returns true if the request path should bypass authentication. +func isExemptPath(path string) bool { + return path == "/events" || + path == "/healthz" || + path == "/status" || + strings.HasPrefix(path, "/api/") || + strings.HasPrefix(path, "/auth/") +} + +// authEnabled returns true if any authentication method is configured. +func (l *RequestLogger) authEnabled() bool { + return l.WebAuthentication || l.WebOIDCAuth != "" } -// ServeHTTP implements the middleware function. It logs all requests at DEBUG level. +// ServeHTTP implements the middleware function. It logs all requests at DEBUG +// level and enforces authentication when configured. func (l *RequestLogger) ServeHTTP(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { - l.logger.Debug("%s %s – from %s", r.Method, r.URL.RequestURI(), r.RemoteAddr) + l.Logger.Debug("%s %s – from %s", r.Method, r.URL.RequestURI(), r.RemoteAddr) + allowed := false - if !l.WebAuthentication || - r.URL.Path == "/events" || - r.URL.Path == "/healthz" || - r.URL.Path == "/status" || - strings.HasPrefix(r.URL.Path, "/api/") { + + hadBasicAuth := false + + if !l.authEnabled() || isExemptPath(r.URL.Path) { allowed = true } else { - user, pass, ok := r.BasicAuth() - if ok { - r.SetBasicAuth(user, pass) - if user == l.WebUsername && pass == l.WebPassword { - l.logger.Debug("[VALID] log in: >> url: %s", r.URL.RequestURI()) + // Try basic auth first if enabled. + if l.WebAuthentication { + user, pass, ok := r.BasicAuth() + if ok { + hadBasicAuth = true + if user == l.WebUsername && pass == l.WebPassword { + l.Logger.Debug("[VALID] basic auth log in: >> url: %s", r.URL.RequestURI()) + allowed = true + } else { + l.Logger.Info("[INVALID] basic auth log in attempt: >> url: %s", r.URL.RequestURI()) + } + } + } + + // Try OIDC session cookie if basic auth didn't succeed. + if !allowed && l.WebOIDCAuth != "" && l.OIDCSessionManager != nil { + if _, err := l.OIDCSessionManager.GetSession(r); err == nil { + l.Logger.Debug("[VALID] OIDC session >> url %q", r.URL.RequestURI()) allowed = true - } else { - allowed = false - l.logger.Info("[INVALID] log in attempt: >> url: %s", r.URL.RequestURI()) } } } + if !allowed { - rw.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) - http.Error(rw, "Unauthorized", http.StatusUnauthorized) + l.handleUnauthorized(rw, r, hadBasicAuth) } else { next(rw, r) } - l.logger.Debug("%s %s – respond HTTP %d", r.Method, r.URL.RequestURI(), rw.(negroni.ResponseWriter).Status()) + l.Logger.Debug("%s %s – respond HTTP %d", r.Method, r.URL.RequestURI(), rw.(negroni.ResponseWriter).Status()) +} + +// handleUnauthorized sends the appropriate response for unauthenticated +// requests depending on which auth methods are configured. +func (l *RequestLogger) handleUnauthorized(rw http.ResponseWriter, r *http.Request, hadBasicAuth bool) { + // If OIDC is enabled and the request doesn't have basic auth credentials, + // redirect to the login page instead of returning a 401. + if l.WebOIDCAuth != "" && !hadBasicAuth { + http.Redirect(rw, r, "auth/login", http.StatusFound) + return + } + + // Fall back to basic auth challenge. + rw.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) + http.Error(rw, "Unauthorized", http.StatusUnauthorized) } diff --git a/server/middleware_test.go b/server/middleware_test.go new file mode 100644 index 0000000000..6998a9cf62 --- /dev/null +++ b/server/middleware_test.go @@ -0,0 +1,161 @@ +// Copyright 2025 The Atlantis Authors +// SPDX-License-Identifier: Apache-2.0 + +package server_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/runatlantis/atlantis/server" + "github.com/runatlantis/atlantis/server/logging" + "github.com/runatlantis/atlantis/server/oidc" + . "github.com/runatlantis/atlantis/testing" + "github.com/urfave/negroni/v3" +) + +func newMiddleware(t *testing.T, basicAuth bool, username, password string, oidcAuth oidc.OIDCAuthProvider, sm *oidc.SessionManager) *server.RequestLogger { + t.Helper() + return &server.RequestLogger{ + Logger: logging.NewNoopLogger(t), + WebAuthentication: basicAuth, + WebUsername: username, + WebPassword: password, + WebOIDCAuth: oidcAuth, + OIDCSessionManager: sm, + } +} + +func serveMiddleware(mw *server.RequestLogger, r *http.Request) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + w := negroni.NewResponseWriter(rec) + mw.ServeHTTP(w, r, func(rw http.ResponseWriter, _ *http.Request) { + rw.WriteHeader(http.StatusOK) + rw.Write([]byte("OK")) //nolint:errcheck + }) + return rec +} + +func TestMiddleware_NoAuthEnabled(t *testing.T) { + mw := newMiddleware(t, false, "", "", "", nil) + r := httptest.NewRequest("GET", "/", nil) + w := serveMiddleware(mw, r) + + Equals(t, http.StatusOK, w.Code) +} + +func TestMiddleware_ExemptPaths(t *testing.T) { + mw := newMiddleware(t, true, "user", "pass", oidc.OIDCAuthEntraID, nil) + + for _, path := range []string{"/events", "/healthz", "/status", "/api/plan", "/auth/login", "/auth/callback"} { + r := httptest.NewRequest("GET", path, nil) + w := serveMiddleware(mw, r) + Equals(t, http.StatusOK, w.Code) + } +} + +func TestMiddleware_BasicAuth_ValidCredentials(t *testing.T) { + mw := newMiddleware(t, true, "admin", "secret", "", nil) + + r := httptest.NewRequest("GET", "/", nil) + r.SetBasicAuth("admin", "secret") + w := serveMiddleware(mw, r) + + Equals(t, http.StatusOK, w.Code) +} + +func TestMiddleware_BasicAuth_InvalidCredentials(t *testing.T) { + mw := newMiddleware(t, true, "admin", "secret", "", nil) + + r := httptest.NewRequest("GET", "/", nil) + r.SetBasicAuth("admin", "wrong") + w := serveMiddleware(mw, r) + + Equals(t, http.StatusUnauthorized, w.Code) + Assert(t, w.Header().Get("WWW-Authenticate") != "", "expected WWW-Authenticate header") +} + +func TestMiddleware_BasicAuth_NoCreds(t *testing.T) { + mw := newMiddleware(t, true, "admin", "secret", "", nil) + + r := httptest.NewRequest("GET", "/", nil) + w := serveMiddleware(mw, r) + + Equals(t, http.StatusUnauthorized, w.Code) +} + +func TestMiddleware_OIDC_ValidSession(t *testing.T) { + logger := logging.NewNoopLogger(t) + sm := oidc.NewSessionManager([]byte("test-secret-32-characters-long!!"), false, "", logger) + + cookieW := httptest.NewRecorder() + Ok(t, sm.SetSession(cookieW, "user@example.com")) + + mw := newMiddleware(t, false, "", "", oidc.OIDCAuthEntraID, sm) + + r := httptest.NewRequest("GET", "/", nil) + for _, c := range cookieW.Result().Cookies() { + r.AddCookie(c) + } + w := serveMiddleware(mw, r) + + Equals(t, http.StatusOK, w.Code) +} + +func TestMiddleware_OIDC_NoSession_RedirectsToLogin(t *testing.T) { + logger := logging.NewNoopLogger(t) + sm := oidc.NewSessionManager([]byte("test-secret-32-characters-long!!"), false, "", logger) + + mw := newMiddleware(t, false, "", "", oidc.OIDCAuthEntraID, sm) + + r := httptest.NewRequest("GET", "/", nil) + w := serveMiddleware(mw, r) + + Equals(t, http.StatusFound, w.Code) + Equals(t, "/auth/login", w.Header().Get("Location")) +} + +func TestMiddleware_BothAuth_BasicAuthWins(t *testing.T) { + logger := logging.NewNoopLogger(t) + sm := oidc.NewSessionManager([]byte("test-secret-32-characters-long!!"), false, "", logger) + + mw := newMiddleware(t, true, "admin", "secret", oidc.OIDCAuthEntraID, sm) + + r := httptest.NewRequest("GET", "/", nil) + r.SetBasicAuth("admin", "secret") + w := serveMiddleware(mw, r) + + Equals(t, http.StatusOK, w.Code) +} + +func TestMiddleware_BothAuth_OIDCFallback(t *testing.T) { + logger := logging.NewNoopLogger(t) + sm := oidc.NewSessionManager([]byte("test-secret-32-characters-long!!"), false, "", logger) + + cookieW := httptest.NewRecorder() + Ok(t, sm.SetSession(cookieW, "user@example.com")) + + mw := newMiddleware(t, true, "admin", "secret", oidc.OIDCAuthEntraID, sm) + + r := httptest.NewRequest("GET", "/", nil) + for _, c := range cookieW.Result().Cookies() { + r.AddCookie(c) + } + w := serveMiddleware(mw, r) + + Equals(t, http.StatusOK, w.Code) +} + +func TestMiddleware_BothAuth_InvalidBasicAuth_Returns401(t *testing.T) { + logger := logging.NewNoopLogger(t) + sm := oidc.NewSessionManager([]byte("test-secret-32-characters-long!!"), false, "", logger) + + mw := newMiddleware(t, true, "admin", "secret", oidc.OIDCAuthEntraID, sm) + + r := httptest.NewRequest("GET", "/", nil) + r.SetBasicAuth("admin", "wrong") + w := serveMiddleware(mw, r) + + Equals(t, http.StatusUnauthorized, w.Code) +} diff --git a/server/oidc/azure_workload_identity.go b/server/oidc/azure_workload_identity.go new file mode 100644 index 0000000000..9ed34cdf9d --- /dev/null +++ b/server/oidc/azure_workload_identity.go @@ -0,0 +1,85 @@ +// Copyright 2025 The Atlantis Authors +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "fmt" + "os" + "sync" + "time" + + "github.com/runatlantis/atlantis/server/logging" +) + +const ( + // azureFederatedTokenFileEnv is the environment variable set by the AKS + // workload identity mutation webhook pointing to the projected service + // account token file. + azureFederatedTokenFileEnv = "AZURE_FEDERATED_TOKEN_FILE" + + // tokenCacheTTL controls how long a federated token is cached before + // re-reading from disk. Kubernetes rotates projected tokens at 80% of + // their TTL (minimum 1 hour), so 10 minutes gives comfortable margin. + tokenCacheTTL = 10 * time.Minute +) + +// AzureWorkloadIdentity reads the projected service account token from disk +// and caches it for use as a client_assertion during OIDC token exchange. +type AzureWorkloadIdentity struct { + mu sync.RWMutex + cachedToken string + cacheExpiration time.Time + tokenFile string + logger logging.SimpleLogging +} + +// NewAzureWorkloadIdentity creates a new AzureWorkloadIdentity. It resolves +// the token file path from AZURE_FEDERATED_TOKEN_FILE at construction time +// and returns an error if the environment variable is not set. +func NewAzureWorkloadIdentity(logger logging.SimpleLogging) (*AzureWorkloadIdentity, error) { + file, ok := os.LookupEnv(azureFederatedTokenFileEnv) + if !ok || file == "" { + return nil, fmt.Errorf("AZURE_FEDERATED_TOKEN_FILE environment variable is not set; " + + "ensure the pod has the azure.workload.identity/use=true label and the " + + "service account is annotated with azure.workload.identity/client-id") + } + return &AzureWorkloadIdentity{ + tokenFile: file, + logger: logger, + }, nil +} + +// GetFederatedToken returns the federated service account token for use as a +// client_assertion in OAuth2 token exchange. It reads from the file resolved +// at construction time and caches the result with double-check locking for +// thread safety. +func (a *AzureWorkloadIdentity) GetFederatedToken() (string, error) { + // Fast path: return cached token if still valid. + a.mu.RLock() + if time.Now().Before(a.cacheExpiration) { + token := a.cachedToken + a.mu.RUnlock() + return token, nil + } + a.mu.RUnlock() + + // Slow path: acquire write lock and re-check (double-check locking). + a.mu.Lock() + defer a.mu.Unlock() + + if time.Now().Before(a.cacheExpiration) { + return a.cachedToken, nil + } + + content, err := os.ReadFile(a.tokenFile) + if err != nil { + return "", fmt.Errorf("reading federated token from %s: %w", a.tokenFile, err) + } + + a.cachedToken = string(content) + a.cacheExpiration = time.Now().Add(tokenCacheTTL) + a.logger.Debug("refreshed azure federated token from %q", a.tokenFile) + + return a.cachedToken, nil +} diff --git a/server/oidc/azure_workload_identity_test.go b/server/oidc/azure_workload_identity_test.go new file mode 100644 index 0000000000..9136870124 --- /dev/null +++ b/server/oidc/azure_workload_identity_test.go @@ -0,0 +1,72 @@ +// Copyright 2025 The Atlantis Authors +// SPDX-License-Identifier: Apache-2.0 + +package oidc_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/runatlantis/atlantis/server/oidc" + . "github.com/runatlantis/atlantis/testing" +) + +func setupAzureIdentity(t *testing.T, tokenContent string) { + t.Helper() + tempDir := t.TempDir() + tokenFilePath := filepath.Join(tempDir, "token.txt") + Ok(t, os.WriteFile(tokenFilePath, []byte(tokenContent), 0600)) + t.Setenv("AZURE_FEDERATED_TOKEN_FILE", tokenFilePath) +} + +func TestAzureWorkloadIdentity_GetFederatedToken(t *testing.T) { + logger := newTestLogger(t) + setupAzureIdentity(t, "serviceAccountToken") + + awi, err := oidc.NewAzureWorkloadIdentity(logger) + Ok(t, err) + + token, err := awi.GetFederatedToken() + Ok(t, err) + Equals(t, "serviceAccountToken", token) +} + +func TestAzureWorkloadIdentity_CachesToken(t *testing.T) { + logger := newTestLogger(t) + setupAzureIdentity(t, "initial-token") + + awi, err := oidc.NewAzureWorkloadIdentity(logger) + Ok(t, err) + + token1, err := awi.GetFederatedToken() + Ok(t, err) + + // Overwrite the file with different content. + file := os.Getenv("AZURE_FEDERATED_TOKEN_FILE") + Ok(t, os.WriteFile(file, []byte("updated-token"), 0600)) + + // Second call should return cached token (not the new file content). + token2, err := awi.GetFederatedToken() + Ok(t, err) + Equals(t, token1, token2) +} + +func TestAzureWorkloadIdentity_MissingEnvVar(t *testing.T) { + logger := newTestLogger(t) + t.Setenv("AZURE_FEDERATED_TOKEN_FILE", "") + + _, err := oidc.NewAzureWorkloadIdentity(logger) + Assert(t, err != nil, "expected error when AZURE_FEDERATED_TOKEN_FILE is empty") +} + +func TestAzureWorkloadIdentity_NonExistentFile(t *testing.T) { + logger := newTestLogger(t) + t.Setenv("AZURE_FEDERATED_TOKEN_FILE", "/nonexistent/path/token.txt") + + awi, err := oidc.NewAzureWorkloadIdentity(logger) + Ok(t, err) + + _, err = awi.GetFederatedToken() + Assert(t, err != nil, "expected error when token file does not exist") +} diff --git a/server/oidc/provider.go b/server/oidc/provider.go new file mode 100644 index 0000000000..7fbc23b9c7 --- /dev/null +++ b/server/oidc/provider.go @@ -0,0 +1,156 @@ +// Copyright 2025 The Atlantis Authors +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "slices" + "strings" + + gooidc "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "github.com/runatlantis/atlantis/server/logging" +) + +// OIDCAuthProvider represents a supported OIDC authentication provider. +type OIDCAuthProvider string + +const ( + // OIDCAuthEntraID enables Entra ID (Azure AD) as the OIDC provider. + OIDCAuthEntraID OIDCAuthProvider = "entraid" +) + +// ValidOIDCAuthProviders contains all supported provider values. +var ValidOIDCAuthProviders = []OIDCAuthProvider{OIDCAuthEntraID} + +// IsValidOIDCAuthProvider returns true if the given string is a supported provider. +func IsValidOIDCAuthProvider(s string) bool { + return slices.Contains(ValidOIDCAuthProviders, OIDCAuthProvider(s)) +} + +const ( + // clientAssertionType is the OAuth2 parameter value for JWT bearer + // client assertion, used with Azure workload identity. + clientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" +) + +// Provider wraps the OIDC discovery provider and OAuth2 configuration needed +// to perform the authorization code flow. +type Provider struct { + oidcProvider *gooidc.Provider + oauth2Config oauth2.Config + verifier *gooidc.IDTokenVerifier + azureWI *AzureWorkloadIdentity + logger logging.SimpleLogging +} + +// NewProvider performs OIDC discovery against the issuer URL and returns a +// configured Provider ready for authorization code flow. +func NewProvider(ctx context.Context, issuerURL, clientID, clientSecret, redirectURL string, scopes []string, azureWI *AzureWorkloadIdentity, logger logging.SimpleLogging) (*Provider, error) { + oidcProvider, err := gooidc.NewProvider(ctx, issuerURL) + if err != nil { + return nil, fmt.Errorf("running OIDC discovery for %q: %w", issuerURL, err) + } + + oauth2Config := oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Endpoint: oidcProvider.Endpoint(), + Scopes: scopes, + } + + verifier := oidcProvider.Verifier(&gooidc.Config{ + ClientID: clientID, + }) + + logger.Info("OIDC provider initialized - issuer=%q clientID=%q", issuerURL, clientID) + + return &Provider{ + oidcProvider: oidcProvider, + oauth2Config: oauth2Config, + verifier: verifier, + azureWI: azureWI, + logger: logger, + }, nil +} + +// AuthCodeURL generates the authorization URL that redirects the user to the +// identity provider for authentication. +func (p *Provider) AuthCodeURL(state string) string { + return p.oauth2Config.AuthCodeURL(state) +} + +// Exchange trades an authorization code for tokens. When Azure workload +// identity is configured, it passes the federated service account token as +// a client_assertion instead of using a client_secret. +// +// Returns the verified ID token, the raw ID token string, and any error. +func (p *Provider) Exchange(ctx context.Context, code string) (*gooidc.IDToken, string, error) { + var options []oauth2.AuthCodeOption + + if p.azureWI != nil { + clientAssertion, err := p.azureWI.GetFederatedToken() + if err != nil { + return nil, "", fmt.Errorf("getting federated token: %w", err) + } + options = []oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("client_assertion_type", clientAssertionType), + oauth2.SetAuthURLParam("client_assertion", clientAssertion), + } + } + + token, err := p.oauth2Config.Exchange(ctx, code, options...) + if err != nil { + return nil, "", fmt.Errorf("exchanging authorization code: %w", err) + } + + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + return nil, "", fmt.Errorf("no id_token in token response") + } + + idToken, err := p.verifier.Verify(ctx, rawIDToken) + if err != nil { + return nil, "", fmt.Errorf("verifying id_token: %w", err) + } + + return idToken, rawIDToken, nil +} + +// Verify validates a raw ID token string and returns the parsed token. +// Used for verifying session tokens on subsequent requests. +func (p *Provider) Verify(ctx context.Context, rawIDToken string) (*gooidc.IDToken, error) { + return p.verifier.Verify(ctx, rawIDToken) +} + +// ExtractEmail extracts the email claim from a raw JWT ID token by decoding +// the payload without signature verification (the token was already verified +// when the session was established). Falls back to preferred_username for +// Entra ID tokens that may not include an email claim. +func ExtractEmail(rawIDToken string) string { + parts := strings.Split(rawIDToken, ".") + if len(parts) != 3 { + return "" + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "" + } + var claims struct { + Email string `json:"email"` + PreferredUsername string `json:"preferred_username"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return "" + } + if claims.Email != "" { + return claims.Email + } + return claims.PreferredUsername +} diff --git a/server/oidc/session.go b/server/oidc/session.go new file mode 100644 index 0000000000..24a650fa68 --- /dev/null +++ b/server/oidc/session.go @@ -0,0 +1,270 @@ +// Copyright 2025 The Atlantis Authors +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/runatlantis/atlantis/server/logging" +) + +const ( + // sessionCookieName is the name of the HTTP cookie storing the OIDC session. + sessionCookieName = "atlantis_oidc" + + // stateCookieName is the name of the HTTP cookie storing the OAuth2 state. + stateCookieName = "atlantis_oidc_state" + + // sessionDuration is how long a session cookie remains valid. + sessionDuration = 8 * time.Hour + + // stateDuration is how long an OAuth2 state parameter remains valid. + stateDuration = 5 * time.Minute +) + +// SessionManager handles OIDC session cookies and OAuth2 state parameters. +// Session cookies are AES-GCM encrypted to avoid storing PII (email) in +// plaintext. State cookies use HMAC-SHA256 signed JWTs. +type SessionManager struct { + cookieSecret []byte + encKey []byte // 32-byte AES-256 key derived from cookieSecret + secure bool // set Secure flag on cookies (requires HTTPS) + basePath string + logger logging.SimpleLogging +} + +// NewSessionManager creates a new SessionManager. If cookieSecret is empty, +// a random 32-byte secret is generated (sessions won't survive restarts). +func NewSessionManager(cookieSecret []byte, secure bool, basePath string, logger logging.SimpleLogging) *SessionManager { + if len(cookieSecret) == 0 { + cookieSecret = make([]byte, 32) + if _, err := rand.Read(cookieSecret); err != nil { + // This should never happen; rand.Read uses /dev/urandom. + panic("failed to generate random cookie secret: " + err.Error()) + } + logger.Warn("no --web-oidc-cookie-secret provided, generated ephemeral secret. " + + "OIDC sessions will not survive server restarts.") + } + // Derive a separate 32-byte AES key from the cookie secret so HMAC + // signing (state cookies) and AES encryption (session cookies) use + // independent keys. + encKeyHash := sha256.Sum256(append([]byte("atlantis-session-enc:"), cookieSecret...)) + return &SessionManager{ + cookieSecret: cookieSecret, + encKey: encKeyHash[:], + secure: secure, + basePath: basePath, + logger: logger, + } +} + +func (s *SessionManager) cookiePath() string { + bp := s.basePath + if bp == "" || bp == "/" { + return "/" + } + if bp[0] != '/' { + bp = "/" + bp + } + return bp +} + +func (s *SessionManager) clearCookie(w http.ResponseWriter, name string) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: "", + Path: s.cookiePath(), + MaxAge: -1, + HttpOnly: true, + Secure: s.secure, + SameSite: http.SameSiteLaxMode, + }) +} + +// sessionPayload is the JSON structure encrypted inside the session cookie. +type sessionPayload struct { + Email string `json:"email"` + ExpiresAt int64 `json:"exp"` +} + +// encrypt encrypts plaintext using AES-256-GCM and returns a base64url-encoded +// ciphertext (nonce prepended). +func (s *SessionManager) encrypt(plaintext []byte) (string, error) { + block, err := aes.NewCipher(s.encKey) + if err != nil { + return "", fmt.Errorf("creating AES cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("creating GCM: %w", err) + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", fmt.Errorf("generating nonce: %w", err) + } + ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) + return base64.RawURLEncoding.EncodeToString(ciphertext), nil +} + +// decrypt decodes a base64url-encoded ciphertext and decrypts it using +// AES-256-GCM. +func (s *SessionManager) decrypt(encoded string) ([]byte, error) { + data, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("decoding session cookie: %w", err) + } + block, err := aes.NewCipher(s.encKey) + if err != nil { + return nil, fmt.Errorf("creating AES cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("creating GCM: %w", err) + } + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return nil, errors.New("session cookie too short") + } + plaintext, err := gcm.Open(nil, data[:nonceSize], data[nonceSize:], nil) + if err != nil { + return nil, fmt.Errorf("decrypting session cookie: %w", err) + } + return plaintext, nil +} + +// SetSession writes an HTTP-only session cookie containing an AES-GCM +// encrypted payload with the user's email and expiry. The email is never +// stored in plaintext in the cookie. +func (s *SessionManager) SetSession(w http.ResponseWriter, email string) error { + payload := sessionPayload{ + Email: email, + ExpiresAt: time.Now().Add(sessionDuration).Unix(), + } + plaintext, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshaling session payload: %w", err) + } + encrypted, err := s.encrypt(plaintext) + if err != nil { + return fmt.Errorf("encrypting session: %w", err) + } + + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: encrypted, + Path: s.cookiePath(), + MaxAge: int(sessionDuration.Seconds()), + HttpOnly: true, + Secure: s.secure, + SameSite: http.SameSiteLaxMode, + }) + return nil +} + +// GetSession reads the session cookie, decrypts it, checks expiry, and +// returns the user's email. Returns an error if the cookie is missing, +// expired, or has been tampered with. +func (s *SessionManager) GetSession(r *http.Request) (string, error) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil { + return "", fmt.Errorf("session cookie not found: %w", err) + } + + plaintext, err := s.decrypt(cookie.Value) + if err != nil { + return "", fmt.Errorf("invalid session cookie: %w", err) + } + + var payload sessionPayload + if err := json.Unmarshal(plaintext, &payload); err != nil { + return "", fmt.Errorf("invalid session payload: %w", err) + } + + if time.Now().Unix() > payload.ExpiresAt { + return "", errors.New("session expired") + } + + return payload.Email, nil +} + +// ClearSession deletes the session cookie. +func (s *SessionManager) ClearSession(w http.ResponseWriter) { + s.clearCookie(w, sessionCookieName) +} + +// CreateState generates a signed state JWT for OAuth2 CSRF protection. +// The state contains a random nonce and expires after 5 minutes. +// It also sets a state cookie for verification during callback. +func (s *SessionManager) CreateState(w http.ResponseWriter) (string, error) { + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + return "", fmt.Errorf("generating state nonce: %w", err) + } + + now := time.Now() + claims := jwt.RegisteredClaims{ + Subject: base64.RawURLEncoding.EncodeToString(nonce), + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(stateDuration)), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString(s.cookieSecret) + if err != nil { + return "", fmt.Errorf("signing state token: %w", err) + } + + http.SetCookie(w, &http.Cookie{ + Name: stateCookieName, + Value: signed, + Path: s.cookiePath(), + MaxAge: int(stateDuration.Seconds()), + HttpOnly: true, + Secure: s.secure, + SameSite: http.SameSiteLaxMode, + }) + + return signed, nil +} + +// VerifyState validates the state parameter from the OAuth2 callback by +// comparing it to the state cookie. +func (s *SessionManager) VerifyState(r *http.Request, state string) error { + cookie, err := r.Cookie(stateCookieName) + if err != nil { + return fmt.Errorf("state cookie not found: %w", err) + } + + if cookie.Value != state { + return errors.New("state parameter does not match state cookie") + } + + // Also verify the JWT is valid and not expired. + claims := &jwt.RegisteredClaims{} + token, err := jwt.ParseWithClaims(state, claims, func(_ *jwt.Token) (any, error) { + return s.cookieSecret, nil + }, jwt.WithValidMethods([]string{"HS256"})) + if err != nil { + return fmt.Errorf("invalid state token: %w", err) + } + if !token.Valid { + return errors.New("state token is not valid") + } + + return nil +} + +// ClearState deletes the state cookie after callback processing. +func (s *SessionManager) ClearState(w http.ResponseWriter) { + s.clearCookie(w, stateCookieName) +} diff --git a/server/oidc/session_test.go b/server/oidc/session_test.go new file mode 100644 index 0000000000..fbf8e98192 --- /dev/null +++ b/server/oidc/session_test.go @@ -0,0 +1,183 @@ +// Copyright 2025 The Atlantis Authors +// SPDX-License-Identifier: Apache-2.0 + +package oidc_test + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/runatlantis/atlantis/server/logging" + "github.com/runatlantis/atlantis/server/oidc" + . "github.com/runatlantis/atlantis/testing" +) + +func newTestLogger(t *testing.T) logging.SimpleLogging { + t.Helper() + return logging.NewNoopLogger(t) +} + +func findCookie(cookies []*http.Cookie, name string) *http.Cookie { + for _, c := range cookies { + if c.Name == name { + return c + } + } + return nil +} + +func TestSessionManager_SetAndGetSession(t *testing.T) { + logger := newTestLogger(t) + sm := oidc.NewSessionManager([]byte("test-secret-32-characters-long!!"), false, "", logger) + + email := "user@example.com" + + w := httptest.NewRecorder() + Ok(t, sm.SetSession(w, email)) + + sessionCookie := findCookie(w.Result().Cookies(), "atlantis_oidc") + Assert(t, sessionCookie != nil, "expected atlantis_oidc cookie to be set") + Assert(t, sessionCookie.HttpOnly, "expected cookie to be HttpOnly") + Equals(t, http.SameSiteLaxMode, sessionCookie.SameSite) + + r := httptest.NewRequest("GET", "/", nil) + r.AddCookie(sessionCookie) + + got, err := sm.GetSession(r) + Ok(t, err) + Equals(t, email, got) +} + +func TestSessionManager_GetSession_NoCookie(t *testing.T) { + logger := newTestLogger(t) + sm := oidc.NewSessionManager([]byte("test-secret-32-characters-long!!"), false, "", logger) + + r := httptest.NewRequest("GET", "/", nil) + _, err := sm.GetSession(r) + Assert(t, err != nil, "expected error when no cookie present") +} + +func TestSessionManager_GetSession_TamperedCookie(t *testing.T) { + logger := newTestLogger(t) + sm := oidc.NewSessionManager([]byte("test-secret-32-characters-long!!"), false, "", logger) + + r := httptest.NewRequest("GET", "/", nil) + r.AddCookie(&http.Cookie{ + Name: "atlantis_oidc", + Value: "tampered-value", + }) + + _, err := sm.GetSession(r) + Assert(t, err != nil, "expected error for tampered cookie") +} + +func TestSessionManager_GetSession_WrongSecret(t *testing.T) { + logger := newTestLogger(t) + sm1 := oidc.NewSessionManager([]byte("secret-one-32-characters-long!!"), false, "", logger) + sm2 := oidc.NewSessionManager([]byte("secret-two-32-characters-long!!"), false, "", logger) + + w := httptest.NewRecorder() + Ok(t, sm1.SetSession(w, "user@example.com")) + + r := httptest.NewRequest("GET", "/", nil) + for _, c := range w.Result().Cookies() { + r.AddCookie(c) + } + + _, err := sm2.GetSession(r) + Assert(t, err != nil, "expected error when verifying with wrong secret") +} + +func TestSessionManager_ClearSession(t *testing.T) { + logger := newTestLogger(t) + sm := oidc.NewSessionManager([]byte("test-secret-32-characters-long!!"), false, "", logger) + + w := httptest.NewRecorder() + sm.ClearSession(w) + + c := findCookie(w.Result().Cookies(), "atlantis_oidc") + Assert(t, c != nil, "expected atlantis_oidc cookie in response") + Equals(t, -1, c.MaxAge) +} + +func TestSessionManager_CreateAndVerifyState(t *testing.T) { + logger := newTestLogger(t) + sm := oidc.NewSessionManager([]byte("test-secret-32-characters-long!!"), false, "", logger) + + w := httptest.NewRecorder() + state, err := sm.CreateState(w) + Ok(t, err) + Assert(t, state != "", "expected non-empty state") + + r := httptest.NewRequest("GET", "/auth/callback?state="+state, nil) + for _, c := range w.Result().Cookies() { + r.AddCookie(c) + } + + Ok(t, sm.VerifyState(r, state)) +} + +func TestSessionManager_VerifyState_Mismatch(t *testing.T) { + logger := newTestLogger(t) + sm := oidc.NewSessionManager([]byte("test-secret-32-characters-long!!"), false, "", logger) + + w := httptest.NewRecorder() + _, err := sm.CreateState(w) + Ok(t, err) + + r := httptest.NewRequest("GET", "/auth/callback", nil) + for _, c := range w.Result().Cookies() { + r.AddCookie(c) + } + + err = sm.VerifyState(r, "wrong-state") + Assert(t, err != nil, "expected error for mismatched state") +} + +func TestSessionManager_AutoGeneratedSecret(t *testing.T) { + logger := newTestLogger(t) + sm := oidc.NewSessionManager(nil, false, "", logger) + + w := httptest.NewRecorder() + Ok(t, sm.SetSession(w, "auto@example.com")) + + r := httptest.NewRequest("GET", "/", nil) + for _, c := range w.Result().Cookies() { + r.AddCookie(c) + } + + got, err := sm.GetSession(r) + Ok(t, err) + Equals(t, "auto@example.com", got) +} + +func TestSessionManager_CookieValueDoesNotContainEmail(t *testing.T) { + logger := newTestLogger(t) + sm := oidc.NewSessionManager([]byte("test-secret-32-characters-long!!"), false, "", logger) + + email := "sensitive-user@example.com" + w := httptest.NewRecorder() + Ok(t, sm.SetSession(w, email)) + + c := findCookie(w.Result().Cookies(), "atlantis_oidc") + Assert(t, c != nil, "expected atlantis_oidc cookie") + Assert(t, !strings.Contains(c.Value, email), "cookie value contains email in plaintext") + if decoded, err := base64.RawURLEncoding.DecodeString(c.Value); err == nil { + Assert(t, !strings.Contains(string(decoded), email), "decoded cookie value contains email in plaintext") + } +} + +func TestSessionManager_SecureCookie(t *testing.T) { + logger := newTestLogger(t) + sm := oidc.NewSessionManager([]byte("test-secret-32-characters-long!!"), true, "", logger) + + w := httptest.NewRecorder() + _ = sm.SetSession(w, "secure@example.com") + + c := findCookie(w.Result().Cookies(), "atlantis_oidc") + Assert(t, c != nil, "expected atlantis_oidc cookie") + Assert(t, c.Secure, "expected Secure flag on cookie when secure=true") +} diff --git a/server/server.go b/server/server.go index 2e409e7262..3d3c2c2e6c 100644 --- a/server/server.go +++ b/server/server.go @@ -74,6 +74,7 @@ import ( "github.com/runatlantis/atlantis/server/events/vcs/gitlab" "github.com/runatlantis/atlantis/server/events/webhooks" "github.com/runatlantis/atlantis/server/logging" + atlantisoidc "github.com/runatlantis/atlantis/server/oidc" ) const ( @@ -129,6 +130,8 @@ type Server struct { WebAuthentication bool WebUsername string WebPassword string + WebOIDCAuth atlantisoidc.OIDCAuthProvider + OIDCController *controllers.OIDCController ProjectCmdOutputHandler jobs.ProjectCommandOutputHandler ScheduledExecutorService *scheduled.ExecutorService DisableGlobalApplyLock bool @@ -1045,11 +1048,21 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { WebAuthentication: userConfig.WebBasicAuth, WebUsername: userConfig.WebUsername, WebPassword: userConfig.WebPassword, + WebOIDCAuth: atlantisoidc.OIDCAuthProvider(userConfig.WebOIDCAuth), ScheduledExecutorService: scheduledExecutorService, EnableProfilingAPI: userConfig.EnableProfilingAPI, database: database, } + // Initialize OIDC authentication if configured. + if userConfig.WebOIDCAuth != "" { + oidcController, oidcErr := initOIDC(userConfig, parsedURL, logger) + if oidcErr != nil { + return nil, fmt.Errorf("initializing OIDC: %w", oidcErr) + } + server.OIDCController = oidcController + } + validate := validator.New(validator.WithRequiredStructEnabled()) err = validate.Struct(server) @@ -1060,6 +1073,56 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { } } +// initOIDC creates and returns an OIDCController from the user configuration. +func initOIDC(userConfig UserConfig, atlantisURL *url.URL, logger logging.SimpleLogging) (*controllers.OIDCController, error) { + scopes := strings.Split(userConfig.WebOIDCScopes, ",") + + // Derive redirect URL from Atlantis URL. + redirectURL := strings.TrimRight(atlantisURL.String(), "/") + "/auth/callback" + + var azureWI *atlantisoidc.AzureWorkloadIdentity + if userConfig.WebOIDCAzureUseWorkloadIdentity { + var wiErr error + azureWI, wiErr = atlantisoidc.NewAzureWorkloadIdentity(logger) + if wiErr != nil { + return nil, fmt.Errorf("Azure workload identity: %w", wiErr) + } + logger.Info("Azure workload identity enabled for OIDC") + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + provider, err := atlantisoidc.NewProvider( + ctx, + userConfig.WebOIDCIssuerURL, + userConfig.WebOIDCClientID, + userConfig.WebOIDCClientSecret, + redirectURL, + scopes, + azureWI, + logger, + ) + if err != nil { + return nil, err + } + + secureCookies := atlantisURL.Scheme == "https" + sessionMgr := atlantisoidc.NewSessionManager( + []byte(userConfig.WebOIDCCookieSecret), + secureCookies, + atlantisURL.Path, + logger, + ) + + return &controllers.OIDCController{ + Provider: provider, + SessionManager: sessionMgr, + Logger: logger, + BasePath: atlantisURL.Path, + }, nil +} + // Start creates the routes and starts serving traffic. func (s *Server) Start() error { s.Router.HandleFunc("/", s.Index).Methods("GET").MatcherFunc(func(r *http.Request, rm *mux.RouteMatch) bool { @@ -1080,6 +1143,13 @@ func (s *Server) Start() error { s.Router.HandleFunc("/jobs/{job-id}", s.JobsController.GetProjectJobs).Methods("GET").Name(ProjectJobsViewRouteName) s.Router.HandleFunc("/jobs/{job-id}/ws", s.JobsController.GetProjectJobsWS).Methods("GET") + // OIDC authentication routes. + if s.OIDCController != nil { + s.Router.HandleFunc("/auth/login", s.OIDCController.Login).Methods("GET") + s.Router.HandleFunc("/auth/callback", s.OIDCController.Callback).Methods("GET") + s.Router.HandleFunc("/auth/logout", s.OIDCController.Logout).Methods("GET") + } + r, ok := s.StatsReporter.(prometheus.Reporter) if ok { s.Router.Handle(s.CommandRunner.GlobalCfg.Metrics.Prometheus.Endpoint, r.HTTPHandler()) @@ -1199,7 +1269,7 @@ func (s *Server) closeDatabase(timeout time.Duration) error { } // Index is the / route. -func (s *Server) Index(w http.ResponseWriter, _ *http.Request) { +func (s *Server) Index(w http.ResponseWriter, r *http.Request) { locks, err := s.Locker.List() if err != nil { w.WriteHeader(http.StatusServiceUnavailable) @@ -1241,12 +1311,23 @@ func (s *Server) Index(w http.ResponseWriter, _ *http.Request) { //Sort by date - newest to oldest. sort.SliceStable(lockResults, func(i, j int) bool { return lockResults[i].Time.After(lockResults[j].Time) }) + // Extract user email from OIDC session if available. + oidcEnabled := s.OIDCController != nil + var userEmail string + if oidcEnabled && s.OIDCController.SessionManager != nil { + if email, sessionErr := s.OIDCController.SessionManager.GetSession(r); sessionErr == nil { + userEmail = email + } + } + err = s.IndexTemplate.Execute(w, web_templates.IndexData{ Locks: lockResults, PullToJobMapping: preparePullToJobMappings(s), ApplyLock: applyLockData, AtlantisVersion: s.AtlantisVersion, CleanedBasePath: s.AtlantisURL.Path, + OIDCEnabled: oidcEnabled, + UserEmail: userEmail, }) if err != nil { s.Logger.Err(err.Error()) diff --git a/server/user_config.go b/server/user_config.go index fcf25a535e..8f6a09d21e 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -131,12 +131,19 @@ type UserConfig struct { DefaultTFVersion string `mapstructure:"default-tf-version"` Webhooks []WebhookConfig `mapstructure:"webhooks" flag:"false"` WebhookHttpHeaders string `mapstructure:"webhook-http-headers"` - WebBasicAuth bool `mapstructure:"web-basic-auth"` - WebUsername string `mapstructure:"web-username"` - WebPassword string `mapstructure:"web-password"` - WriteGitCreds bool `mapstructure:"write-git-creds"` - WebsocketCheckOrigin bool `mapstructure:"websocket-check-origin"` - UseTFPluginCache bool `mapstructure:"use-tf-plugin-cache"` + WebBasicAuth bool `mapstructure:"web-basic-auth"` + WebUsername string `mapstructure:"web-username"` + WebPassword string `mapstructure:"web-password"` + WebOIDCAuth string `mapstructure:"web-oidc-auth"` + WebOIDCIssuerURL string `mapstructure:"web-oidc-issuer-url"` + WebOIDCClientID string `mapstructure:"web-oidc-client-id"` + WebOIDCClientSecret string `mapstructure:"web-oidc-client-secret"` + WebOIDCScopes string `mapstructure:"web-oidc-scopes"` + WebOIDCCookieSecret string `mapstructure:"web-oidc-cookie-secret"` + WebOIDCAzureUseWorkloadIdentity bool `mapstructure:"web-oidc-azure-use-workload-identity"` + WriteGitCreds bool `mapstructure:"write-git-creds"` + WebsocketCheckOrigin bool `mapstructure:"websocket-check-origin"` + UseTFPluginCache bool `mapstructure:"use-tf-plugin-cache"` } // ToAllowCommandNames parse AllowCommands into a slice of CommandName