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
13 changes: 10 additions & 3 deletions apps/standalone/src/app/components/Login/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useFetch } from '@flightctl/ui-components/src/hooks/useFetch';
import { useTranslation } from '@flightctl/ui-components/src/hooks/useTranslation';
import { getProviderDisplayName } from '@flightctl/ui-components/src/utils/authProvider';

import { apiProxy } from '../../utils/apiCalls';
import { JUST_LOGGED_OUT_KEY, apiProxy } from '../../utils/apiCalls';
import LoginPageLayout from './LoginPageLayout';

const redirectToProviderLogin = async (provider: AuthProvider) => {
Expand Down Expand Up @@ -38,6 +38,7 @@ const LoginPage = () => {
const [userSelectedProvider, setUserSelectedProvider] = React.useState<AuthProvider | null>(null);
const [defaultProviderName, setDefaultProviderName] = React.useState<string>('');
const [isRedirecting, setIsRedirecting] = React.useState(false);
const [suppressAutoSelect, setSuppressAutoSelect] = React.useState(false);

const handleProviderSelect = async (provider: AuthProvider) => {
// Prevent multiple clicks while redirect is in progress
Expand Down Expand Up @@ -83,7 +84,12 @@ const LoginPage = () => {
if (providers.length > 0) {
setProviders(providers);
setDefaultProviderName(config.defaultProvider || '');
if (providers.length === 1 && providers[0].spec.providerType !== ProviderType.K8s) {
const justLoggedOut = sessionStorage.getItem(JUST_LOGGED_OUT_KEY) === 'true';
if (justLoggedOut) {
sessionStorage.removeItem(JUST_LOGGED_OUT_KEY);
setSuppressAutoSelect(true);
}
if (!justLoggedOut && providers.length === 1 && providers[0].spec.providerType !== ProviderType.K8s) {
setIsRedirecting(true);
try {
await redirectToProviderLogin(providers[0]);
Expand Down Expand Up @@ -125,7 +131,8 @@ const LoginPage = () => {
);
}

const selectedProvider = userSelectedProvider || (providers.length === 1 ? providers[0] : null);
const selectedProvider =
userSelectedProvider || (suppressAutoSelect ? null : providers.length === 1 ? providers[0] : null);
if (selectedProvider?.spec.providerType === ProviderType.K8s) {
return (
<TokenLoginForm
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 @@ -65,6 +65,8 @@ const getFullApiUrl = (path: string): { api: Api; url: string } => {
return { api: apiName, url: `${apiProxy}/flightctl/api/v1/${path}` };
};

export const JUST_LOGGED_OUT_KEY = 'flightctl.justLoggedOut';

export const logout = async () => {
const redirectBase = encodeURIComponent(window.location.origin);
const response = await fetch(`${apiProxy}/logout?redirect_base=${redirectBase}`, {
Expand All @@ -74,7 +76,8 @@ export const logout = async () => {
if (url) {
window.location.href = url;
} else {
window.location.reload();
sessionStorage.setItem(JUST_LOGGED_OUT_KEY, 'true');
window.location.href = '/login';
}
};

Expand Down
138 changes: 0 additions & 138 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions proxy/auth/helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package auth

import (
"fmt"
"net/http"
"net/url"
"os"
"testing"

"github.com/flightctl/flightctl-ui/log"
)

func TestMain(m *testing.M) {
log.InitLogs()
os.Exit(m.Run())
}

// redirectBaseMatchesRequest is a test helper that checks whether url u's origin
// matches the effective request origin. Returns nil when they match, error otherwise.
func redirectBaseMatchesRequest(t *testing.T, r *http.Request, u *url.URL) error {
t.Helper()
ok, err := isSameSchemeAndHost(u.String(), r)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("origin mismatch: redirect base %s://%s does not match request origin", u.Scheme, u.Host)
}
return nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
76 changes: 75 additions & 1 deletion proxy/auth/openshift.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package auth

import (
"crypto/sha256"
"crypto/tls"
b64 "encoding/base64"
"fmt"
"net/http"
"net/url"
"strings"
"time"

"github.com/flightctl/flightctl-ui/bridge"
"github.com/flightctl/flightctl-ui/log"
"github.com/flightctl/flightctl/api/v1beta1"
"github.com/openshift/osincli"
)
Expand Down Expand Up @@ -47,6 +51,9 @@ func getOpenShiftAuthHandlerFromSpec(provider *v1beta1.AuthProvider, openshiftSp
}
apiServerURL = parsedURL.String()
} else {
// url.Parse should not fail on a URL already validated by the API
// server; fall back to the raw string to preserve backward
// compatibility rather than failing provider initialization.
apiServerURL = authURL
}
} else {
Expand Down Expand Up @@ -101,6 +108,8 @@ func getOpenShiftAuthHandlerFromSpec(provider *v1beta1.AuthProvider, openshiftSp
return handler, nil
}

// openshiftClientForRedirect creates an osincli OAuth2 client configured for
// the given redirect URI using the handler's stored credentials and TLS config.
func (o *OpenShiftAuthHandler) openshiftClientForRedirect(redirectURI string) (*osincli.Client, error) {
oauthClientConfig := &osincli.ClientConfig{
ClientId: o.clientId,
Expand All @@ -123,11 +132,76 @@ func (o *OpenShiftAuthHandler) openshiftClientForRedirect(redirectURI string) (*
return client, nil
}

// openShiftTokenName derives the OAuthAccessToken Kubernetes resource name from
// an access token value. Tokens prefixed with "sha256~" are named by hashing
// the full token string with SHA-256 and base64url-encoding the result (no
// padding). Legacy tokens (no prefix) are used as-is — the token value IS the
// resource name in that case.
func openShiftTokenName(token string) string {
if strings.HasPrefix(token, "sha256~") {
hash := sha256.Sum256([]byte(token))
return "sha256~" + b64.RawURLEncoding.EncodeToString(hash[:])
}
return token
}

// openShiftAPIServerBase normalises apiServerURL to a plain scheme+host (no
// path, query, or fragment) so the revocation URL is always well-formed even
// when apiServerURL was derived from an AuthorizationUrl that carries extra
// components.
func openShiftAPIServerBase(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" {
return strings.TrimSuffix(rawURL, "/")
}
return parsed.Scheme + "://" + parsed.Host
}

// Logout revokes the OpenShift OAuth access token server-side by issuing a
// DELETE to /apis/oauth.openshift.io/v1/oauthaccesstokens/{name}, authenticated
// with the token itself as the Bearer credential. Revocation invalidates the
// OpenShift session without requiring a browser redirect, avoiding the 405 that
// the OAuth server's /logout endpoint returns for unauthenticated GET requests.
//
// Always returns ("", nil) — the caller falls back to a local page reload which
// lands the user on the login page with no valid session.
func (o *OpenShiftAuthHandler) Logout(token string, _ string) (string, error) {
// The cookie will be cleared by the proxy
if token == "" || o.apiServerURL == "" {
return "", nil
}

tokenName := openShiftTokenName(token)
base := openShiftAPIServerBase(o.apiServerURL)
revokeURL := fmt.Sprintf("%s/apis/oauth.openshift.io/v1/oauthaccesstokens/%s", base, tokenName)

client := &http.Client{
Transport: &http.Transport{TLSClientConfig: o.tlsConfig},
Timeout: 10 * time.Second,
}

req, err := http.NewRequest(http.MethodDelete, revokeURL, nil)
if err != nil {
log.GetLogger().WithError(err).Warn("Failed to build OpenShift token revocation request")
return "", nil
}
req.Header.Set("Authorization", "Bearer "+token)

resp, err := client.Do(req)
if err != nil {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
log.GetLogger().WithError(err).Warn("Failed to revoke OpenShift OAuth token")
return "", nil
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
log.GetLogger().Warnf("OpenShift token revocation returned unexpected status %d", resp.StatusCode)
}

return "", nil
}

// GetLoginRedirectURL returns the OAuth2 authorization URL the browser should
// be redirected to in order to initiate the OpenShift login flow.
func (o *OpenShiftAuthHandler) GetLoginRedirectURL(state string, codeChallenge string, redirectURI string) (string, error) {
client, err := o.openshiftClientForRedirect(redirectURI)
if err != nil {
Expand Down
Loading
Loading