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
81 changes: 81 additions & 0 deletions docs/web/web-sso-microsoft.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
title: Flux Web UI SSO with Microsoft Entra
description: Flux Status Web UI SSO guide using Microsoft Entra as the identity provider.
---

# Flux Web UI with Microsoft Entra SSO

There are several ways to configure Single Sign-On (SSO) for the Flux Web UI using
Microsoft Entra (formerly Azure Active Directory) as the identity provider. This guide
covers two common approaches: direct integration with Microsoft Entra OIDC, and using
Dex as an intermediate OIDC provider.

## Direct Integration with Microsoft Entra OIDC

When deploying Flux Operator through the Helm chart, you can configure the Flux Web UI
to use Microsoft Entra OIDC with a configuration similar to the following:

```yaml
config:
baseURL: https://flux-status.example.com
authentication:
type: OAuth2
oauth2:
provider: OIDC
clientID: 2d01bd48-7914-4b50-9667-068be6afd2f2 # App registration's "Application (client) ID" value.
clientSecret: "O.y8Q~MGIm9B.ahUlOx376EP7l5mu9xgIet6hdBD" # App registration's "Client secret" value.
issuerURL: https://login.microsoftonline.com/4bd94393-a3a0-ab26-4c05-bfc69377f6c0/v2.0 # URL containing tenant ID.
scopes: [openid, profile, email, offline_access] # Scopes supported by Microsoft Entra OIDC.
```

In order to receive groups from Microsoft Entra, you need to configure the
App registration to include the `groups` claim in the ID token by following
[these](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims#configure-groups-optional-claims)
docs.

!!! note "Limitations"

This approach has a limitation for Azure free-tier plans, where the UUIDs
of the groups are returned instead of their names. For receiving group
names, you need to choose the "Groups assigned to the application" type
and have a paid plan. Alternatively, you can use Dex as an intermediate
OIDC provider to get group names, as described in the next section.

## Using Dex as an intermediate OIDC provider

To receive groups from Microsoft Entra and get more advanced features, you can
use Dex as an intermediate OIDC provider between the Flux Web UI and Microsoft
Entra. For this, please refer to the guide [Flux Web UI SSO with Dex](./web-sso-dex.md)
and the docs for the [Dex Microsoft connector](https://dexidp.io/docs/connectors/microsoft/).

### Restricting the groups added by Dex to the ID token

The Microsoft Entra connector in Dex supports filtering the groups
added to the ID token using various configuration options. Example:

```yaml
connectors:
- type: microsoft
id: microsoft
name: Microsoft
config:
clientID: 2d01bd48-7914-4b50-9667-068be6afd2f2
clientSecret: "O.y8Q~MGIm9B.ahUlOx376EP7l5mu9xgIet6hdBD"
redirectURI: https://dex.example.com/callback
onlySecurityGroups: true # only groups created with the "Security" type
useGroupsAsWhitelist: true # only include groups listed in "groups" below
groups: # user needs at least one of these groups to be authenticated
- dev-team
- platform-team
```

This can be useful to shorten the list of groups added to the ID token,
and therefore reducing the overall size of the token. The Web UI supports
a maximum of 35 KiB for the entire HTTP cookie used to store the ID token.

## Further Reading

- [Flux Web UI SSO with Dex](./web-sso-dex.md)
- [Flux Web UI Ingress Configuration](./web-ingress.md)
- [Dex Microsoft connector](https://dexidp.io/docs/connectors/microsoft/)
- [Add the groups claim for direct integration](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims#configure-groups-optional-claims)
9 changes: 7 additions & 2 deletions internal/web/auth/cookies.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,17 @@ type authStorage struct {
// setAuthStorage sets the authStorage in the response cookies.
// It first clears any existing auth storage cookies to avoid duplicates.
// For large tokens, the value is automatically split across multiple cookies.
func setAuthStorage(conf *config.ConfigSpec, w http.ResponseWriter, storage authStorage) {
// It only returns an error if the storage data is too large to fit
// within the allowed number of cookie chunks.
func setAuthStorage(conf *config.ConfigSpec, w http.ResponseWriter, storage authStorage) error {
// Clear any existing auth storage cookies (including chunks).
clearChunkedCookiesFromResponse(w, cookieNameAuthStorage)

b, _ := json.Marshal(storage)
cValue := base64.RawURLEncoding.EncodeToString(b)

// Set chunked cookies (automatically handles single vs multiple cookies).
_ = setChunkedCookies(w, cookieNameAuthStorage, cookiePathAuthStorage, cValue,
return setChunkedCookies(w, cookieNameAuthStorage, cookiePathAuthStorage, cValue,
conf.Authentication.SessionDuration.Duration, !conf.Insecure)
}

Expand Down Expand Up @@ -160,6 +162,7 @@ func clearCookieFromResponse(w http.ResponseWriter, name string) {

// splitIntoChunks splits a string value into chunks of the specified maximum size.
// Returns an error if the value would require more than the maximum allowed chunks.
// It only errors if the value is too large to fit within the allowed number of chunks.
func splitIntoChunks(value string, maxChunkSize, maxChunks int) ([]string, error) {
if len(value) <= maxChunkSize {
return []string{value}, nil
Expand Down Expand Up @@ -223,6 +226,8 @@ func getChunkedCookieValue(r *http.Request, baseName string) (string, error) {
// setChunkedCookies sets a value across multiple cookies if needed.
// For values smaller than maxChunkSize, sets a single cookie.
// For larger values, splits across multiple cookies.
// It only returns an error if the value is too large to fit within
// the allowed number of chunks.
func setChunkedCookies(w http.ResponseWriter, baseName, path, value string,
maxAge time.Duration, secure bool) error {

Expand Down
29 changes: 26 additions & 3 deletions internal/web/auth/cookies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ func TestAuthStorage(t *testing.T) {
AccessToken: "access-token-123",
RefreshToken: "refresh-token-456",
}
setAuthStorage(conf, rec, storage)
g.Expect(setAuthStorage(conf, rec, storage)).To(Succeed())

cookies := rec.Result().Cookies()
g.Expect(cookies).To(HaveLen(1))
Expand Down Expand Up @@ -443,7 +443,7 @@ func TestAuthStorageChunking(t *testing.T) {
AccessToken: "short-token",
RefreshToken: "short-refresh",
}
setAuthStorage(conf, rec, storage)
g.Expect(setAuthStorage(conf, rec, storage)).To(Succeed())

cookies := rec.Result().Cookies()
// Count auth-storage cookies.
Expand Down Expand Up @@ -475,7 +475,7 @@ func TestAuthStorageChunking(t *testing.T) {
AccessToken: largeToken,
RefreshToken: "refresh-token",
}
setAuthStorage(conf, rec, storage)
g.Expect(setAuthStorage(conf, rec, storage)).To(Succeed())

cookies := rec.Result().Cookies()

Expand All @@ -502,6 +502,29 @@ func TestAuthStorageChunking(t *testing.T) {
g.Expect(result.RefreshToken).To(Equal("refresh-token"))
})

t.Run("too large token errors out", func(t *testing.T) {
g := NewWithT(t)

conf := &config.ConfigSpec{
Insecure: false,
Authentication: &config.AuthenticationSpec{
SessionDuration: &metav1.Duration{Duration: 24 * time.Hour},
},
}

// Create a large token that exceeds chunk size.
largeToken := strings.Repeat("12345678901234567890", 10000)

rec := httptest.NewRecorder()
storage := authStorage{
AccessToken: largeToken,
RefreshToken: "refresh-token",
}
err := setAuthStorage(conf, rec, storage)
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring("value too large: requires 75 chunks"))
})

t.Run("backward compatibility with single cookie", func(t *testing.T) {
g := NewWithT(t)

Expand Down
3 changes: 2 additions & 1 deletion internal/web/auth/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package auth

import (
"errors"
"strings"
)

var (
Expand All @@ -16,7 +17,7 @@ var (
// avoids exposing internal error details that could aid attackers.
func sanitizeErrorMessage(err error) string {
switch {
case errors.Is(err, errInternalError), errors.Is(err, errInvalidOAuth2Scopes):
case errors.Is(err, errInternalError), err != nil && strings.Contains(err.Error(), errInvalidOAuth2Scopes):
return "An internal error occurred. Please try again. Contact your administrator if the problem persists."
default:
return "Authentication failed. Please try again."
Expand Down
4 changes: 2 additions & 2 deletions internal/web/auth/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ func TestSanitizeErrorMessage(t *testing.T) {
},
{
name: "invalid scopes error returns generic message",
err: errInvalidOAuth2Scopes,
err: errors.New(errInvalidOAuth2Scopes),
expected: "An internal error occurred. Please try again. Contact your administrator if the problem persists.",
},
{
name: "wrapped invalid scopes error returns generic message",
err: fmt.Errorf("scope issue: %w", errInvalidOAuth2Scopes),
err: fmt.Errorf("scope issue: %s", errInvalidOAuth2Scopes),
expected: "An internal error occurred. Please try again. Contact your administrator if the problem persists.",
},
{
Expand Down
37 changes: 23 additions & 14 deletions internal/web/auth/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,16 @@ const (
oauth2LoginStateAESKeySize = 32 // 32 bytes = AES-256
)

var (
errInvalidOAuth2Scopes = errors.New(
"the OAuth2 authorization server does not support the requested scopes. " +
"if you are using the default scopes, please consider setting custom " +
"scopes in the OAuth2 configuration that are supported by your provider, " +
"see docs: https://fluxoperator.dev/docs/web-ui/web-config-api/")
const (
errInvalidOAuth2Scopes = "The OAuth2 provider does not support the requested scopes. " +
"If you are using the default scopes, please consider setting custom " +
"scopes in the OAuth2 configuration that are supported by your provider: " +
"https://fluxoperator.dev/docs/web-ui/web-config-api/#oidc-provider"

logCookieTooLarge = "The credentials issued by the OAuth2 provider are too large to fit in HTTP cookies. " +
"If your provider is Dex with the Microsoft connector, please consider reducing " +
"the number of groups returned by Dex: " +
"https://fluxoperator.dev/docs/web-ui/sso-microsoft#restricting-the-groups-added-by-dex-to-the-id-token"
)

// oauth2Authenticator implements OAuth2 authentication.
Expand Down Expand Up @@ -150,10 +154,11 @@ func (o *oauth2Authenticator) serveCallback(w http.ResponseWriter, r *http.Reque
errURI := r.URL.Query().Get(errorURIKey)
if errCode != "" || errDesc != "" || errURI != "" {
const logMsg = "OAuth2 callback error"
switch errCode {
const invalidScope = "invalid_scope"
switch {
// Special case: it's common needing to configure the correct scopes.
case "invalid_scope":
callbackErr = errInvalidOAuth2Scopes
case strings.Contains(errCode, invalidScope), strings.Contains(errDesc, invalidScope):
callbackErr = fmt.Errorf("%s", errInvalidOAuth2Scopes)
log.FromContext(r.Context()).Error(callbackErr, logMsg)
default:
callbackErr = errInternalError
Expand Down Expand Up @@ -387,13 +392,17 @@ func (o *oauth2Authenticator) verifyTokenAndSetStorageOrLogError(
token *oauth2.Token, nonce ...string) (*user.Details, error) {

details, as, err := v.verifyToken(ctx, token, nonce...)
if err == nil {
setAuthStorage(o.conf, w, *as)
return details, nil
if err != nil {
log.FromContext(ctx).Error(err, "failed to verify token")
return nil, errUserError
}

if err := setAuthStorage(o.conf, w, *as); err != nil {
log.FromContext(ctx).Error(err, logCookieTooLarge)
return nil, errInternalError
}

log.FromContext(ctx).Error(err, "failed to verify token")
return nil, errUserError
return details, nil
}

// verifyAccessTokenOrDeleteStorageAndLogError verifies the access token and
Expand Down
2 changes: 2 additions & 0 deletions internal/web/auth/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"sigs.k8s.io/controller-runtime/pkg/log"

"github.com/controlplaneio-fluxcd/flux-operator/internal/web/config"
"github.com/controlplaneio-fluxcd/flux-operator/internal/web/user"
Expand Down Expand Up @@ -135,6 +136,7 @@ func (o *oidcVerifier) verifyAccessToken(ctx context.Context,
if err != nil {
return nil, fmt.Errorf("failed to process claims from OIDC ID token: %w", err)
}
log.FromContext(ctx).V(1).Info("OIDC authentication successful", "claims", claims)

return details, nil
}
Expand Down