Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 72 additions & 4 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = ""
Expand Down Expand Up @@ -201,6 +209,7 @@ const (
DefaultWebBasicAuth = false
DefaultWebUsername = "atlantis"
DefaultWebPassword = "atlantis"
DefaultWebOIDCScopes = "openid,profile,email"
)

var stringFlags = map[string]stringFlag{
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}

Expand Down
15 changes: 11 additions & 4 deletions cmd/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
70 changes: 70 additions & 0 deletions runatlantis.io/docs/server-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` <Badge text="v0.1.0+" type="info"/>

```bash
Expand Down
112 changes: 112 additions & 0 deletions server/controllers/oidc_controller.go
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
rbstp marked this conversation as resolved.
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)
}
Loading
Loading