diff --git a/docs/web/web-sso-microsoft.md b/docs/web/web-sso-microsoft.md new file mode 100644 index 00000000..c3f47a77 --- /dev/null +++ b/docs/web/web-sso-microsoft.md @@ -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) diff --git a/internal/web/auth/cookies.go b/internal/web/auth/cookies.go index b82faba9..3d2bdb88 100644 --- a/internal/web/auth/cookies.go +++ b/internal/web/auth/cookies.go @@ -108,7 +108,9 @@ 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) @@ -116,7 +118,7 @@ func setAuthStorage(conf *config.ConfigSpec, w http.ResponseWriter, storage auth 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) } @@ -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 @@ -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 { diff --git a/internal/web/auth/cookies_test.go b/internal/web/auth/cookies_test.go index bdde0501..d7bb3093 100644 --- a/internal/web/auth/cookies_test.go +++ b/internal/web/auth/cookies_test.go @@ -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)) @@ -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. @@ -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() @@ -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) diff --git a/internal/web/auth/errors.go b/internal/web/auth/errors.go index e0254fbf..c31d7bf6 100644 --- a/internal/web/auth/errors.go +++ b/internal/web/auth/errors.go @@ -5,6 +5,7 @@ package auth import ( "errors" + "strings" ) var ( @@ -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." diff --git a/internal/web/auth/errors_test.go b/internal/web/auth/errors_test.go index 7feb9e7c..21215383 100644 --- a/internal/web/auth/errors_test.go +++ b/internal/web/auth/errors_test.go @@ -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.", }, { diff --git a/internal/web/auth/oauth2.go b/internal/web/auth/oauth2.go index a293bf2e..0b04f41b 100644 --- a/internal/web/auth/oauth2.go +++ b/internal/web/auth/oauth2.go @@ -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. @@ -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 @@ -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 diff --git a/internal/web/auth/oidc.go b/internal/web/auth/oidc.go index 94d2c5e8..44f77828 100644 --- a/internal/web/auth/oidc.go +++ b/internal/web/auth/oidc.go @@ -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" @@ -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 }