Compare commits

...

3 Commits

Author SHA1 Message Date
Oisin Coveney
76fda5e291 do another thing with saml and auth flow 2025-04-16 15:54:08 +01:00
Oisin Coveney
d21c94c62e Popup PoC 2025-04-11 14:22:36 +01:00
Oisin Coveney
0a5a2f1195 Return token on SAML 2025-04-10 18:11:15 +01:00
7 changed files with 293 additions and 54 deletions

View File

@@ -129,20 +129,35 @@
;; Initial call that will result in a redirect to the IDP along with information about how the IDP can authenticate
;; and redirect them back to us
[req]
(println "SSO GET" req)
(premium-features/assert-has-feature :sso-saml (tru "SAML-based authentication"))
(check-saml-enabled)
(let [redirect (get-in req [:params :redirect])
token-requested? (= "true" (get-in req [:params :token]))
popup-requested? (= "true" (get-in req [:params :popup]))
redirect-url (if (nil? redirect)
(do
(log/warn "Warning: expected `redirect` param, but none is present")
(public-settings/site-url))
(if (sso-utils/relative-uri? redirect)
(str (public-settings/site-url) redirect)
redirect))]
redirect))
;; Add token parameter if requested
redirect-with-token (if token-requested?
(str redirect-url
(if (.contains redirect-url "?") "&" "?")
"token=true")
redirect-url)
;; Add popup parameter if requested
redirect-with-params (if popup-requested?
(str redirect-with-token
(if (.contains redirect-with-token "?") "&" "?")
"popup=true")
redirect-with-token)]
(sso-utils/check-sso-redirect redirect-url)
(try
(let [idp-url (sso-settings/saml-identity-provider-uri)
relay-state (u/encode-base64 redirect-url)]
relay-state (u/encode-base64 redirect-with-params)]
(saml/idp-redirect-response {:request-id (str "id-" (random-uuid))
:sp-name (sso-settings/saml-application-name)
:issuer (sso-settings/saml-application-name)
@@ -185,49 +200,140 @@
;; Does the verification of the IDP's response and 'logs the user in'. The attributes are available in the response:
;; `(get-in saml-info [:assertions :attrs])
[{:keys [params], :as request}]
(println "SSO POST" params request)
(premium-features/assert-has-feature :sso-saml (tru "SAML-based authentication"))
(check-saml-enabled)
(let [continue-url (u/ignore-exceptions
(when-let [s (some-> (:RelayState params) u/decode-base64)]
(when-not (str/blank? s)
s)))]
(sso-utils/check-sso-redirect continue-url)
s)))
;; Check for token param in the relay state/continue URL
token-requested? (and continue-url
(re-find #"[?&]token=true" continue-url))
popup-requested? (and continue-url
(re-find #"[?&]popup=true" continue-url))
;; Clean the continue URL by removing our token parameter if present
clean-continue-url (-> continue-url
(cond-> token-requested? (str/replace #"[?&]token=true(&|$)" "$1"))
(cond-> popup-requested? (str/replace #"[?&]popup=true(&|$)" "$1")))]
(sso-utils/check-sso-redirect clean-continue-url)
(try
(let [saml-response (saml/validate-response request
{:idp-cert (idp-cert)
:sp-private-key (sp-cert-keystore-details)
:acs-url (acs-url)
;; remove :in-response-to validation since we're not
;; tracking that in metabase
:response-validators [:issuer
:signature
:require-authenticated]
:assertion-validators [:signature
:recipient
:not-on-or-after
:not-before
:address
:issuer]
:issuer (sso-settings/saml-identity-provider-issuer)})
{:idp-cert (idp-cert)
:sp-private-key (sp-cert-keystore-details)
:acs-url (acs-url)
:response-validators [:issuer
:signature
:require-authenticated]
:assertion-validators [:signature
:recipient
:not-on-or-after
:not-before
:address
:issuer]
:issuer (sso-settings/saml-identity-provider-issuer)})
attrs (saml-response->attributes saml-response)
email (get attrs (sso-settings/saml-attribute-email))
first-name (get attrs (sso-settings/saml-attribute-firstname))
last-name (get attrs (sso-settings/saml-attribute-lastname))
groups (get attrs (sso-settings/saml-attribute-group))
session (fetch-or-create-user!
{:first-name first-name
:last-name last-name
:email email
:group-names groups
:user-attributes attrs
:device-info (request/device-info request)})
response (response/redirect (or continue-url (public-settings/site-url)))]
(request/set-session-cookies request response session (t/zoned-date-time (t/zone-id "GMT"))))
{:first-name first-name
:last-name last-name
:email email
:group-names groups
:user-attributes attrs
:device-info (request/device-info request)})]
;; Check if token was requested
;; Add this to your sso.i/sso-post :saml method
;; inside the try block, after you've created the session
(cond
;; Case 1: Both token and popup are requested
(and token-requested? popup-requested?)
(let [;; Current time in seconds since epoch
current-time (quot (System/currentTimeMillis) 1000)
;; Expiration time - 24 hours from now (86400 seconds)
expiration-time (+ current-time 86400)]
{:status 200
:headers {"Content-Type" "text/html"}
:body (str "<!DOCTYPE html>
<html>
<head>
<title>Authentication Complete</title>
</head>
<body>
<script>
// The authentication data in the required shape
var authData = {
id: \"" (:key session) "\",
exp: " expiration-time ",
iat: " current-time ",
status: \"ok\"
};
// Store the token in local storage
localStorage.setItem('metabaseAuthToken', JSON.stringify(authData));
// Redirect back to the client URL
window.location.href = '" clean-continue-url "';
</script>
</body>
</html>")})
;; Case 2: Only token is requested (no popup)
;; Case 2: Only token is requested (no popup)
token-requested?
(let [;; Current time in seconds since epoch
current-time (quot (System/currentTimeMillis) 1000)
;; Expiration time - 24 hours from now (86400 seconds)
expiration-time (+ current-time 86400)
;; Get the origin parameter for postMessage targeting
origin (get-in request [:params :origin])]
{:status 200
:headers {"Content-Type" "text/html"}
:body (str "<!DOCTYPE html>
<html>
<head>
<title>Authentication Complete</title>
<script>
// The authentication data in the required shape
const authData = {
id: \"" (:key session) "\",
exp: " expiration-time ",
iat: " current-time ",
status: \"ok\"
};
// Send message to opening window with specific origin
if (window.opener) {
window.opener.postMessage({
type: 'SAML_AUTH_COMPLETE',
authData: authData
}, '" (or origin "*") "');
// Auto-close this window immediately to minimize visibility
window.close();
} else {
// If there's no opener (direct navigation), redirect
window.location.href = '" clean-continue-url "';
}
</script>
</head>
<body style=\"background-color: transparent; margin: 0; padding: 0;\">
<!-- Minimal visible content -->
</body>
</html>")})
;; Case 3: Default case - neither token nor popup requested (or only popup but no token)
:else
(let [response (response/redirect (or clean-continue-url (public-settings/site-url)))]
(request/set-session-cookies request response session (t/zoned-date-time (t/zone-id "GMT"))))))
(catch Throwable e
(log/error e "SAML response validation failed")
(throw (ex-info (tru "Unable to log in: SAML response validation failed")
{:status-code 401}
e))))))
{:status-code 401}
e))))))
(defmethod sso.i/sso-handle-slo :saml
[{:keys [cookies] :as req}]

View File

@@ -6,6 +6,7 @@ import { SdkThemeProvider } from "embedding-sdk/components/private/SdkThemeProvi
import { useInitData } from "embedding-sdk/hooks";
import { getSdkStore } from "embedding-sdk/store";
import {
setAuthInterface,
setErrorComponent,
setEventHandlers,
setLoaderComponent,
@@ -109,6 +110,11 @@ export const MetabaseProviderInternal = ({
store.dispatch(setMetabaseClientUrl(authConfig.metabaseInstanceUrl));
}, [store, authConfig.metabaseInstanceUrl]);
useEffect(() => {
console.log(authConfig.authInterface)
store.dispatch(setAuthInterface(authConfig.authInterface));
}, [store, authConfig.authInterface]);
return (
<SdkContextProvider>
<EmotionCacheProvider>

View File

@@ -3,7 +3,6 @@ import * as Yup from "yup";
import type {
MetabaseAuthConfig,
MetabaseEmbeddingSessionToken,
MetabaseFetchRequestTokenFn,
} from "embedding-sdk";
import { getEmbeddingSdkVersion } from "embedding-sdk/config";
import { getIsLocalhost } from "embedding-sdk/lib/is-localhost";
@@ -17,7 +16,7 @@ import { refreshCurrentUser } from "metabase/redux/user";
import type { Settings } from "metabase-types/api";
import { getOrRefreshSession } from "./reducer";
import { getFetchRefreshTokenFn } from "./selectors";
import { getAuthInterface, getFetchRefreshTokenFn } from "./selectors";
export const initAuth = createAsyncThunk(
"sdk/token/INIT_AUTH",
@@ -97,7 +96,9 @@ export const refreshTokenAsync = createAsyncThunk(
getState() as SdkStoreState,
);
const getRefreshToken = customGetRefreshToken ?? defaultGetRefreshTokenFn;
const authInterface = getAuthInterface(getState() as SdkStoreState)
const getRefreshToken = customGetRefreshToken ?? getRefreshFunction({ authInterface });
// # How does the error handling work?
// This is an async thunk, thunks _by design_ can fail and no error will be shown on the console (it's the reducer that should handle the reject action)
@@ -152,31 +153,142 @@ const safeStringify = (value: unknown) => {
}
};
/**
* The default implementation of the function to get the refresh token.
* Only supports sessions by default.
*/
export const defaultGetRefreshTokenFn: MetabaseFetchRequestTokenFn = async (
url,
) => {
const response = await fetch(url, {
method: "GET",
credentials: "include",
});
export const getRefreshFunction = ({ authInterface }: Pick<SdkStoreState['sdk'], "authInterface">) => {
console.log({ authInterface })
if (authInterface === "popup") {
return popupRefreshTokenFn
}
if (!response.ok) {
throw new Error(
`Failed to fetch the session, HTTP status: ${response.status}`,
return silentIframeRefreshFunction
}
export const silentIframeRefreshFunction = async (url: string): Promise<MetabaseEmbeddingSessionToken> => {
// This function is only for token refreshes where the user is already authenticated
return new Promise((resolve, reject) => {
// Add parameters to indicate this is a token refresh
const tokenUrl = url.includes("?")
? `${url}&token=true&origin=${encodeURIComponent(window.location.origin)}&refresh=true`
: `${url}?token=true&origin=${encodeURIComponent(window.location.origin)}&refresh=true`;
// Open a minimal popup window for token refresh
// Make it tiny and position in corner to minimize visibility
const width = 1;
const height = 1;
const left = window.screen.width - width;
const top = 0;
const authWindow = window.open(
tokenUrl,
"samlRefresh",
`width=${width},height=${height},left=${left},top=${top},resizable=no,status=no,location=no,toolbar=no,menubar=no`
);
}
const asText = await response.text();
if (!authWindow) {
reject(new Error("Auth window was blocked. Please allow popups for this site."));
return;
}
try {
return JSON.parse(asText);
} catch (ex) {
return asText;
}
// Listen for message from auth window
const messageHandler = (event: MessageEvent) => {
// Only accept messages with the correct type
if (event.data?.type === 'SAML_AUTH_COMPLETE' && event.data.authData) {
// Remove event listener
window.removeEventListener('message', messageHandler);
// Make sure window is closed
if (!authWindow.closed) {
authWindow.close();
}
// Resolve with auth data
resolve(event.data.authData);
}
};
window.addEventListener('message', messageHandler);
// Set timeout to prevent hanging indefinitely
const timeoutId = setTimeout(() => {
window.removeEventListener('message', messageHandler);
if (!authWindow.closed) {
authWindow.close();
}
reject(new Error("Authentication timed out"));
}, 30000); // 30 second timeout
});
};
export const popupRefreshTokenFn = async (url: string) => {
// For SAML authentication, use a popup approach
return new Promise((resolve, reject) => {
// Define popup dimensions
const width = 600;
const height = 700;
const left = window.screenX + (window.outerWidth - width) / 2;
const top = window.screenY + (window.outerHeight - height) / 2;
// Just use the token=true parameter to get the HTML response
const tokenUrl = url.includes("?")
? `${url}&token=true&popup=true`
: `${url}?token=true&popup=true`;
// Open popup
const popup = window.open(
tokenUrl,
"samlLoginPopup",
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes,status=yes`,
);
if (!popup) {
reject(new Error("Popup blocked. Please allow popups for this site."));
return;
}
// Listen for message from the popup
const messageHandler = (event: MessageEvent) => {
// Security check - only accept messages from our popup
if (event.source !== popup) {
return;
}
// Check for our specific message type
if (event.data && event.data.type === "saml_auth_complete") {
// Remove the event listener
window.removeEventListener("message", messageHandler);
// Close the popup
popup.close();
// Resolve with the auth data
resolve(event.data.payload);
}
};
window.addEventListener("message", messageHandler);
// Handle popup closing
const checkClosed = setInterval(() => {
if (popup.closed) {
clearInterval(checkClosed);
window.removeEventListener("message", messageHandler);
reject(new Error("Authentication was canceled"));
}
}, 1000);
// Set timeout to prevent hanging indefinitely
setTimeout(() => {
clearInterval(checkClosed);
window.removeEventListener("message", messageHandler);
if (!popup.closed) {
popup.close();
}
reject(new Error("Authentication timed out"));
}, 300000); // 5 minutes timeout
});
};
const sessionSchema = Yup.object({

View File

@@ -36,7 +36,9 @@ const GET_OR_REFRESH_SESSION = "sdk/token/GET_OR_REFRESH_SESSION";
export const getOrRefreshSession = createAsyncThunk(
GET_OR_REFRESH_SESSION,
async (url: string, { dispatch, getState }) => {
const state = getSessionTokenState(getState() as SdkStoreState);
const authToken = localStorage.getItem("metabaseAuthToken");
console.log({ authToken })
const state = authToken ?? getSessionTokenState(getState() as SdkStoreState);
const token = state?.token;
const isTokenValid = token && token.exp * 1000 >= Date.now();
@@ -64,6 +66,8 @@ export const setUsageProblem = createAction<SdkUsageProblem | null>(
SET_USAGE_PROBLEM,
);
export const setAuthInterface = createAction<SdkState['authInterface']>("sdk/SET_AUTH_INTERFACE")
const initialState: SdkState = {
metabaseInstanceUrl: "",
token: {
@@ -78,6 +82,7 @@ const initialState: SdkState = {
loaderComponent: null,
errorComponent: null,
fetchRefreshTokenFn: null,
authInterface: "redirect"
};
export const sdk = createReducer(initialState, (builder) => {
@@ -134,6 +139,10 @@ export const sdk = createReducer(initialState, (builder) => {
state.metabaseInstanceUrl = action.payload;
});
builder.addCase(setAuthInterface, (state, action) => {
state.authInterface = action.payload;
});
builder.addCase(setFetchRefreshTokenFn, (state, action) => {
state.fetchRefreshTokenFn = action.payload;
});

View File

@@ -27,5 +27,8 @@ export const getErrorComponent = (state: SdkStoreState) =>
export const getMetabaseInstanceUrl = (state: SdkStoreState) =>
state.sdk?.metabaseInstanceUrl;
export const getAuthInterface = (state: SdkStoreState) =>
state.sdk?.authInterface;
export const getFetchRefreshTokenFn = (state: SdkStoreState) =>
state.sdk.fetchRefreshTokenFn;

View File

@@ -58,6 +58,7 @@ export type SdkState = {
loaderComponent: null | (() => JSX.Element);
errorComponent: null | SdkErrorComponent;
fetchRefreshTokenFn: null | MetabaseFetchRequestTokenFn;
authInterface: MetabaseAuthConfig['authInterface'];
};
export interface SdkStoreState extends State {

View File

@@ -13,11 +13,13 @@ type BaseMetabaseAuthConfig = {
export type MetabaseAuthConfigWithProvider = BaseMetabaseAuthConfig & {
authProviderUri: string;
apiKey?: never;
authInterface: "popup" | "redirect"
};
export type MetabaseAuthConfigWithApiKey = BaseMetabaseAuthConfig & {
apiKey: string;
authProviderUri?: never;
authInterface?: never;
};
export type MetabaseAuthConfig =