Skip to content

feat(ui): make SSO re-authentication optional on logout #412

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Apr 29, 2025
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
158 changes: 153 additions & 5 deletions packages/browser-tests/cypress/integration/enterprise/oidc.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@ const interceptSettings = (payload) => {
);
};

const interceptAuthorizationCodeRequest = (redirectUrl) => {
const interceptAuthorizationCodeRequest = (redirectUrl, stateError) => {
cy.intercept("GET", `${oidcAuthorizationCodeUrl}?**`, (req) => {
req.redirect(redirectUrl);
const url = new URL(req.url);
const state = url.searchParams.get('state');

req.redirect(redirectUrl + (state && !stateError ? `&state=${state}` : ""));
}).as('authorizationCode');
};

Expand All @@ -33,6 +36,8 @@ describe("OIDC authentication", () => {
});

beforeEach(() => {
cy.clearLocalStorage();

// load login page
interceptSettings({
"release.type": "EE",
Expand Down Expand Up @@ -76,7 +81,7 @@ describe("OIDC authentication", () => {
cy.logout();
});

it("should force authentication if token expired, and there is no refresh token", () => {
it("should go to Login page if token expired, and there is no refresh token", () => {
interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`);
cy.getByDataHook("button-sso-login").click();
cy.wait("@authorizationCode");
Expand All @@ -91,12 +96,76 @@ describe("OIDC authentication", () => {
cy.getEditor().should("be.visible");

cy.reload();
cy.getByDataHook("button-log-in").should("be.visible");
cy.getByDataHook("button-sso-continue").should("be.visible");
cy.getByDataHook("button-sso-login").should("be.visible");
cy.getByDataHook("button-sso-login").contains("Choose a different account");

cy.getByDataHook("button-log-in").click()
cy.getByDataHook("button-sso-continue").click()
cy.getEditor().should("be.visible");
});

it("should not force SSO re-authentication with 'Continue as <username>' button", () => {
interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`);
cy.getByDataHook("button-sso-login").click();
cy.wait("@authorizationCode");

interceptTokenRequest({
"access_token": "gslpJtzmmi6RwaPSx0dYGD4tEkom",
"refresh_token": "FUuAAqMp6LSTKmkUd5uZuodhiE4Kr6M7Eyv",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6I",
"token_type": "Bearer",
"expires_in": 300
});
cy.wait("@tokens");
cy.getEditor().should("be.visible");

cy.executeSQL("select current_user();");
cy.getGridRow(0).should("contain", "user1");

cy.logout();
cy.getByDataHook("button-sso-continue").should("be.visible");
cy.getByDataHook("button-sso-login").should("be.visible");
cy.getByDataHook("button-sso-login").contains("Choose a different account");

cy.getByDataHook("button-sso-continue").click();
cy.wait("@authorizationCode").then((interception) => {
expect(interception.request.url).to.include("/authorization");
const url = new URL(interception.request.url);
expect(url.searchParams.get("prompt")).to.equal(null);
});
});

it("should force SSO re-authentication with 'Choose a different account' button", () => {
interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`);
cy.getByDataHook("button-sso-login").click();
cy.wait("@authorizationCode");

interceptTokenRequest({
"access_token": "gslpJtzmmi6RwaPSx0dYGD4tEkom",
"refresh_token": "FUuAAqMp6LSTKmkUd5uZuodhiE4Kr6M7Eyv",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6I",
"token_type": "Bearer",
"expires_in": 300
});
cy.wait("@tokens");
cy.getEditor().should("be.visible");

cy.executeSQL("select current_user();");
cy.getGridRow(0).should("contain", "user1");

cy.logout();
cy.getByDataHook("button-sso-continue").should("be.visible");
cy.getByDataHook("button-sso-login").should("be.visible");
cy.getByDataHook("button-sso-login").contains("Choose a different account");

cy.getByDataHook("button-sso-login").click();
cy.wait("@authorizationCode").then((interception) => {
expect(interception.request.url).to.include("/authorization");
const url = new URL(interception.request.url);
expect(url.searchParams.get("prompt")).to.equal("login");
});
});

it("display import panel", () => {
interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`);
cy.getByDataHook("button-sso-login").click();
Expand Down Expand Up @@ -125,3 +194,82 @@ describe("OIDC authentication", () => {
cy.logout();
});
});

describe("OIDC authentication - with state", () => {
before(() => {
// setup SSO group mappings
cy.loadConsoleAsAdminAndCreateSSOGroup("group1");
});

beforeEach(() => {
cy.clearLocalStorage();

// load login page
interceptSettings({
"release.type": "EE",
"release.version": "1.2.3",
"acl.enabled": true,
"acl.basic.auth.realm.enabled": false,
"acl.oidc.enabled": true,
"acl.oidc.client.id": "client1",
"acl.oidc.authorization.endpoint": oidcAuthorizationCodeUrl,
"acl.oidc.token.endpoint": oidcTokenUrl,
"acl.oidc.pkce.required": true,
"acl.oidc.state.required": true,
"acl.oidc.groups.encoded.in.token": false,
});
cy.visit(baseUrl);

cy.wait("@settings");
cy.getByDataHook("auth-login").should("be.visible");
cy.getByDataHook("button-sso-continue").should("not.exist");
cy.getByDataHook("button-sso-login").should("be.visible");
cy.getByDataHook("button-sso-login").contains("Continue with SSO");
cy.getEditor().should("not.exist");
});

it("should login via OIDC with state required", () => {
interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`);
cy.getByDataHook("button-sso-login").click();
cy.wait("@authorizationCode");

interceptTokenRequest({
"access_token": "gslpJtzmmi6RwaPSx0dYGD4tEkom",
"refresh_token": "FUuAAqMp6LSTKmkUd5uZuodhiE4Kr6M7Eyv",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6I",
"token_type": "Bearer",
"expires_in": 300
});
cy.wait("@tokens");
cy.getEditor().should("be.visible");

cy.executeSQL("select current_user();");
cy.getGridRow(0).should("contain", "user1");

cy.logout();
cy.getByDataHook("auth-login").should("be.visible");
cy.getByDataHook("button-sso-continue").should("be.visible");
cy.getByDataHook("button-sso-login").should("be.visible");
cy.getByDataHook("button-sso-login").contains("Choose a different account");
cy.getEditor().should("not.exist");
});

it("should force SSO re-authentication with state error", () => {
interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`, true);
cy.getByDataHook("button-sso-login").click();
cy.wait("@authorizationCode");

cy.getByDataHook("auth-login").should("be.visible");
cy.getByDataHook("button-sso-continue").should("not.exist");
cy.getByDataHook("button-sso-login").should("be.visible");
cy.getByDataHook("button-sso-login").contains("Continue with SSO");
cy.getEditor().should("not.exist");

cy.getByDataHook("button-sso-login").click();
cy.wait("@authorizationCode").then((interception) => {
expect(interception.request.url).to.include("/authorization");
const url = new URL(interception.request.url);
expect(url.searchParams.get("prompt")).to.equal("login");
});
});
});
2 changes: 1 addition & 1 deletion packages/browser-tests/questdb
Submodule questdb updated 26 files
+1 −1 benchmarks/pom.xml
+1 −1 compat/pom.xml
+2 −2 core/pom.xml
+5 −1 core/src/main/java/io/questdb/griffin/SqlParser.java
+80 −17 core/src/main/java/io/questdb/griffin/engine/ops/CreateMatViewOperationImpl.java
+0 −11 core/src/test/java/io/questdb/test/AbstractBootstrapTest.java
+13 −19 core/src/test/java/io/questdb/test/AbstractCairoTest.java
+52 −0 core/src/test/java/io/questdb/test/AbstractTest.java
+109 −11 core/src/test/java/io/questdb/test/cairo/mv/CreateMatViewTest.java
+4 −11 core/src/test/java/io/questdb/test/cairo/mv/MatViewFuzzTest.java
+3 −13 core/src/test/java/io/questdb/test/cairo/mv/MatViewOomTest.java
+31 −93 core/src/test/java/io/questdb/test/cairo/mv/MatViewReloadOnRestartTest.java
+1 −1 core/src/test/java/io/questdb/test/cairo/mv/MatViewTelemetryTest.java
+8 −20 core/src/test/java/io/questdb/test/cairo/mv/MatViewTest.java
+1 −1 core/src/test/java/io/questdb/test/cairo/o3/O3FailureTest.java
+12 −15 core/src/test/java/io/questdb/test/cairo/wal/WalTableFailureTest.java
+80 −90 core/src/test/java/io/questdb/test/cairo/wal/WalTableSqlTest.java
+1 −2 core/src/test/java/io/questdb/test/cairo/wal/WalTableWriterFuzzTest.java
+2 −2 core/src/test/java/io/questdb/test/cutlass/http/IODispatcherTest.java
+1 −7 core/src/test/java/io/questdb/test/cutlass/pgwire/PGJobContextTest.java
+1 −1 core/src/test/java/io/questdb/test/griffin/AlterTableSetTypeSuspendedTest.java
+183 −146 core/src/test/java/io/questdb/test/griffin/SqlParserTest.java
+5 −15 core/src/test/java/io/questdb/test/griffin/TableBackupTest.java
+1 −1 examples/pom.xml
+1 −1 pom.xml
+1 −1 utils/pom.xml
15 changes: 12 additions & 3 deletions packages/web-console/src/components/TopBar/toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { Text } from "../Text"
import { selectors } from "../../store"
import { useSelector } from "react-redux"
import { IconWithTooltip } from "../IconWithTooltip"
import { hasUIAuth } from "../../modules/OAuth2/utils"
import { hasUIAuth, setSSOUserNameWithClientID } from "../../modules/OAuth2/utils"
import { getValue } from "../../utils/localStorage"
import { StoreKey } from "../../utils/localStorage/types"

type ServerDetails = {
instance_name: string | null
Expand Down Expand Up @@ -93,11 +95,18 @@ export const Toolbar = () => {
},
)
if (response.type === QuestDB.Type.DQL && response.count === 1) {
const currentUser = response.data[0].current_user
setServerDetails({
instance_name: response.data[0].instance_name,
instance_rgb: response.data[0].instance_rgb,
current_user: response.data[0].current_user,
current_user: currentUser,
})

// an SSO user is logged in, update the SSO username
const authPayload = getValue(StoreKey.AUTH_PAYLOAD)
if (authPayload && currentUser && settings["acl.oidc.client.id"]) {
setSSOUserNameWithClientID(settings["acl.oidc.client.id"], currentUser)
}
}
} catch (e) {
return
Expand Down Expand Up @@ -145,7 +154,7 @@ export const Toolbar = () => {
skin="secondary"
data-hook="button-logout"
>
Log out
Logout
</Button>
)}
</Box>
Expand Down
19 changes: 16 additions & 3 deletions packages/web-console/src/modules/OAuth2/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Settings } from "../../providers/SettingsProvider/types"
import { StoreKey } from "../../utils/localStorage/types"

type TokenPayload = Partial<{
grant_type: string
Expand All @@ -25,13 +26,13 @@ export const getAuthorisationURL = ({
settings,
code_challenge = null,
state = null,
login,
loginWithDifferentAccount,
redirect_uri,
}: {
settings: Settings
code_challenge: string | null
state: string | null
login?: boolean
loginWithDifferentAccount?: boolean
redirect_uri: string
}) => {
const params = {
Expand All @@ -49,7 +50,7 @@ export const getAuthorisationURL = ({
if (state) {
urlParams.append("state", state)
}
if (login) {
if (loginWithDifferentAccount) {
urlParams.append("prompt", "login")
}

Expand Down Expand Up @@ -83,3 +84,15 @@ export const getAuthToken = async (

export const hasUIAuth = (settings: Settings) =>
settings["acl.enabled"] && !settings["acl.basic.auth.realm.enabled"]

export const getSSOUserNameWithClientID = (clientId: string) => {
return localStorage.getItem(`${StoreKey.SSO_USERNAME}.${clientId}`) ?? ""
}

export const setSSOUserNameWithClientID = (clientId: string, value: string) => {
localStorage.setItem(`${StoreKey.SSO_USERNAME}.${clientId}`, value)
}

export const removeSSOUserNameWithClientID = (clientId: string) => {
localStorage.removeItem(`${StoreKey.SSO_USERNAME}.${clientId}`)
}
2 changes: 1 addition & 1 deletion packages/web-console/src/modules/OAuth2/views/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const Error = ({
prefixIcon={<User size="18px" />}
onClick={() => onLogout()}
>
Login with other account
Login
</Button>
)}
</Box>
Expand Down
54 changes: 35 additions & 19 deletions packages/web-console/src/modules/OAuth2/views/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Text } from "../../../components"
import { setValue } from "../../../utils/localStorage"
import { StoreKey } from "../../../utils/localStorage/types"
import { useSettings } from "../../../providers"
import { getSSOUserNameWithClientID } from "../utils"

const Header = styled.div`
position: absolute;
Expand Down Expand Up @@ -53,16 +54,15 @@ const Container = styled.div`
font-size: 16px;
transition: height 10s ease;
`
const Title = styled.h1`
const Title = styled.h2`
color: white;
text-align: center;
`
text-align: start;
font-weight: 600;`

const SSOCard = styled.div`
button {
padding-top: 2rem;
padding-bottom: 2rem;
border-radius: 0 5px 5px 0;
width: 100%;
margin-bottom: 10px;
}
Expand Down Expand Up @@ -199,12 +199,15 @@ export const Login = ({
onOAuthLogin,
onBasicAuthSuccess,
}: {
onOAuthLogin: () => void
onOAuthLogin: (loginWithDifferentAccount?: boolean) => void
onBasicAuthSuccess: () => void
}) => {
const { settings } = useSettings()
const isEE = settings["release.type"] === "EE"
const [errorMessage, setErrorMessage] = React.useState<string | undefined>()
const ssoUsername = settings["acl.oidc.enabled"] && settings["acl.oidc.client.id"]
? getSSOUserNameWithClientID(settings["acl.oidc.client.id"])
: ""

const httpBasicAuthStrategy = isEE
? {
Expand Down Expand Up @@ -284,22 +287,35 @@ export const Login = ({
is absent, we should display generic text as the title contributes to
the page layout.
*/}
<Title>Please Sign In</Title>
{settings["acl.oidc.enabled"] && (
<SSOCard>
<StyledButton
data-hook="button-sso-login"
skin="secondary"
prefixIcon={<User size="18px" />}
onClick={() => onOAuthLogin()}
>
Continue with SSO
</StyledButton>
<Line>
<LineText color="gray2">or</LineText>
</Line>
</SSOCard>
<>
<Title style={{ marginBottom: '4rem' }}>Single Sign-On</Title>
<SSOCard>
{!!ssoUsername && (
<StyledButton
data-hook="button-sso-continue"
skin="primary"
prefixIcon={<User size="18px" />}
onClick={() => onOAuthLogin(false)}
>
Continue as {ssoUsername}
</StyledButton>
)}
<StyledButton
data-hook="button-sso-login"
skin={!!ssoUsername ? "transparent" : "primary"}
prefixIcon={!!ssoUsername ? undefined : <User size="18px" />}
onClick={() => onOAuthLogin(true)}
>
{!!ssoUsername ? "Choose a different account" : "Continue with SSO"}
</StyledButton>
<Line style={{ marginBottom: '4rem', marginTop: '2rem' }}>
<LineText color="gray2">or</LineText>
</Line>
</SSOCard>
</>
)}
<Title>Sign In</Title>
<Card hasError={errorMessage}>
<Form<FormValues>
name="login"
Expand Down
Loading