Skip to content
Merged
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
30 changes: 16 additions & 14 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,22 @@ This document describes all environment variables and configuration options avai

## Backend configuration

| Variable | Description | Default | Values |
| --------------------------------------- | ------------------------------------ | ------------------------ | -------------------------------------------- |
| `BASE_UI_URL` | Base URL for UI application | `http://localhost:9000` | `https://ui.flightctl.example.com` |
| `FLIGHTCTL_SERVER` | Flight Control API server URL | `https://localhost:3443` | `https://api.flightctl.example.com` |
| `FLIGHTCTL_SERVER_INSECURE_SKIP_VERIFY` | Skip backend server TLS verification | `false` | `true`, `false` |
| `FLIGHTCTL_CLI_ARTIFACTS_SERVER` | CLI artifacts server URL | `http://localhost:8090` | `https://cli.flightctl.example.com` |
| `FLIGHTCTL_ALERTMANAGER_PROXY` | AlertManager proxy server URL | `https://localhost:8443` | `https://alerts.flightctl.example.com` |
| `FLIGHTCTL_IMAGEBUILDER_SERVER` | ImageBuilder API server URL | `https://localhost:8445` | `https://imagebuilder.flightctl.example.com` |
| `AUTH_INSECURE_SKIP_VERIFY` | Skip auth server TLS verification | `false` | `true`, `false` |
| `TLS_CERT` | Path to TLS certificate | _(empty)_ | `/path/to/server.crt` |
| `TLS_KEY` | Path to TLS private key | _(empty)_ | `/path/to/server.key` |
| `API_PORT` | UI proxy server port | `3001` | `8080`, `3000`, etc. |
| `IS_OCP_PLUGIN` | Run as OpenShift Console plugin | `false` | `true`, `false` |
| `IS_RHEM` | Red Hat Enterprise Mode | _(empty)_ | `true`, `false` |
| Variable | Description | Default | Values |
| --------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------ | -------------------------------------------- |
| `BASE_UI_URL` | Base URL for UI application | `http://localhost:9000` | `https://ui.flightctl.example.com` |
| `FLIGHTCTL_SERVER` | Flight Control API server URL | `https://localhost:3443` | `https://api.flightctl.example.com` |
| `FLIGHTCTL_SERVER_INSECURE_SKIP_VERIFY` | Skip backend server TLS verification | `false` | `true`, `false` |
| `FLIGHTCTL_CLI_ARTIFACTS_SERVER` | CLI artifacts server URL | `http://localhost:8090` | `https://cli.flightctl.example.com` |
| `FLIGHTCTL_ALERTMANAGER_PROXY` | AlertManager proxy server URL | `https://localhost:8443` | `https://alerts.flightctl.example.com` |
| `FLIGHTCTL_IMAGEBUILDER_SERVER` | ImageBuilder API server URL | `https://localhost:8445` | `https://imagebuilder.flightctl.example.com` |
| `AUTH_INSECURE_SKIP_VERIFY` | Skip auth server TLS verification | `false` | `true`, `false` |
| `TRUST_X_FORWARDED_HEADERS` | Trust `X-Forwarded-Proto`/`X-Forwarded-Host` for request origin checks (enable behind trusted LB) | `false` | `true`, `false` |
| `TRUSTED_PROXY_CIDRS` | Comma-separated trusted proxy CIDRs for forwarded-header trust; when set but invalid, trust fails closed | _(empty)_ | `10.0.0.0/8,192.168.0.0/16` |
| `TLS_CERT` | Path to TLS certificate | _(empty)_ | `/path/to/server.crt` |
| `TLS_KEY` | Path to TLS private key | _(empty)_ | `/path/to/server.key` |
| `API_PORT` | UI proxy server port | `3001` | `8080`, `3000`, etc. |
| `IS_OCP_PLUGIN` | Run as OpenShift Console plugin | `false` | `true`, `false` |
| `IS_RHEM` | Red Hat Enterprise Mode | _(empty)_ | `true`, `false` |

## Configuration examples

Expand Down
3 changes: 2 additions & 1 deletion apps/standalone/src/app/components/Login/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import LoginPageLayout from './LoginPageLayout';
import { loginAPI } from '../../utils/apiCalls';

const redirectToProviderLogin = async (provider: AuthProvider) => {
const response = await fetch(`${loginAPI}?provider=${provider.metadata.name}`, {
const redirectBase = encodeURIComponent(window.location.origin);
const response = await fetch(`${loginAPI}?provider=${provider.metadata.name}&redirect_base=${redirectBase}`, {
credentials: 'include',
});

Expand Down
5 changes: 4 additions & 1 deletion apps/standalone/src/app/utils/apiCalls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ const getFullApiUrl = (path: string): { api: 'flightctl' | 'imagebuilder' | 'ale
};

export const logout = async () => {
const response = await fetch(`${uiProxyAPI}/logout`, { credentials: 'include' });
const redirectBase = encodeURIComponent(window.location.origin);
const response = await fetch(`${uiProxyAPI}/logout?redirect_base=${redirectBase}`, {
credentials: 'include',
});
const { url } = (await response.json()) as { url: string };
url ? (window.location.href = url) : window.location.reload();
};
Expand Down
25 changes: 9 additions & 16 deletions proxy/auth/aap.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,12 @@ import (
"net/url"

"github.com/flightctl/flightctl-ui/bridge"
"github.com/flightctl/flightctl-ui/config"
"github.com/flightctl/flightctl-ui/log"
"github.com/flightctl/flightctl/api/v1beta1"
"github.com/openshift/osincli"
)

type AAPAuthHandler struct {
client *osincli.Client
internalClient *osincli.Client
tlsConfig *tls.Config
authURL string
tokenURL string
Expand Down Expand Up @@ -72,15 +69,7 @@ func getAAPAuthHandler(provider *v1beta1.AuthProvider, aapSpec *v1beta1.AapProvi
return nil, err
}

// Use the authorization and token URLs directly from the spec
client, err := getClient(aapSpec.AuthorizationUrl, aapSpec.TokenUrl, tlsConfig, aapSpec.ClientId)
if err != nil {
return nil, err
}

handler := &AAPAuthHandler{
client: client,
internalClient: client,
tlsConfig: tlsConfig,
authURL: aapSpec.AuthorizationUrl,
tokenURL: aapSpec.TokenUrl,
Expand All @@ -92,7 +81,7 @@ func getAAPAuthHandler(provider *v1beta1.AuthProvider, aapSpec *v1beta1.AapProvi
return handler, nil
}

func getClient(authorizationUrl, tokenUrl string, tlsConfig *tls.Config, clientId string) (*osincli.Client, error) {
func getAAPClient(authorizationUrl, tokenUrl string, tlsConfig *tls.Config, clientId string, redirectURI string) (*osincli.Client, error) {
// Use provided clientId, require it to be non-empty
if clientId == "" {
return nil, fmt.Errorf("clientId is required for AAP provider")
Expand All @@ -102,7 +91,7 @@ func getClient(authorizationUrl, tokenUrl string, tlsConfig *tls.Config, clientI
ClientId: clientId,
AuthorizeUrl: authorizationUrl,
TokenUrl: tokenUrl,
RedirectUrl: config.BaseUiUrl + "/callback",
RedirectUrl: redirectURI,
ErrorsInStatusCode: true,
SendClientSecretInParams: true,
Scope: "read",
Expand All @@ -122,7 +111,7 @@ func getClient(authorizationUrl, tokenUrl string, tlsConfig *tls.Config, clientI
return client, nil
}

func (a *AAPAuthHandler) Logout(token string) (string, error) {
func (a *AAPAuthHandler) Logout(token string, _ string) (string, error) {
data := url.Values{}
data.Set("client_id", a.clientId)
data.Set("token", token)
Expand All @@ -148,6 +137,10 @@ func (a *AAPAuthHandler) Logout(token string) (string, error) {
return "", nil
}

func (a *AAPAuthHandler) GetLoginRedirectURL(state string, codeChallenge string) string {
return loginRedirect(a.client, state, codeChallenge)
func (a *AAPAuthHandler) GetLoginRedirectURL(state string, codeChallenge string, redirectURI string) (string, error) {
client, err := getAAPClient(a.authURL, a.tokenURL, a.tlsConfig, a.clientId, redirectURI)
if err != nil {
return "", fmt.Errorf("failed to create AAP OAuth client: %w", err)
}
return loginRedirect(client, state, codeChallenge), nil
}
69 changes: 56 additions & 13 deletions proxy/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"net/http"
"sort"
"strings"

"github.com/flightctl/flightctl-ui/common"
"github.com/flightctl/flightctl-ui/config"
Expand Down Expand Up @@ -258,7 +259,7 @@ func handleTokenProviderLogin(w http.ResponseWriter, r *http.Request, tokenProvi
}

tokenData.Provider = providerName
respondWithToken(w, tokenData, expires)
respondWithToken(w, r, tokenData, expires)
return true
}

Expand All @@ -282,7 +283,12 @@ func (a AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
// Check if this is a token-based auth provider (k8s) - token providers don't use PKCE flow
if _, ok := provider.(*TokenAuthProvider); ok {
// Token providers don't need a redirect URL - they handle login via POST with token
loginUrl := provider.GetLoginRedirectURL("", "")
loginUrl, err := provider.GetLoginRedirectURL("", "", "")
if err != nil {
log.GetLogger().WithError(err).Warnf("Failed to initialize authentication provider %s login flow", providerName)
respondWithError(w, http.StatusInternalServerError, "Failed to initialize authentication flow")
return
}
response, err := json.Marshal(RedirectResponse{Url: loginUrl})
if err != nil {
log.GetLogger().WithError(err).Warn("Failed to marshal response")
Expand Down Expand Up @@ -311,13 +317,30 @@ func (a AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
}

// Store code verifier in cookie for later use during token exchange
setPKCEVerifierCookie(w, providerName, codeVerifier)
setPKCEVerifierCookie(w, r, providerName, codeVerifier)

// Store state → providerName mapping in secure cookie for validation on callback
setStateCookie(w, state, providerName)
setStateCookie(w, r, state, providerName)

redirectBase := r.URL.Query().Get("redirect_base")
redirectURI, err := ResolveOAuthRedirectURI(r, redirectBase)
if err != nil {
respondWithError(w, http.StatusBadRequest, err.Error())
return
}
setOAuthRedirectURICookie(w, r, state, redirectURI)

// Generate login URL with random state and PKCE challenge
loginUrl := provider.GetLoginRedirectURL(state, codeChallenge)
loginUrl, err := provider.GetLoginRedirectURL(state, codeChallenge, redirectURI)
if err != nil {
log.GetLogger().WithError(err).Warnf("Failed to initialize authentication provider %s login flow", providerName)
respondWithError(w, http.StatusInternalServerError, "Failed to initialize authentication flow")
return
}
if loginUrl == "" {
respondWithError(w, http.StatusInternalServerError, "Failed to build login URL")
return
}
response, err := json.Marshal(RedirectResponse{Url: loginUrl})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
Expand Down Expand Up @@ -374,7 +397,7 @@ func (a AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
}

// Clear state cookie after validation (success or failure)
clearStateCookie(w, state)
clearStateCookie(w, r, state)

// PKCE is required - retrieve code_verifier from cookie
if loginParams.CodeVerifier == "" {
Expand All @@ -394,15 +417,28 @@ func (a AuthHandler) Login(w http.ResponseWriter, r *http.Request) {

// Clear PKCE verifier cookie after use (success or failure)
// Note: state cookie was already cleared above after validation
clearPKCEVerifierCookie(w, providerName)
clearPKCEVerifierCookie(w, r, providerName)

clientId, err := getClientIdFromProviderConfig(providerConfig)
if err != nil {
respondWithError(w, http.StatusInternalServerError, "Failed to obtain the configuration details for provider")
return
}

redirectURI := config.BaseUiUrl + "/callback"
redirectURI, err := getOAuthRedirectURICookie(r, state)
if err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid OAuth session")
return
}
if redirectURI == "" {
redirectURI, err = ResolveOAuthRedirectURI(r, "")
if err != nil {
respondWithError(w, http.StatusBadRequest, err.Error())
return
}
}
clearOAuthRedirectURICookie(w, r, state)

tokenReq := &v1beta1.TokenRequest{
GrantType: v1beta1.AuthorizationCode,
ClientId: clientId,
Expand All @@ -418,7 +454,7 @@ func (a AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
}

tokenData, expiresIn := convertTokenResponseToTokenData(tokenResp, providerConfig)
respondWithToken(w, tokenData, expiresIn)
respondWithToken(w, r, tokenData, expiresIn)
} else {
respondWithError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
Expand Down Expand Up @@ -475,7 +511,7 @@ func (a AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {

// Convert backend response to TokenData
newTokenData, expiresIn := convertTokenResponseToTokenData(tokenResp, providerConfig)
respondWithToken(w, newTokenData, expiresIn)
respondWithToken(w, r, newTokenData, expiresIn)
}

// handleOAuthErrorResponse handles OAuth2 error responses from token exchange/refresh
Expand All @@ -491,8 +527,8 @@ func handleOAuthErrorResponse(w http.ResponseWriter, tokenResp *v1beta1.TokenRes
}
}

func respondWithToken(w http.ResponseWriter, tokenData TokenData, expires *int64) {
err := setCookie(w, tokenData)
func respondWithToken(w http.ResponseWriter, r *http.Request, tokenData TokenData, expires *int64) {
err := setCookie(w, r, tokenData)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
Expand Down Expand Up @@ -570,6 +606,13 @@ func (a AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {

var redirectUrl string

redirectBase := r.URL.Query().Get("redirect_base")
postLogoutBase, resolveErr := ResolveLogoutRedirectBase(r, redirectBase)
if resolveErr != nil {
log.GetLogger().WithError(resolveErr).Warn("Invalid redirect_base for logout, using BASE_UI_URL")
postLogoutBase = strings.TrimSuffix(config.BaseUiUrl, "/")
}

// If we have a provider, call its Logout method
if tokenData.Provider != "" {
authToken := tokenData.Token
Expand All @@ -583,7 +626,7 @@ func (a AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {

provider, _, err := a.getProviderInstance(tokenData.Provider)
if err == nil {
redirectUrl, err = provider.Logout(authToken)
redirectUrl, err = provider.Logout(authToken, postLogoutBase)
if err != nil {
log.GetLogger().WithError(err).Warn("Failed to logout from provider")
}
Expand Down
29 changes: 15 additions & 14 deletions proxy/auth/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"strings"

"github.com/flightctl/flightctl-ui/common"
"github.com/flightctl/flightctl-ui/config"
"github.com/flightctl/flightctl/api/v1beta1"
"github.com/openshift/osincli"
)
Expand Down Expand Up @@ -41,16 +40,18 @@ type LoginParameters struct {
}

type AuthProvider interface {
Logout(token string) (string, error)
GetLoginRedirectURL(state string, codeChallenge string) string
// postLogoutRedirectBase is the UI base URL (scheme + host + optional path prefix from BASE_UI_URL) for OIDC end-session post_logout_redirect_uri; use ResolveLogoutRedirectBase.
Logout(token string, postLogoutRedirectBase string) (string, error)
// redirectURI is the full OAuth redirect_uri (e.g. https://host/callback). Empty means use BASE_UI_URL (legacy); callers should resolve via ResolveOAuthRedirectURI first.
GetLoginRedirectURL(state string, codeChallenge string, redirectURI string) (string, error)
}

func setCookie(w http.ResponseWriter, value TokenData) error {
func setCookie(w http.ResponseWriter, r *http.Request, value TokenData) error {
cookieVal, err := json.Marshal(value)
if err != nil {
return err
}
secure := config.TlsCertPath != ""
secure := cookieSecureForRequest(r)
encodedValue := b64.StdEncoding.EncodeToString(cookieVal)

// Check cookie value size to ensure it doesn't exceed the maximum
Expand Down Expand Up @@ -191,12 +192,12 @@ func generateCodeChallenge(codeVerifier string) string {
}

// setPKCEVerifierCookie stores the code verifier in a cookie
func setPKCEVerifierCookie(w http.ResponseWriter, providerName string, codeVerifier string) {
func setPKCEVerifierCookie(w http.ResponseWriter, r *http.Request, providerName string, codeVerifier string) {
cookieName := pkceCookiePrefix + providerName
cookie := http.Cookie{
Name: cookieName,
Value: codeVerifier,
Secure: config.TlsCertPath != "",
Secure: cookieSecureForRequest(r),
HttpOnly: true,
SameSite: http.SameSiteLaxMode, // Use Lax instead of Strict to allow cookie on redirect from OAuth provider
Path: "/",
Expand Down Expand Up @@ -224,14 +225,14 @@ func getPKCEVerifierCookie(r *http.Request, providerName string) (string, error)
}

// clearPKCEVerifierCookie removes the PKCE verifier cookie
func clearPKCEVerifierCookie(w http.ResponseWriter, providerName string) {
func clearPKCEVerifierCookie(w http.ResponseWriter, r *http.Request, providerName string) {
cookieName := pkceCookiePrefix + providerName
cookie := http.Cookie{
Name: cookieName,
Value: "",
MaxAge: -1,
Path: "/",
Secure: config.TlsCertPath != "",
Secure: cookieSecureForRequest(r),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
Expand All @@ -258,12 +259,12 @@ func generateState() (string, error) {

// setStateCookie stores the state → providerName mapping in a secure cookie
// This allows us to validate the state on callback and extract the provider name
func setStateCookie(w http.ResponseWriter, state string, providerName string) {
func setStateCookie(w http.ResponseWriter, r *http.Request, state string, providerName string) {
cookieName := stateCookiePrefix + state
cookie := http.Cookie{
Name: cookieName,
Value: providerName,
Secure: config.TlsCertPath != "",
Secure: cookieSecureForRequest(r),
HttpOnly: true,
SameSite: http.SameSiteLaxMode, // Use Lax to allow cookie on redirect from OAuth provider
Path: "/",
Expand Down Expand Up @@ -292,14 +293,14 @@ func getStateCookie(r *http.Request, state string) (string, error) {
}

// clearStateCookie removes the state cookie
func clearStateCookie(w http.ResponseWriter, state string) {
func clearStateCookie(w http.ResponseWriter, r *http.Request, state string) {
cookieName := stateCookiePrefix + state
cookie := http.Cookie{
Name: cookieName,
Value: "",
MaxAge: -1,
Path: "/",
Secure: config.TlsCertPath != "",
Secure: cookieSecureForRequest(r),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
Expand All @@ -313,7 +314,7 @@ func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
Value: "",
MaxAge: -1,
Path: "/",
Secure: config.TlsCertPath != "",
Secure: cookieSecureForRequest(r),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
Expand Down
Loading
Loading