I want a user to be able to login in Foundry, while outside of Foundry (e.g. in my own React app/portal in my company’s network).
How can this user login, without seeing it much/at all Foundry (no dropdown, popup, visible login flow, etc.)
I want a user to be able to login in Foundry, while outside of Foundry (e.g. in my own React app/portal in my company’s network).
How can this user login, without seeing it much/at all Foundry (no dropdown, popup, visible login flow, etc.)
You need a few things in order for the users to be able to login without popup/action from their side:
_realm parameter to the login page, so that the user doesn’t have to select manually which IDP integration to use. See below.This screen is the OAuth application consent:
This can be “turned off” by allowing a whole organization access to a given application in Control Panel:
The realm parameter can be passed to
https://foundry.example.com/multipass/api/oauth2/authorize?_realm=127b1e4a-5678-498a-1234-b20113eeeace
To easily test the flow, you can use this below local webpage:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Foundry OAuth PKCE + curl Tester</title>
<style>
body {
box-sizing: border-box;
max-width: 800px;
margin: 40px auto;
padding: 0 20px 60px;
font-family: system-ui, sans-serif;
line-height: 1.5;
}
label {
display: block;
margin: 16px 0 5px;
font-weight: 600;
}
input {
box-sizing: border-box;
width: 100%;
padding: 9px;
font: inherit;
}
button {
margin: 16px 8px 0 0;
padding: 9px 14px;
font: inherit;
cursor: pointer;
}
pre {
min-height: 50px;
padding: 14px;
overflow-wrap: anywhere;
white-space: pre-wrap;
background: #f4f5f7;
border: 1px solid #d8dce1;
border-radius: 5px;
}
.success {
color: #08783e;
}
.error {
color: #c23030;
}
.warning {
padding: 12px;
border-left: 4px solid #d99b20;
background: #fff7e6;
}
</style>
</head>
<body>
<h1>Foundry OAuth PKCE + curl Tester</h1>
<p>
This page performs OAuth 2.0 Authorization Code with PKCE and
generates an authenticated curl command.
</p>
<label for="foundryUrl">Foundry URL</label>
<input
id="foundryUrl"
type="url"
value="https://REDACTED.com"
/>
<label for="realm">Authentication realm (optional)</label>
<input
id="realm"
placeholder="Realm ID from Control Panel"
/>
<label for="clientId">OAuth client ID</label>
<input
id="clientId"
placeholder="YOUR_FOUNDRY_CLIENT_ID"
autocomplete="off"
/>
<label for="redirectUri">Registered callback URI</label>
<input id="redirectUri" type="url" />
<label for="scopes">OAuth scopes</label>
<input
id="scopes"
value="offline_access api:use-ontologies-read api:use-ontologies-write"
/>
<div>
<button id="loginButton">Log in</button>
<button id="tokenButton">Check/refresh token</button>
<button id="clearButton">Clear local session</button>
</div>
<h2>curl request</h2>
<label for="apiUrl">Foundry API URL or path</label>
<input
id="apiUrl"
value="/api/v2/ontologies"
/>
<div>
<button id="generateButton">Generate curl</button>
<button id="copyButton">Copy curl</button>
</div>
<h2>Status</h2>
<pre id="status">Ready.</pre>
<h2>Generated curl</h2>
<p class="warning">
The generated command contains a live bearer token. Do not share it.
</p>
<pre id="curlOutput">No command generated.</pre>
<script>
const STORAGE = {
config: "foundry.config",
state: "foundry.state",
verifier: "foundry.verifier",
tokens: "foundry.tokens",
};
const elements = {
foundryUrl: document.querySelector("#foundryUrl"),
realm: document.querySelector("#realm"),
clientId: document.querySelector("#clientId"),
redirectUri: document.querySelector("#redirectUri"),
scopes: document.querySelector("#scopes"),
apiUrl: document.querySelector("#apiUrl"),
status: document.querySelector("#status"),
curlOutput: document.querySelector("#curlOutput"),
};
elements.redirectUri.value =
window.location.origin + window.location.pathname;
function setStatus(message, type = "") {
elements.status.textContent = message;
elements.status.className = type;
}
function getConfig() {
const config = {
foundryUrl:
elements.foundryUrl.value.trim().replace(/\/+$/, ""),
realm: elements.realm.value.trim(),
clientId: elements.clientId.value.trim(),
redirectUri: elements.redirectUri.value.trim(),
scopes: elements.scopes.value.trim(),
};
if (
!config.foundryUrl ||
!config.clientId ||
!config.redirectUri ||
!config.scopes
) {
throw new Error("Complete all required OAuth fields.");
}
new URL(config.foundryUrl);
new URL(config.redirectUri);
return config;
}
function saveConfig(config) {
sessionStorage.setItem(
STORAGE.config,
JSON.stringify(config),
);
}
function loadConfig() {
const serialized =
sessionStorage.getItem(STORAGE.config);
if (!serialized) {
return;
}
try {
const config = JSON.parse(serialized);
elements.foundryUrl.value =
config.foundryUrl ?? elements.foundryUrl.value;
elements.realm.value =
config.realm ?? "";
elements.clientId.value =
config.clientId ?? "";
elements.redirectUri.value =
config.redirectUri ?? elements.redirectUri.value;
elements.scopes.value =
config.scopes ?? elements.scopes.value;
} catch {
sessionStorage.removeItem(STORAGE.config);
}
}
function base64Url(bytes) {
return btoa(String.fromCharCode(...bytes))
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "");
}
function randomValue() {
return base64Url(
crypto.getRandomValues(new Uint8Array(32)),
);
}
async function sha256(value) {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(value),
);
return new Uint8Array(digest);
}
function storeTokens(tokens) {
const stored = {
...tokens,
expires_at:
Date.now() + Number(tokens.expires_in) * 1000,
};
sessionStorage.setItem(
STORAGE.tokens,
JSON.stringify(stored),
);
return stored;
}
function describeTokens(tokens) {
return [
"Authenticated.",
`Token type: ${tokens.token_type ?? "unknown"}`,
`Expires in: ${tokens.expires_in ?? "unknown"} seconds`,
`Expires at: ${
tokens.expires_at
? new Date(tokens.expires_at).toISOString()
: "unknown"
}`,
`Refresh token available: ${
tokens.refresh_token ? "yes" : "no"
}`,
].join("\n");
}
async function tokenRequest(config, parameters) {
const response = await fetch(
`${config.foundryUrl}/multipass/api/oauth2/token`,
{
method: "POST",
headers: {
"Content-Type":
"application/x-www-form-urlencoded",
},
body: new URLSearchParams(parameters),
},
);
const body = await response.text();
if (!response.ok) {
throw new Error(
`Token request failed (${response.status}): ${body}`,
);
}
try {
return JSON.parse(body);
} catch {
throw new Error(
`Token endpoint returned invalid JSON: ${body}`,
);
}
}
async function beginFoundryLogin() {
const config = getConfig();
const state = randomValue();
const verifier = randomValue();
const challenge = base64Url(
await sha256(verifier),
);
saveConfig(config);
sessionStorage.setItem(STORAGE.state, state);
sessionStorage.setItem(STORAGE.verifier, verifier);
const authorizeUrl = new URL(
"/multipass/api/oauth2/authorize",
config.foundryUrl,
);
const parameters = new URLSearchParams({
response_type: "code",
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: config.scopes,
state,
code_challenge: challenge,
code_challenge_method: "S256",
});
// Realm belongs only on the authorization request.
if (config.realm) {
parameters.set("_realm", config.realm);
}
authorizeUrl.search = parameters.toString();
window.location.assign(authorizeUrl.toString());
}
async function finishFoundryLogin() {
const callback = new URL(window.location.href);
const oauthError =
callback.searchParams.get("error");
if (oauthError) {
const description =
callback.searchParams.get("error_description") ||
oauthError;
throw new Error(
`Authorization failed: ${description}`,
);
}
const code = callback.searchParams.get("code");
const returnedState =
callback.searchParams.get("state");
const expectedState =
sessionStorage.getItem(STORAGE.state);
const verifier =
sessionStorage.getItem(STORAGE.verifier);
if (
!code ||
!returnedState ||
!expectedState ||
!verifier ||
returnedState !== expectedState
) {
throw new Error(
"Invalid OAuth callback or state mismatch.",
);
}
const config = getConfig();
// _realm is intentionally not included here.
const tokens = await tokenRequest(config, {
grant_type: "authorization_code",
client_id: config.clientId,
redirect_uri: config.redirectUri,
code,
code_verifier: verifier,
});
const stored = storeTokens(tokens);
sessionStorage.removeItem(STORAGE.state);
sessionStorage.removeItem(STORAGE.verifier);
history.replaceState(
{},
document.title,
callback.pathname,
);
return stored;
}
async function getFoundryAccessToken() {
const serialized =
sessionStorage.getItem(STORAGE.tokens);
if (!serialized) {
await beginFoundryLogin();
return new Promise(() => {});
}
const tokens = JSON.parse(serialized);
if (
(tokens.expires_at ?? 0) >
Date.now() + 30_000
) {
setStatus(
describeTokens(tokens),
"success",
);
return tokens.access_token;
}
if (!tokens.refresh_token) {
sessionStorage.removeItem(STORAGE.tokens);
await beginFoundryLogin();
return new Promise(() => {});
}
const config = getConfig();
try {
// _realm is also intentionally absent during refresh.
const refreshed = await tokenRequest(config, {
grant_type: "refresh_token",
client_id: config.clientId,
refresh_token: tokens.refresh_token,
});
const stored = storeTokens({
...refreshed,
refresh_token:
refreshed.refresh_token ??
tokens.refresh_token,
});
setStatus(
describeTokens(stored),
"success",
);
return stored.access_token;
} catch (error) {
sessionStorage.removeItem(STORAGE.tokens);
throw new Error(
`${error.message}\nLog in again to obtain a new token.`,
);
}
}
function shellQuote(value) {
return `'${String(value).replaceAll(
"'",
"'\"'\"'",
)}'`;
}
async function generateCurl() {
const config = getConfig();
const apiValue = elements.apiUrl.value.trim();
if (!apiValue) {
throw new Error("Enter an API URL or path.");
}
saveConfig(config);
const target = new URL(
apiValue,
config.foundryUrl,
);
if (
target.origin !==
new URL(config.foundryUrl).origin
) {
throw new Error(
"The API URL must use the configured Foundry host.",
);
}
const accessToken =
await getFoundryAccessToken();
const command = [
"curl --fail-with-body --silent --show-error \\",
` -H ${shellQuote(
`Authorization: Bearer ${accessToken}`,
)} \\`,
` ${shellQuote(target.toString())}`,
].join("\n");
elements.curlOutput.textContent = command;
setStatus(
"curl command generated successfully.",
"success",
);
}
async function copyCurl() {
const command =
elements.curlOutput.textContent;
if (
!command ||
command === "No command generated."
) {
throw new Error(
"Generate a curl command first.",
);
}
await navigator.clipboard.writeText(command);
setStatus(
"curl command copied.",
"success",
);
}
function clearSession() {
Object.values(STORAGE).forEach((key) => {
sessionStorage.removeItem(key);
});
elements.curlOutput.textContent =
"No command generated.";
setStatus("Local OAuth session cleared.");
}
function run(action) {
return async () => {
try {
await action();
} catch (error) {
console.error(error);
setStatus(
error.message ?? String(error),
"error",
);
}
};
}
document
.querySelector("#loginButton")
.addEventListener(
"click",
run(beginFoundryLogin),
);
document
.querySelector("#tokenButton")
.addEventListener(
"click",
run(async () => {
await getFoundryAccessToken();
}),
);
document
.querySelector("#generateButton")
.addEventListener(
"click",
run(generateCurl),
);
document
.querySelector("#copyButton")
.addEventListener(
"click",
run(copyCurl),
);
document
.querySelector("#clearButton")
.addEventListener(
"click",
clearSession,
);
async function initialize() {
loadConfig();
const callback = new URL(window.location.href);
if (
callback.searchParams.has("code") ||
callback.searchParams.has("error")
) {
setStatus("Completing OAuth callback…");
try {
const tokens =
await finishFoundryLogin();
setStatus(
describeTokens(tokens),
"success",
);
} catch (error) {
console.error(error);
setStatus(
error.message ?? String(error),
"error",
);
}
return;
}
const serialized =
sessionStorage.getItem(STORAGE.tokens);
if (serialized) {
try {
setStatus(
describeTokens(JSON.parse(serialized)),
"success",
);
} catch {
sessionStorage.removeItem(STORAGE.tokens);
}
}
}
initialize();
</script>
</body>
</html>
Put the file in a folder, navigate to this folder with your terminal, and start serving it via python3 -m http.server 8000
Access http://localhost:8000/oauth_test.html and you should get:
Fill the configuration, namely from the Developer console application:
You can click “login” to start the OAuth flow, and see what a user would see (Do they see the OSDK approval screen ? Their IDP screen ? Something else ?).
Once logged-in, a token should be retrieved. You can then try to hit /api/v2/ontologies and get a curl generated, which you can then test from your terminal.
You should have a curl that look like:
curl --fail-with-body --silent --show-error \
-H 'Authorization: Bearer ey...REDACTED...Q' \
'https://REDACTED.com/api/v2/ontologies'
Result:
{"data":[{"apiName":"ontology-d2770208-d11e-42d5-8708-67835767f0d6","displayName":"Advisory Ontology","description":" Ontology","rid":"ri.ontology.main.ontology.0bf6a0db-1234-5678-9413-58ee7d3edf3e"}]}
See https://www.palantir.com/docs/foundry/consumer-mode/foundry-consumer-setup/ for more details