Manually managing token expire in OSDK apps

I know the generated OSDK client in Foundry handles refreshing the token automatically, but we can not use these generated SDK clients. We have a common core of code which swaps dev console apps using ENV vars.

It is unclear how to manage tokens for OSDK apps. Below is the typescript interface:

export interface Token {
  readonly access_token: string;
  readonly expires_in: number;
  readonly refresh_token?: string;
  readonly expires_at: number;
}

export interface BaseOauthClient {
  (): Promise<string>;
  getTokenOrUndefined: () => string | undefined;
  signIn: () => Promise<Token>;
  signOut: () => Promise<void>;
}

It’s obvious that getTokenOrUndefined returns a token or undefined. But it is not clear if the method will handle checking if the token is still valid (I assume no, but just want to confirm). Nor is it clear how to use the refresh token to update an expired token. How should I manually manage tokens in my OSDK apps?

1 Like

I think I found the answer here, which depends on the type of client you are using (we use both).

interface ConfidentialOauthClient extends BaseOauthClient<"signIn" | "signOut"> {
}

/**
 * @param client_id
 * @param client_secret
 * @param url the base url of your foundry server
 * @param scopes
 * @param fetchFn
 * @param ctxPath
 * @returns which can be used as a token provider
 */
declare function createConfidentialOauthClient(client_id: string, client_secret: string, url: string, scopes?: string[], fetchFn?: typeof globalThis.fetch, ctxPath?: string): ConfidentialOauthClient;

interface PublicOauthClient extends BaseOauthClient<"signIn" | "signOut" | "refresh"> {
    refresh: () => Promise<Token | undefined>;
}

export interface BaseOauthClient<K extends keyof Events & string> {
	(): Promise<string>;
	getTokenOrUndefined: () => string | undefined;
	signIn: () => Promise<Token>;
	signOut: () => Promise<void>;
	addEventListener: <T extends K>(type: T, listener: ((evt: Events[T]) => void) | null, options?: boolean | AddEventListenerOptions) => void;
	removeEventListener: <T extends K>(type: T, callback: ((evt: Events[T]) => void) | null, options?: EventListenerOptions | boolean) => void;
}

In the case of the public clients

import { createPublicOauthClient } from '@osdk/oauth';

You can call the refresh method to obtain a new token, but you will have to figure out if you still have a valid token by storing the expiration whenever a token is received. However, I did see that when you create a client, you can pass a token provider function:

export declare const createClient: (baseUrl: string, ontologyRid: string | Promise<string>, tokenProvider: () => Promise<string>, options?: {
	logger?: Logger
} | undefined, fetchFn?: typeof fetch | undefined) => Client;

It’s clear it could be used as a token vending machine but it’s unclear how it’s connected to the client. Anyway I will leave this here is case I missed anything or in case other uses hav ea similar question

Here is a basic flow for the confidential client. Appreciate any feedback if anyone sees issues with it

import { createClient } from '@osdk/client';
import { User, Users } from '@osdk/foundry.admin';
import { createConfidentialOauthClient } from '@osdk/oauth';
import { FoundryClient, Token } from '@codestrap/developer-foundations-types';

// this is a utility method to manage usage of the Foundry Client and ensure we only get a singleton
// files in the palantir services package can't use the container to get the foundry client, nor should they really
// They are in the same package
let client: FoundryClient | undefined = undefined;

export function getFoundryClient(): FoundryClient {
  if (!client) {
    client = createFoundryClient();
  }

  return client;
}

function createFoundryClient(): FoundryClient {
  // log ENV vars
  console.log('Environment variable keys:');
  Object.keys(process.env).forEach((key) => {
    if (key.indexOf('FOUNDRY') >= 0 || key.indexOf('OSDK') >= 0) {
      console.log(`- ${key}`);
    }
  });

  if (!process.env['OSDK_CLIENT_ID'] || !process.env['OSDK_CLIENT_SECRET']) {
    throw new Error(
      'missing required env vars: OSDK_CLIENT_ID, OSDK_CLIENT_SECRET'
    );
  }

  // setup the OSDK
  const clientId: string = process.env['OSDK_CLIENT_ID']!;
  const url: string = process.env['FOUNDRY_STACK_URL']!;
  const ontologyRid: string = process.env['ONTOLOGY_RID']!;
  const clientSecret: string = process.env['OSDK_CLIENT_SECRET']!;
  const scopes: string[] = [
    'api:use-ontologies-read',
    'api:use-ontologies-write',
    'api:use-admin-read',
    'api:use-connectivity-read',
    'api:use-connectivity-execute',
    'api:use-orchestration-read',
    'api:use-mediasets-read',
    'api:use-mediasets-write',
  ];

  const auth = createConfidentialOauthClient(
    clientId,
    clientSecret,
    url,
    scopes
  );

  const client = createClient(url, ontologyRid, auth);

  const getUser = async () => {
    const user: User = await Users.getCurrent(client);

    return user;
  };

  let token: Token | undefined;
  let tokenExpire: Date | undefined;
  let pendingRequest: Promise<Token> | undefined;

  auth.addEventListener('signIn', (evt) => {
    token = evt.detail; // Token
    tokenExpire = new Date(token.expires_at);
  });

  auth.addEventListener('signOut', (evt) => {
    token = undefined;
    tokenExpire = undefined;
  });

  const getToken = async function () {

    if (token && tokenExpire) {
      // add 60 seconds to account for processing time
      const skew = tokenExpire.getTime() + 60000;

      if (skew > new Date().getTime()) {
        return token.access_token;
      }
    }
    // avoid duplicate signin requests
    if (!pendingRequest) {
      pendingRequest = auth.signIn();
    }

    try {
      token = await pendingRequest;
      const accessToken = token.access_token;

      pendingRequest = undefined;

      return accessToken;
    } catch (e) {
      console.log(e);

      throw (e);
    } finally {
      pendingRequest = undefined;
    }

  }

  return { auth, ontologyRid, url, client, getUser, getToken };
}


If you are using the publish client to support user login the only change would be:

auth.addEventListener('refresh', (evt) => {
    token = evt.detail; // Token
    tokenExpire = new Date(token.expires_at);
  });

The server-side flow worked fine, but I am having an issue with the client-side OAuth flow. Here is my client:

import { createClient } from '@osdk/client';
import { User, Users } from '@osdk/foundry.admin';
import { createPublicOauthClient } from '@osdk/oauth';
import { FoundryClient, Token } from '@codestrap/developer-foundations-types';

// this is a utility method to manage usage of the Foundry Client and ensure we only get a singleton
// files in the palantir services package can't use the container to get the foundry client, nor should they really
// They are in the same package
let client: FoundryClient | undefined = undefined;

export function getFoundryClient(): FoundryClient {
  if (!client) {
    client = createFoundryClient();
  }

  return client;
}

function createFoundryClient(): FoundryClient {
  // log ENV vars
  console.log('Environment variable keys:');
  Object.keys(process.env).forEach((key) => {
    if (key.indexOf('NEXT_PUBLIC_') >= 0) {
      console.log(`- ${key}`);
    }
  });

  if (!process.env['NEXT_PUBLIC_OSDK_CLIENT_ID']
    || !process.env['NEXT_PUBLIC_REDIRECT_URL']
    || !process.env['NEXT_PUBLIC_FOUNDRY_STACK_URL']
    || !process.env['NEXT_PUBLIC_ONTOLOGY_RID']
  ) {
    throw new Error(
      'missing required env vars: NEXT_PUBLIC_OSDK_CLIENT_ID, NEXT_PUBLIC_REDIRECT_URL, NEXT_PUBLIC_FOUNDRY_STACK_URL, NEXT_PUBLIC_ONTOLOGY_RID'
    );
  }

  // setup the OSDK
  const clientId: string = process.env['NEXT_PUBLIC_OSDK_CLIENT_ID']!;
  const url: string = process.env['NEXT_PUBLIC_FOUNDRY_STACK_URL']!;
  const ontologyRid: string = process.env['NEXT_PUBLIC_ONTOLOGY_RID']!;
  const redirectUrl: string = process.env['NEXT_PUBLIC_REDIRECT_URL']!;
  const scopes: string[] = [
    'api:use-ontologies-read',
    'api:use-ontologies-write',
    'api:use-admin-read',
    'api:use-connectivity-read',
    'api:use-connectivity-execute',
    'api:use-orchestration-read',
    'api:use-mediasets-read',
    'api:use-mediasets-write'
  ];

  const auth = createPublicOauthClient(clientId, url, redirectUrl, true, undefined, window.location.toString(), scopes);
  const client = createClient(url, ontologyRid, auth);

  const getUser = async () => {
    const user: User = await Users.getCurrent(client);

    return user;
  };

  let token: Token | undefined;
  let tokenExpire: Date | undefined;
  let pendingRequest: Promise<Token> | undefined;

  auth.addEventListener('signIn', (evt) => {
    token = evt.detail; // Token
    tokenExpire = new Date(token.expires_at);
  });

  auth.addEventListener('signOut', (evt) => {
    token = undefined;
    tokenExpire = undefined;
  });

  auth.addEventListener('refresh', (evt) => {
    token = evt.detail; // Token
    tokenExpire = new Date(token.expires_at);
  });

  const getToken = async function () {

    if (token && tokenExpire) {
      // add 60 seconds to account for processing time
      const skew = tokenExpire.getTime() + 60000;

      if (skew > new Date().getTime()) {
        return token.access_token;
      }
    }
    // avoid duplicate signin requests
    if (!pendingRequest) {
      pendingRequest = auth.signIn();
    }

    try {
      token = await pendingRequest;

      return token.access_token;
    } catch (e) {
      console.log(e);

      throw (e);
    } finally {
      pendingRequest = undefined;
    }

  }

  return { auth, ontologyRid, url, client, getUser, getToken };
}

I use the client in my React applications as follows:

const { client, getUser, getToken } = foundryClientFactory(SupportedFoundryClients.PUBLIC, undefined);
const userResource = createUserResource(client);
const tokenResource = createTokenResource(getToken);

//resource
// userResource.ts
/** Minimal resource wrapper for Suspense */
function createResource<T>(promise: Promise<T>) {
    let status: "pending" | "success" | "error" = "pending";
    let result: T;
    let suspender = promise.then(
        (r) => {
            status = "success";
            result = r;
        },
        (e) => {
            status = "error";
            result = e;
        }
    );

    return {
        read(): T {
            if (status === "pending") throw suspender;
            if (status === "error") throw result;
            return result;
        },
    };
}

export function createTokenResource(getToken: () => Promise<string>) {

    return createResource<string>(getToken());
}

I am able to trigger the OAuth flow to log in to Foundry and accept the permissions. However, on redirect, I get the following error:

curl 'https://<MY_STACK_URL>/multipass/api/oauth2/token' \
  -H 'accept: application/json' \
  -H 'accept-language: en-US,en;q=0.9' \
  -H 'cache-control: no-cache' \
  -H 'content-type: application/x-www-form-urlencoded;charset=UTF-8' \
  -H 'origin: http://localhost:4200' \
  -H 'pragma: no-cache' \
  -H 'priority: u=1, i' \
  -H 'referer: http://localhost:4200/' \
  -H 'sec-ch-ua: "Chromium";v="128", "Not;A=Brand";v="24", "Google Chrome";v="128"' \
  -H 'sec-ch-ua-mobile: ?0' \
  -H 'sec-ch-ua-platform: "macOS"' \
  -H 'sec-fetch-dest: empty' \
  -H 'sec-fetch-mode: cors' \
  -H 'sec-fetch-site: cross-site' \
  -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36' \
  --data-raw 'redirect_uri=http%3A%2F%2Flocalhost%3A4200%2Fvickie&code_verifier=<RETURNED_VERIFIER>&code=<RETURNED_CODE>&grant_type=authorization_code&client_id=<MY_CLIENT_ID>'


{"error":"invalid_request","error_description":"Missing credentials"}

I’m pretty sure I am handling the flow incorrectly. I thought maybe I should be checking `auth.getTokenOrUndefined()` to get the retrieved token, but it is always undefined. How can I handle the callback correctly and retrieve the token?

I tried serving my app from https instead of http, thinking that the redirect URL might be triggering the error, as it was originally http. But I get the same error, even after repeating the login flow.

I also read https://www.palantir.com/docs/foundry/platform-security-third-party/writing-oauth2-clients and based on the documentation everything is correct. The state and code returned by the auth flow were correctly sent in the request to /multipass/api/oauth2/token. There has to be something in the setup of my app or something that is triggering this. My OSDK app is setup for both auth flows. Not sure if they might be the issue.

I don’t think multipass supports having a TPA with client credentials (client_id, Client_secret = Confidential Client) and auth code grant (only client_id = public client) at the same time.

That’s based of my experience. Not sure if Developer Console UI is showing that correctly.

Maybe try with a new Public OSDK app (which only has a client_id).

1 Like

Switching to another SDK client that was configured only for Authorization code grant flow fixed the issue. I wish Palantir would stop wasting developer cycles by clearly documenting how their platform works, and to not present options to developers that will footgun them at midnight when they should be in bed sleeping.

Hey @CodeStrap - Thanks for flagging this. If your TPA is configured to be a confidential client, then Multipass will always authenticate the client (by verifying client ID and secret) when requesting all grant types. The Missing credentials indicates that a client secret wasn’t provided in the token request. For public clients, we can’t authenticate the token requests (since there’s no secret).

I’ll follow up with the team that owns the TPA hosting infrastructure to make sure we don’t let you configure a confidential client to be hosted in Foundry. We don’t have a way to securely store the client secret, so deploying an application like this won’t work.

1 Like

Thank you! Just a heads up we are not using Foundry web hosting and actaully need both secure and public clients supported in the same OSDK app. We have some automated workflows that are triggered in response to events as well as some API routes that require service accounts to function. Ideally, I should not have to manage separate OSDK apps to support both workflows. If the UI in the OSDK could be updated to reflect the fact that the options are mutually exclusive (for now) that would be great. It will save other developers in the future IMO.

Hey @CodeStrap ,

So sorry to be keeping you up late with bad UI!

Over the next few months we’re revamping dev console to better explain authentication. One thing we’ll make clear is that most of the time you’ll choose either user log-in (public client, authorization code grant) or app log-in (confidential client, client credentials grant) but not both. In advanced cases, your app can use both authentication types by either:

  1. Using two developer console applications (since an application is 1:1 with an authentication client) - this is the right approach when you expect authentication via both a client in the frontend and a client in the backend. Long term, we plan to take steps to make it easier for multiple applications to use the same SDKs, making this workflow far easier to maintain.

  2. Using two authentication grant types via a single confidential client – This makes sense for cases where all authentication happens hidden from users but certain actions are taken by a user and other actions are taken by the application itself. As part of our UI revamp, we will continue to provide this option, but label it as advanced since our out-of-the-box confidential client doesn’t support authorization code grant flow. Given the complexity and variability of setups here, we ask that users implement this flow themselves.

Both these options work today. In your case, you likely want option 2 which would require you to extend our confidential client to support authorization code grant flow. If any of the above doesn’t make sense or you continue to hit footguns like these please let us know.

1 Like

Hi @ehirsch ,

while you rework this could you please, please decouple the creation of developer console apps (with authorization code grant) from TPA creation permissions?

Without this we are not able to open up developer console to a broader audience on our stack since the risks of arbitrary creation of client credentials TPAs and corresponding service users are too high.

Thank you!

2 Likes

This is something we’re tracking, ideally before EOY. I’ll keep you posted!

2 Likes

This topic was automatically closed 91 days after the last reply. New replies are no longer allowed.