Skip to content

Commit 3e01eb3

Browse files
authored
feat(ui): make SSO re-authentication optional on logout (questdb#412)
* feat(ui): make SSO re-authentication optional on logout * do not clear error count on login attempt * update submodule * update submodule * Continue as <username> button
1 parent 2ca55eb commit 3e01eb3

10 files changed

Lines changed: 237 additions & 83 deletions

File tree

packages/browser-tests/cypress/integration/enterprise/oidc.spec.js

Lines changed: 153 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@ const interceptSettings = (payload) => {
1414
);
1515
};
1616

17-
const interceptAuthorizationCodeRequest = (redirectUrl) => {
17+
const interceptAuthorizationCodeRequest = (redirectUrl, stateError) => {
1818
cy.intercept("GET", `${oidcAuthorizationCodeUrl}?**`, (req) => {
19-
req.redirect(redirectUrl);
19+
const url = new URL(req.url);
20+
const state = url.searchParams.get('state');
21+
22+
req.redirect(redirectUrl + (state && !stateError ? `&state=${state}` : ""));
2023
}).as('authorizationCode');
2124
};
2225

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

3538
beforeEach(() => {
39+
cy.clearLocalStorage();
40+
3641
// load login page
3742
interceptSettings({
3843
"release.type": "EE",
@@ -76,7 +81,7 @@ describe("OIDC authentication", () => {
7681
cy.logout();
7782
});
7883

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

9398
cy.reload();
94-
cy.getByDataHook("button-log-in").should("be.visible");
99+
cy.getByDataHook("button-sso-continue").should("be.visible");
100+
cy.getByDataHook("button-sso-login").should("be.visible");
101+
cy.getByDataHook("button-sso-login").contains("Choose a different account");
95102

96-
cy.getByDataHook("button-log-in").click()
103+
cy.getByDataHook("button-sso-continue").click()
97104
cy.getEditor().should("be.visible");
98105
});
99106

107+
it("should not force SSO re-authentication with 'Continue as <username>' button", () => {
108+
interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`);
109+
cy.getByDataHook("button-sso-login").click();
110+
cy.wait("@authorizationCode");
111+
112+
interceptTokenRequest({
113+
"access_token": "gslpJtzmmi6RwaPSx0dYGD4tEkom",
114+
"refresh_token": "FUuAAqMp6LSTKmkUd5uZuodhiE4Kr6M7Eyv",
115+
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6I",
116+
"token_type": "Bearer",
117+
"expires_in": 300
118+
});
119+
cy.wait("@tokens");
120+
cy.getEditor().should("be.visible");
121+
122+
cy.executeSQL("select current_user();");
123+
cy.getGridRow(0).should("contain", "user1");
124+
125+
cy.logout();
126+
cy.getByDataHook("button-sso-continue").should("be.visible");
127+
cy.getByDataHook("button-sso-login").should("be.visible");
128+
cy.getByDataHook("button-sso-login").contains("Choose a different account");
129+
130+
cy.getByDataHook("button-sso-continue").click();
131+
cy.wait("@authorizationCode").then((interception) => {
132+
expect(interception.request.url).to.include("/authorization");
133+
const url = new URL(interception.request.url);
134+
expect(url.searchParams.get("prompt")).to.equal(null);
135+
});
136+
});
137+
138+
it("should force SSO re-authentication with 'Choose a different account' button", () => {
139+
interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`);
140+
cy.getByDataHook("button-sso-login").click();
141+
cy.wait("@authorizationCode");
142+
143+
interceptTokenRequest({
144+
"access_token": "gslpJtzmmi6RwaPSx0dYGD4tEkom",
145+
"refresh_token": "FUuAAqMp6LSTKmkUd5uZuodhiE4Kr6M7Eyv",
146+
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6I",
147+
"token_type": "Bearer",
148+
"expires_in": 300
149+
});
150+
cy.wait("@tokens");
151+
cy.getEditor().should("be.visible");
152+
153+
cy.executeSQL("select current_user();");
154+
cy.getGridRow(0).should("contain", "user1");
155+
156+
cy.logout();
157+
cy.getByDataHook("button-sso-continue").should("be.visible");
158+
cy.getByDataHook("button-sso-login").should("be.visible");
159+
cy.getByDataHook("button-sso-login").contains("Choose a different account");
160+
161+
cy.getByDataHook("button-sso-login").click();
162+
cy.wait("@authorizationCode").then((interception) => {
163+
expect(interception.request.url).to.include("/authorization");
164+
const url = new URL(interception.request.url);
165+
expect(url.searchParams.get("prompt")).to.equal("login");
166+
});
167+
});
168+
100169
it("display import panel", () => {
101170
interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`);
102171
cy.getByDataHook("button-sso-login").click();
@@ -125,3 +194,82 @@ describe("OIDC authentication", () => {
125194
cy.logout();
126195
});
127196
});
197+
198+
describe("OIDC authentication - with state", () => {
199+
before(() => {
200+
// setup SSO group mappings
201+
cy.loadConsoleAsAdminAndCreateSSOGroup("group1");
202+
});
203+
204+
beforeEach(() => {
205+
cy.clearLocalStorage();
206+
207+
// load login page
208+
interceptSettings({
209+
"release.type": "EE",
210+
"release.version": "1.2.3",
211+
"acl.enabled": true,
212+
"acl.basic.auth.realm.enabled": false,
213+
"acl.oidc.enabled": true,
214+
"acl.oidc.client.id": "client1",
215+
"acl.oidc.authorization.endpoint": oidcAuthorizationCodeUrl,
216+
"acl.oidc.token.endpoint": oidcTokenUrl,
217+
"acl.oidc.pkce.required": true,
218+
"acl.oidc.state.required": true,
219+
"acl.oidc.groups.encoded.in.token": false,
220+
});
221+
cy.visit(baseUrl);
222+
223+
cy.wait("@settings");
224+
cy.getByDataHook("auth-login").should("be.visible");
225+
cy.getByDataHook("button-sso-continue").should("not.exist");
226+
cy.getByDataHook("button-sso-login").should("be.visible");
227+
cy.getByDataHook("button-sso-login").contains("Continue with SSO");
228+
cy.getEditor().should("not.exist");
229+
});
230+
231+
it("should login via OIDC with state required", () => {
232+
interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`);
233+
cy.getByDataHook("button-sso-login").click();
234+
cy.wait("@authorizationCode");
235+
236+
interceptTokenRequest({
237+
"access_token": "gslpJtzmmi6RwaPSx0dYGD4tEkom",
238+
"refresh_token": "FUuAAqMp6LSTKmkUd5uZuodhiE4Kr6M7Eyv",
239+
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6I",
240+
"token_type": "Bearer",
241+
"expires_in": 300
242+
});
243+
cy.wait("@tokens");
244+
cy.getEditor().should("be.visible");
245+
246+
cy.executeSQL("select current_user();");
247+
cy.getGridRow(0).should("contain", "user1");
248+
249+
cy.logout();
250+
cy.getByDataHook("auth-login").should("be.visible");
251+
cy.getByDataHook("button-sso-continue").should("be.visible");
252+
cy.getByDataHook("button-sso-login").should("be.visible");
253+
cy.getByDataHook("button-sso-login").contains("Choose a different account");
254+
cy.getEditor().should("not.exist");
255+
});
256+
257+
it("should force SSO re-authentication with state error", () => {
258+
interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`, true);
259+
cy.getByDataHook("button-sso-login").click();
260+
cy.wait("@authorizationCode");
261+
262+
cy.getByDataHook("auth-login").should("be.visible");
263+
cy.getByDataHook("button-sso-continue").should("not.exist");
264+
cy.getByDataHook("button-sso-login").should("be.visible");
265+
cy.getByDataHook("button-sso-login").contains("Continue with SSO");
266+
cy.getEditor().should("not.exist");
267+
268+
cy.getByDataHook("button-sso-login").click();
269+
cy.wait("@authorizationCode").then((interception) => {
270+
expect(interception.request.url).to.include("/authorization");
271+
const url = new URL(interception.request.url);
272+
expect(url.searchParams.get("prompt")).to.equal("login");
273+
});
274+
});
275+
});

packages/browser-tests/questdb

Submodule questdb updated 26 files

packages/web-console/src/components/TopBar/toolbar.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import { Text } from "../Text"
88
import { selectors } from "../../store"
99
import { useSelector } from "react-redux"
1010
import { IconWithTooltip } from "../IconWithTooltip"
11-
import { hasUIAuth } from "../../modules/OAuth2/utils"
11+
import { hasUIAuth, setSSOUserNameWithClientID } from "../../modules/OAuth2/utils"
12+
import { getValue } from "../../utils/localStorage"
13+
import { StoreKey } from "../../utils/localStorage/types"
1214

1315
type ServerDetails = {
1416
instance_name: string | null
@@ -93,11 +95,18 @@ export const Toolbar = () => {
9395
},
9496
)
9597
if (response.type === QuestDB.Type.DQL && response.count === 1) {
98+
const currentUser = response.data[0].current_user
9699
setServerDetails({
97100
instance_name: response.data[0].instance_name,
98101
instance_rgb: response.data[0].instance_rgb,
99-
current_user: response.data[0].current_user,
102+
current_user: currentUser,
100103
})
104+
105+
// an SSO user is logged in, update the SSO username
106+
const authPayload = getValue(StoreKey.AUTH_PAYLOAD)
107+
if (authPayload && currentUser && settings["acl.oidc.client.id"]) {
108+
setSSOUserNameWithClientID(settings["acl.oidc.client.id"], currentUser)
109+
}
101110
}
102111
} catch (e) {
103112
return
@@ -145,7 +154,7 @@ export const Toolbar = () => {
145154
skin="secondary"
146155
data-hook="button-logout"
147156
>
148-
Log out
157+
Logout
149158
</Button>
150159
)}
151160
</Box>

packages/web-console/src/modules/OAuth2/utils.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Settings } from "../../providers/SettingsProvider/types"
2+
import { StoreKey } from "../../utils/localStorage/types"
23

34
type TokenPayload = Partial<{
45
grant_type: string
@@ -25,13 +26,13 @@ export const getAuthorisationURL = ({
2526
settings,
2627
code_challenge = null,
2728
state = null,
28-
login,
29+
loginWithDifferentAccount,
2930
redirect_uri,
3031
}: {
3132
settings: Settings
3233
code_challenge: string | null
3334
state: string | null
34-
login?: boolean
35+
loginWithDifferentAccount?: boolean
3536
redirect_uri: string
3637
}) => {
3738
const params = {
@@ -49,7 +50,7 @@ export const getAuthorisationURL = ({
4950
if (state) {
5051
urlParams.append("state", state)
5152
}
52-
if (login) {
53+
if (loginWithDifferentAccount) {
5354
urlParams.append("prompt", "login")
5455
}
5556

@@ -83,3 +84,15 @@ export const getAuthToken = async (
8384

8485
export const hasUIAuth = (settings: Settings) =>
8586
settings["acl.enabled"] && !settings["acl.basic.auth.realm.enabled"]
87+
88+
export const getSSOUserNameWithClientID = (clientId: string) => {
89+
return localStorage.getItem(`${StoreKey.SSO_USERNAME}.${clientId}`) ?? ""
90+
}
91+
92+
export const setSSOUserNameWithClientID = (clientId: string, value: string) => {
93+
localStorage.setItem(`${StoreKey.SSO_USERNAME}.${clientId}`, value)
94+
}
95+
96+
export const removeSSOUserNameWithClientID = (clientId: string) => {
97+
localStorage.removeItem(`${StoreKey.SSO_USERNAME}.${clientId}`)
98+
}

packages/web-console/src/modules/OAuth2/views/error.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export const Error = ({
2323
prefixIcon={<User size="18px" />}
2424
onClick={() => onLogout()}
2525
>
26-
Login with other account
26+
Login
2727
</Button>
2828
)}
2929
</Box>

packages/web-console/src/modules/OAuth2/views/login.tsx

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Text } from "../../../components"
88
import { setValue } from "../../../utils/localStorage"
99
import { StoreKey } from "../../../utils/localStorage/types"
1010
import { useSettings } from "../../../providers"
11+
import { getSSOUserNameWithClientID } from "../utils"
1112

1213
const Header = styled.div`
1314
position: absolute;
@@ -53,16 +54,15 @@ const Container = styled.div`
5354
font-size: 16px;
5455
transition: height 10s ease;
5556
`
56-
const Title = styled.h1`
57+
const Title = styled.h2`
5758
color: white;
58-
text-align: center;
59-
`
59+
text-align: start;
60+
font-weight: 600;`
6061

6162
const SSOCard = styled.div`
6263
button {
6364
padding-top: 2rem;
6465
padding-bottom: 2rem;
65-
border-radius: 0 5px 5px 0;
6666
width: 100%;
6767
margin-bottom: 10px;
6868
}
@@ -199,12 +199,15 @@ export const Login = ({
199199
onOAuthLogin,
200200
onBasicAuthSuccess,
201201
}: {
202-
onOAuthLogin: () => void
202+
onOAuthLogin: (loginWithDifferentAccount?: boolean) => void
203203
onBasicAuthSuccess: () => void
204204
}) => {
205205
const { settings } = useSettings()
206206
const isEE = settings["release.type"] === "EE"
207207
const [errorMessage, setErrorMessage] = React.useState<string | undefined>()
208+
const ssoUsername = settings["acl.oidc.enabled"] && settings["acl.oidc.client.id"]
209+
? getSSOUserNameWithClientID(settings["acl.oidc.client.id"])
210+
: ""
208211

209212
const httpBasicAuthStrategy = isEE
210213
? {
@@ -284,22 +287,35 @@ export const Login = ({
284287
is absent, we should display generic text as the title contributes to
285288
the page layout.
286289
*/}
287-
<Title>Please Sign In</Title>
288290
{settings["acl.oidc.enabled"] && (
289-
<SSOCard>
290-
<StyledButton
291-
data-hook="button-sso-login"
292-
skin="secondary"
293-
prefixIcon={<User size="18px" />}
294-
onClick={() => onOAuthLogin()}
295-
>
296-
Continue with SSO
297-
</StyledButton>
298-
<Line>
299-
<LineText color="gray2">or</LineText>
300-
</Line>
301-
</SSOCard>
291+
<>
292+
<Title style={{ marginBottom: '4rem' }}>Single Sign-On</Title>
293+
<SSOCard>
294+
{!!ssoUsername && (
295+
<StyledButton
296+
data-hook="button-sso-continue"
297+
skin="primary"
298+
prefixIcon={<User size="18px" />}
299+
onClick={() => onOAuthLogin(false)}
300+
>
301+
Continue as {ssoUsername}
302+
</StyledButton>
303+
)}
304+
<StyledButton
305+
data-hook="button-sso-login"
306+
skin={!!ssoUsername ? "transparent" : "primary"}
307+
prefixIcon={!!ssoUsername ? undefined : <User size="18px" />}
308+
onClick={() => onOAuthLogin(true)}
309+
>
310+
{!!ssoUsername ? "Choose a different account" : "Continue with SSO"}
311+
</StyledButton>
312+
<Line style={{ marginBottom: '4rem', marginTop: '2rem' }}>
313+
<LineText color="gray2">or</LineText>
314+
</Line>
315+
</SSOCard>
316+
</>
302317
)}
318+
<Title>Sign In</Title>
303319
<Card hasError={errorMessage}>
304320
<Form<FormValues>
305321
name="login"

0 commit comments

Comments
 (0)