Skip to content

Commit c565e80

Browse files
committed
feat(web-console): add SSO session continue mechanism
1 parent 938ec19 commit c565e80

File tree

7 files changed

+133
-52
lines changed

7 files changed

+133
-52
lines changed

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

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ describe("OIDC authentication", () => {
3333
});
3434

3535
beforeEach(() => {
36+
cy.clearLocalStorage();
37+
3638
// load login page
3739
interceptSettings({
3840
"release.type": "EE",
@@ -76,7 +78,7 @@ describe("OIDC authentication", () => {
7678
cy.logout();
7779
});
7880

79-
it("should force authentication if token expired, and there is no refresh token", () => {
81+
it("should force SSO authentication if token expired, and there is no refresh token", () => {
8082
interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`);
8183
cy.getByDataHook("button-sso-login").click();
8284
cy.wait("@authorizationCode");
@@ -91,9 +93,65 @@ describe("OIDC authentication", () => {
9193
cy.getEditor().should("be.visible");
9294

9395
cy.reload();
94-
cy.getByDataHook("button-log-in").should("be.visible");
96+
cy.getByDataHook("button-sso-login").should("be.visible");
97+
98+
cy.getByDataHook("button-sso-login").click();
99+
cy.getEditor().should("be.visible");
100+
});
101+
102+
it("should not force SSO re-authentication with continue button", () => {
103+
interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`);
104+
cy.getByDataHook("button-sso-login").click();
105+
cy.wait("@authorizationCode");
106+
107+
interceptTokenRequest({
108+
"access_token": "gslpJtzmmi6RwaPSx0dYGD4tEkom",
109+
"refresh_token": "FUuAAqMp6LSTKmkUd5uZuodhiE4Kr6M7Eyv",
110+
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6I",
111+
"token_type": "Bearer",
112+
"expires_in": 300
113+
});
114+
cy.wait("@tokens");
115+
cy.getEditor().should("be.visible");
116+
117+
cy.executeSQL("select current_user();");
118+
cy.getGridRow(0).should("contain", "user1");
119+
120+
cy.logout();
121+
122+
cy.getByDataHook("button-sso-continue").click();
123+
cy.wait("@authorizationCode").then((interception) => {
124+
expect(interception.request.url).to.include("/authorization");
125+
const url = new URL(interception.request.url);
126+
expect(url.searchParams.get("prompt")).to.equal(null);
127+
});
128+
});
129+
130+
it("should force SSO re-authentication with choose a different account button", () => {
131+
interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`);
132+
cy.getByDataHook("button-sso-login").click();
133+
cy.wait("@authorizationCode");
95134

96-
cy.getByDataHook("button-log-in").click()
135+
interceptTokenRequest({
136+
"access_token": "gslpJtzmmi6RwaPSx0dYGD4tEkom",
137+
"refresh_token": "FUuAAqMp6LSTKmkUd5uZuodhiE4Kr6M7Eyv",
138+
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6I",
139+
"token_type": "Bearer",
140+
"expires_in": 300
141+
});
142+
cy.wait("@tokens");
97143
cy.getEditor().should("be.visible");
144+
145+
cy.executeSQL("select current_user();");
146+
cy.getGridRow(0).should("contain", "user1");
147+
148+
cy.logout();
149+
150+
cy.getByDataHook("button-sso-login").click();
151+
cy.wait("@authorizationCode").then((interception) => {
152+
expect(interception.request.url).to.include("/authorization");
153+
const url = new URL(interception.request.url);
154+
expect(url.searchParams.get("prompt")).to.equal("login");
155+
});
98156
});
99-
});
157+
});

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: 13 additions & 0 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
@@ -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/login.tsx

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +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-
11+
import { getSSOUserNameWithClientID } from "../utils"
1212
const Header = styled.div`
1313
position: absolute;
1414
width: 100%;
@@ -53,16 +53,16 @@ const Container = styled.div`
5353
font-size: 16px;
5454
transition: height 10s ease;
5555
`
56-
const Title = styled.h1`
56+
const Title = styled.h2`
5757
color: white;
58-
text-align: center;
58+
text-align: start;
59+
font-weight: 600;
5960
`
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"

packages/web-console/src/providers/AuthProvider.tsx

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
getAuthorisationURL,
1313
getAuthToken,
1414
getTokenExpirationDate,
15+
removeSSOUserNameWithClientID,
1516
} from "../modules/OAuth2/utils"
1617
import {
1718
generateCodeChallenge,
@@ -29,17 +30,15 @@ import { useSettings } from "./SettingsProvider"
2930

3031
type ContextProps = {
3132
sessionData?: Partial<AuthPayload>
32-
logout: () => void
33+
logout: (removeSSOUsername?: boolean) => void
3334
refreshAuthToken: (settings: Settings) => Promise<AuthPayload>
34-
switchToOAuth: () => void
3535
}
3636

3737
enum View {
3838
ready,
3939
loading,
4040
error,
4141
login,
42-
loggedOut,
4342
}
4443

4544
type State = { view: View; errorMessage?: string }
@@ -52,7 +51,6 @@ const defaultValues: ContextProps = {
5251
sessionData: undefined,
5352
logout: () => {},
5453
refreshAuthToken: async () => ({} as AuthPayload),
55-
switchToOAuth: () => {},
5654
}
5755

5856
class OAuth2Error {
@@ -259,32 +257,27 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
259257
}
260258
}
261259

262-
const redirectToAuthorizationUrl = (login?: boolean) => {
260+
const redirectToAuthorizationUrl = (loginWithDifferentAccount?: boolean) => {
263261
const state = generateState(settings)
264262
const code_verifier = generateCodeVerifier(settings)
265263
const code_challenge = generateCodeChallenge(code_verifier)
266264
window.location.href = getAuthorisationURL({
267265
settings,
268266
code_challenge,
269267
state,
270-
login,
268+
login: loginWithDifferentAccount,
271269
redirect_uri: settings["acl.oidc.redirect.uri"] || window.location.href,
272270
})
273271
}
274272

275-
const logout = (noRedirect?: boolean) => {
273+
const logout = (removeSSOUsername?: boolean) => {
276274
removeValue(StoreKey.AUTH_PAYLOAD)
277275
removeValue(StoreKey.REST_TOKEN)
278276
removeValue(StoreKey.BASIC_AUTH_HEADER)
279-
if (noRedirect) {
280-
dispatch({ view: View.loggedOut })
281-
} else {
282-
window.location.reload()
277+
if (removeSSOUsername && settings["acl.oidc.client.id"]) {
278+
removeSSOUserNameWithClientID(settings["acl.oidc.client.id"])
283279
}
284-
}
285-
286-
const switchToOAuth = () => {
287-
redirectToAuthorizationUrl(true)
280+
dispatch({ view: View.login })
288281
}
289282

290283
useEffect(() => {
@@ -322,7 +315,6 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
322315
sessionData,
323316
logout,
324317
refreshAuthToken,
325-
switchToOAuth,
326318
}}
327319
>
328320
{children}
@@ -337,20 +329,12 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
337329
),
338330
[View.login]: () => (
339331
<Login
340-
onOAuthLogin={switchToOAuth}
332+
onOAuthLogin={redirectToAuthorizationUrl}
341333
onBasicAuthSuccess={() => {
342334
dispatch({ view: View.ready })
343335
}}
344336
/>
345337
),
346-
[View.loggedOut]: () => (
347-
<Logout
348-
onLogout={() => {
349-
removeValue(StoreKey.OAUTH_REDIRECT_COUNT)
350-
redirectToAuthorizationUrl(true)
351-
}}
352-
/>
353-
),
354338
}
355339

356340
return <>{views[state.view]()}</>

packages/web-console/src/utils/localStorage/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,5 @@ export enum StoreKey {
3838
REST_TOKEN = "rest.token",
3939
BASIC_AUTH_HEADER = "basic.auth.header",
4040
AUTO_REFRESH_TABLES = "auto.refresh.tables",
41+
SSO_USERNAME = "sso.username",
4142
}

0 commit comments

Comments
 (0)