Skip to content

fix(ui): do not persist OAuth2 tokens #438

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

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
7 changes: 3 additions & 4 deletions packages/web-console/src/components/TopBar/toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,14 @@ import { selectors } from "../../store"
import { useSelector } from "react-redux"
import { IconWithTooltip } from "../IconWithTooltip"
import { hasUIAuth, setSSOUserNameWithClientID } from "../../modules/OAuth2/utils"
import { getValue } from "../../utils/localStorage"
import { StoreKey } from "../../utils/localStorage/types"
import { InstanceSettingsPopper } from "./InstanceSettingsPopper"
import { Preferences, InstanceType } from "../../utils"
import { PopperHover, Placement } from "../"
import { useTheme } from "styled-components"
import { TelemetryTable } from "../../consts";
import { TelemetryConfigShape } from "../../store/Telemetry/types";
import { sendServerInfoTelemetry } from "../../utils/telemetry";
import { authPayloadHolder } from "../../modules/OAuth2/authPayloadHolder";

const EnvIconWrapper = styled.div<{ $background?: string }>`
display: flex;
Expand Down Expand Up @@ -369,8 +368,8 @@ export const Toolbar = () => {
setCurrentUser(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"]) {
const ssoAuthenticated = authPayloadHolder.isSSOAuthenticated()
if (ssoAuthenticated && currentUser && settings["acl.oidc.client.id"]) {
setSSOUserNameWithClientID(settings["acl.oidc.client.id"], currentUser)
}
return currentUser
Expand Down
3 changes: 2 additions & 1 deletion packages/web-console/src/consts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export enum TelemetryTable {
WAL = "sys.telemetry_wal",
}

const BASE = process.env.NODE_ENV === "production" ? "fara" : "alurin"
//const BASE = process.env.NODE_ENV === "production" ? "fara" : "alurin"
const BASE = "alurin"

export const API = `https://${BASE}.questdb.io`

Expand Down
5 changes: 2 additions & 3 deletions packages/web-console/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,13 @@ import {
TransitionDuration,
ToastContainer,
} from "./components"
import { actions, rootEpic, rootReducer } from "./store"
import { rootEpic, rootReducer } from "./store"
import { StoreAction, StoreShape } from "./types"

import Layout from "./scenes/Layout"
import { theme } from "./theme"
import { LocalStorageProvider } from "./providers/LocalStorageProvider"
import { PosthogProviderWrapper } from "./providers/PosthogProviderWrapper"
import { AuthProvider, QuestProvider, SettingsProvider } from "./providers"
import { AuthProvider, QuestProvider, SettingsProvider, PosthogProviderWrapper } from "./providers"

const epicMiddleware = createEpicMiddleware<
StoreAction,
Expand Down
23 changes: 23 additions & 0 deletions packages/web-console/src/modules/OAuth2/authPayloadHolder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { AuthPayload } from "./types";

class AuthPayloadHolder {
private authPayload: AuthPayload | null = null;

setAuthPayload(authPayload: AuthPayload) {
this.authPayload = authPayload
}

getAuthPayload(): AuthPayload | null {
return this.authPayload;
}

isSSOAuthenticated(): boolean {
return !!this.authPayload;
}

clearAuthPayload() {
this.authPayload = null;
}
}

export const authPayloadHolder = new AuthPayloadHolder();
2 changes: 1 addition & 1 deletion packages/web-console/src/modules/OAuth2/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ export type AuthPayload = {
refresh_token: string
token_type: string
expires_in: number
expires_at?: string
expires_at: string
groups_encoded_in_token?: boolean
}
8 changes: 4 additions & 4 deletions packages/web-console/src/modules/OAuth2/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ export const getAuthorisationURL = ({
settings,
code_challenge = null,
state = null,
loginWithDifferentAccount,
prompt,
redirect_uri,
}: {
settings: Settings
code_challenge: string | null
state: string | null
loginWithDifferentAccount?: boolean
prompt?: "login" | "none"
redirect_uri: string
}) => {
const params = {
Expand All @@ -50,8 +50,8 @@ export const getAuthorisationURL = ({
if (state) {
urlParams.append("state", state)
}
if (loginWithDifferentAccount) {
urlParams.append("prompt", "login")
if (prompt) {
urlParams.append("prompt", prompt)
}

return (
Expand Down
141 changes: 70 additions & 71 deletions packages/web-console/src/providers/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getValue, removeValue, setValue } from "../utils/localStorage"
import {
getAuthorisationURL,
getAuthToken,
getSSOUserNameWithClientID,
getTokenExpirationDate,
removeSSOUserNameWithClientID,
} from "../modules/OAuth2/utils"
Expand All @@ -26,11 +27,12 @@ import { Error } from "../modules/OAuth2/views/error"
import { Login } from "../modules/OAuth2/views/login"
import { Settings } from "./SettingsProvider/types"
import { useSettings } from "./SettingsProvider"
import { authPayloadHolder } from "../modules/OAuth2/authPayloadHolder";

type ContextProps = {
sessionData?: Partial<AuthPayload>,
logout: (promptForLogin?: boolean) => void,
refreshAuthToken: (settings: Settings) => Promise<AuthPayload>,
refreshAuthToken: (settings: Settings, refreshToken: string | undefined) => Promise<AuthPayload>,
redirectToAuthorizationUrl: () => void,
}

Expand Down Expand Up @@ -84,10 +86,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
tokenResponse.expires_at = getTokenExpirationDate(
tokenResponse.expires_in,
).toString() // convert from the sec offset
setValue(StoreKey.AUTH_PAYLOAD, JSON.stringify(tokenResponse))
// if the token payload does not contain refresh token, token refresh has been disabled in
// the OAuth2 provider, and we need to clear the refresh token in local storage
setValue(StoreKey.AUTH_REFRESH_TOKEN, tokenResponse.refresh_token ?? "")
authPayloadHolder.setAuthPayload(tokenResponse)
setSessionData(tokenResponse)
// Remove the code from the URL
history.replaceState &&
Expand All @@ -108,10 +107,10 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
}
}

const refreshAuthToken = async (settings: Settings) => {
const refreshAuthToken = async (settings: Settings, refreshToken: string | undefined) => {
const response = await getAuthToken(settings, {
grant_type: "refresh_token",
refresh_token: getValue(StoreKey.AUTH_REFRESH_TOKEN),
refresh_token: refreshToken,
client_id: settings["acl.oidc.client.id"],
})
const tokenResponse = await response.json()
Expand All @@ -125,29 +124,21 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
return
}

// Proceed with the OAuth2 flow only if it's enabled on the server
// Proceed with the OAuth2 flow only if it is enabled on the server
if (settings["acl.oidc.enabled"]) {
// Loading state is for OAuth2 flow only, as basic auth has no persistence layer, and it's in-memory only
const authPayload = getValue(StoreKey.AUTH_PAYLOAD)
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get("code")
const oauth2Error = new OAuth2Error(
urlParams.get("error"),
urlParams.get("error_description"),
)

// Subscribe for any subsequent REST 401 responses (incorrect token, etc)
eventBus.subscribe(EventType.MSG_CONNECTION_UNAUTHORIZED, () => {
const oauthRedirectCount = getValue(StoreKey.OAUTH_REDIRECT_COUNT)
// If any prior 401 when oauth2 is enabled has been received
if (oauthRedirectCount) {
const count = parseInt(oauthRedirectCount)
// Something's wrong with the backend consistently rejecting the token.
// Something is wrong with the backend, it is consistently rejecting the token.
// Redirect to a dedicated logout page instead to break the loop.
if (!isNaN(count) && count >= 5) {
// redirect to /logout and force user authentication to avoid infinite loop
removeValue(StoreKey.OAUTH_REDIRECT_COUNT)
logout(true)
// redirect to /logout to avoid infinite loop
} else {
setValue(
StoreKey.OAUTH_REDIRECT_COUNT,
Expand All @@ -169,60 +160,64 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
removeValue(StoreKey.OAUTH_REDIRECT_COUNT)
})

// User is authenticated already
if (authPayload !== "") {
const tokenResponse = JSON.parse(authPayload)
// Check if the token expired or is about to in 30 seconds
if (new Date(tokenResponse.expires_at).getTime() - Date.now() < 30000) {
if (getValue(StoreKey.AUTH_REFRESH_TOKEN) !== "") {
// if there is a refresh token, go to OAuth2 provider to get fresh tokens
await refreshAuthToken(settings)
} else {
// if there is no refresh token, send the user to the login screen
logout()
const ssoUsername = settings["acl.oidc.client.id"]
? getSSOUserNameWithClientID(settings["acl.oidc.client.id"])
: ""
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get("code")
const oauth2Error = new OAuth2Error(
urlParams.get("error"),
urlParams.get("error_description"),
)

if (code !== null) {
// User has just been redirected back from the OAuth2 provider with an authorization code
const state = getValue(StoreKey.OAUTH_STATE)
if (state) {
removeValue(StoreKey.OAUTH_STATE)
const stateParam = urlParams.get("state")
if (!stateParam || state !== stateParam) {
// state is missing or there is a mismatch, user has to re-authenticate
logout(true)
return
}
} else {
setSessionData(tokenResponse)
}
} else {
// User has just been redirected back from the OAuth2 provider and has the code
if (code !== null) {
const state = getValue(StoreKey.OAUTH_STATE)
if (state) {
removeValue(StoreKey.OAUTH_STATE)
const stateParam = urlParams.get("state")
if (!stateParam || state !== stateParam) {
// state is missing or there is a mismatch, send user to authenticate again
logout(true)
return
}
}

try {
const code_verifier = getValue(StoreKey.PKCE_CODE_VERIFIER)
const response = await getAuthToken(settings, {
grant_type: "authorization_code",
code,
code_verifier,
client_id: settings["acl.oidc.client.id"],
redirect_uri:
settings["acl.oidc.redirect.uri"] ||
window.location.origin + window.location.pathname,
})
const tokenResponse = await response.json()
setAuthToken(tokenResponse, settings)
} catch (e) {
throw e
}
} else if (oauth2Error.error) {
// User has just been redirected back from the OAuth2 provider and there is an error
setErrorMessage(
oauth2Error.error + ": " + oauth2Error.error_description,
)
// Stop loading and display the login state
try {
const code_verifier = getValue(StoreKey.PKCE_CODE_VERIFIER)
const response = await getAuthToken(settings, {
grant_type: "authorization_code",
code,
code_verifier,
client_id: settings["acl.oidc.client.id"],
redirect_uri:
settings["acl.oidc.redirect.uri"] ||
window.location.origin + window.location.pathname,
})
const tokenResponse = await response.json()
setAuthToken(tokenResponse, settings)
} catch (e) {
throw e
}
} else if (oauth2Error.error) {
// User has just been redirected back from the OAuth2 provider and there is an error
const previousPrompt = getValue(StoreKey.OAUTH_PROMPT)
removeValue(StoreKey.OAUTH_PROMPT)
if (previousPrompt === "none") {
// If we requested authorization code silently (prompt=none), it could be that the user
// does not have an active SSO session, so let's send the user to re-authenticate (prompt=login)
redirectToAuthorizationUrl()
} else {
uiAuthLogin()
// If the error is not in response for a silent authorization code request, display the error
setErrorMessage(oauth2Error.error + ": " + oauth2Error.error_description)
dispatch({ view: View.error })
}
} else if (ssoUsername) {
// We should try to request a token silently
redirectToAuthorizationUrl("none")
} else {
// Stop loading and display the login state
uiAuthLogin()
}
} else if (settings["acl.basic.auth.realm.enabled"]) {
await basicAuthLogin()
Expand Down Expand Up @@ -259,21 +254,23 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
}
}

const redirectToAuthorizationUrl = (loginWithDifferentAccount?: boolean) => {
const redirectToAuthorizationUrl = (prompt?: "login" | "none") => {
const state = generateState(settings)
const code_verifier = generateCodeVerifier(settings)
const code_challenge = generateCodeChallenge(code_verifier)
setValue(StoreKey.OAUTH_PROMPT, prompt ?? "")
window.location.href = getAuthorisationURL({
settings,
code_challenge,
state,
loginWithDifferentAccount,
prompt,
redirect_uri: settings["acl.oidc.redirect.uri"] || window.location.href,
})
}

const logout = (promptForLogin?: boolean) => {
removeValue(StoreKey.AUTH_PAYLOAD)
authPayloadHolder.clearAuthPayload()
removeValue(StoreKey.OAUTH_PROMPT)
removeValue(StoreKey.REST_TOKEN)
removeValue(StoreKey.BASIC_AUTH_HEADER)
if (promptForLogin && settings["acl.oidc.client.id"]) {
Expand Down Expand Up @@ -332,7 +329,9 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
),
[View.login]: () => (
<Login
onOAuthLogin={redirectToAuthorizationUrl}
onOAuthLogin={(loginWithDifferentAccount) => {
redirectToAuthorizationUrl(loginWithDifferentAccount ? "login" : undefined)
}}
onBasicAuthSuccess={() => {
dispatch({ view: View.ready })
}}
Expand Down
Loading