Next-Auth (Auth.js) integration with ISC OAuth

:bangbang: Note: The content in this blog is also available in a video format:

Introduction and Background

Next-Auth is a complete open-source authentication solution for Next.js. For those who are unfamiliar with Next.js, it’s a React framework that enhances features such as routing, easy and fast development cycles, and serverless functions.

This article is mainly targeting developers who are interested in using Next-Auth within their Next.js projects to integrate with SailPoint Identity Security Cloud (ISC). It’s also suggested to have some basic knowledge of TypeScript, React, and Next.js. For the moment, Next-Auth is mainly used for the Next.js framework only. Currently, there is progress moving Next-Auth to Auth.js, which is framework independent. While Auth.js is still in beta, here I will use Next-Auth as a case study for ISC OAuth usage. In the future, this will be a similar setup when moving to Auth.js with another framework.

SailPoint ISC defines a set of authentication methods which can be used by 3rd party or custom applications. (Reference: Authentication) In this article, we will go through how to request an access_token with an authorization code and refresh token.

Before we start, here is the diagram which describes the authorization code grant flow:

  1. The user clicks the login link on a web app.
  2. The web app sends an authorization request to ISC in this form:
GET https://{tenant}.login.sailpoint.com/oauth/authorize?client_id={client-id}&response_type=code&redirect_uri={redirect-url}
  1. ISC redirects the user to a login prompt to authenticate to Identity Security Cloud.
  2. The user authenticates to ISC.
  3. Once authentication is successful, ISC issues an authorization code back to the web app.
  4. The web app submits an OAuth 2.0 token request to ISC in this form:
POST https://{tenant}.api.identitynow.com/oauth/token?grant_type=authorization_code&client_id={client-id}&code={code}&redirect_uri={redirect-url}

Pre-requistes

  1. ISC Tenant OAuth Information (can be retrieved under https://{tenant}.api.identitynow.com/oauth/info
  2. Create OAuth Client with grant types: AUTHORIZATION_TOKEN and REFRESH_TOKEN. Use the scope sp:scope:all. We also needs to set the redirect URL to http://localhost:3000/api/auth/callback/identitySecureCloud. The redirect URL is constructed by Next-Auth follow following the format http://{domain}/api/auth/callback/{providerId}. The providerId will be configured later in the session. However, we simply pre-configure it first. Note down the clientId and clientSecret.
  3. Initiate a React project. While there are many ways to initiate a React project, here I recommend using Next.js installation.
    npx create-next-app@latest
    
  4. Install Next-Auth
    npm install next-auth
    

Creating a Next-Auth OAuth Provider

Next-Auth does have lots of built-in OAuth providers such as Google, Apple, GitHub, …, even TikTok. For the moment, there is still no template for SailPoint Identity Secure Cloud. Therefore, in this article, we will use the Custom OAuth provider.

  1. Create folder(s) /api/auth/[...nextauth]. Note, in the Next.js framework, the /api folder defines the REST API endpoints.
  2. Create the custom provider file: /api/auth/sailpoint.ts
import { OAuthConfig, OAuthUserConfig } from "next-auth/providers/oauth";

export interface IdentitySecureCloudProfile extends Record<string, any> {
  tenant: string;
  id: string;
  uid: string;
  email: string;
  phone: string;
  workPhone: string;
  firstname: string;
  lastname: string;
  capabilities: string[];
  displayName: string;
  name: string;
}

export default function SailPoint(
    config: OAuthUserConfig<IdentitySecureCloudProfile> & {
      baseUrl: string,
      apiUrl: string,
      clientId: string,
      clientSecret: string,
      scope: string
    }
  ): OAuthConfig<IdentitySecureCloudProfile> {
  
    return {
      id: "identitySecureCloud",
      name: "Identity Secure Cloud",
      type: "oauth",
      clientId: config.clientId,
      clientSecret: config.clientSecret,
      authorization: {
        url: `${config.baseUrl}/oauth/authorize`,
        params: { scope: config.scope },
      },
      token: `${config.apiUrl}/oauth/token`,
      userinfo: `${config.apiUrl}/oauth/userinfo`,
      profile(profile: IdentitySecureCloudProfile) {
        return {
            tenant: profile.tenant,
            id: profile.id,
            uid: profile.uid,
            email: profile.email,
            phone: profile.phone,
            workPhone: profile.workPhone,
            firstname: profile.firstname,
            lastname: profile.lastname,
            capabilities: profile.capabilities,
            displayName: profile.displayName,
            name: profile.uid,
        }
      },
      options: config,
    }
}
  • The interface IdentitySecureCloudProfile describes the user profile which is returned from the access_token.
  • The SailPoint function has the input arguments config, which requires information such as base_url (e.g., https://{tenant}.identitynow.com), api_url (e.g., https://{tenant}.api.identitynow.com), clientId, clientSecret, and scope. We then use that information to construct the custom OAuth provider.

Setup Refresh Token

We then create another file /api/auth/authOptions:

import { NextAuthOptions } from "next-auth";
import axios from "axios";
import SailPoint from "./sailpoint";


export const authOptions: NextAuthOptions = {
    providers: [
      SailPoint({
          baseUrl: process.env.ISC_BASE_URL!,
          apiUrl: process.env.ISC_BASE_API_URL!,
          clientId: process.env.ISC_CLIENT_ID!,
          clientSecret: process.env.ISC_CLIENT_SECRET!,
          scope: 'sp:scopes:all',
      }),
    ],
    callbacks: {
      async jwt({ token, user, account, profile }) {
        // First login
        if (account && user && profile) {
          token.accessToken = account.access_token;
          token.id = profile.id;
          token.tenant = profile.tenant;
          token.displayName = profile.displayName;
          token.name = profile.uid;
          token.capabilities = profile.capabilities;
          token.accessTokenExpires= account.expires_at!;
          token.refreshToken= account.refresh_token;
          return token;
        }
        
        // Return previous token if the access token has not expired yet
        if (Date.now() < (token.accessTokenExpires as number) * 1000) {
          return token
        }
  
        // Access token has expired, try to update it
        return refreshAccessToken(process.env.ISC_BASE_API_URL!, token)
      },
      async session({ session, user, token }) {
        session.accessToken = token.accessToken as string;
        session.user.id = token.id as string;
        session.user.capabilities = token.capabilities as string[];
        session.user.tenant = token.tenant as string;
        session.user.name = token.name as string;
        session.user.uid = token.name as string;
        session.user.displayName = token.displayName as string;
        return session;
      },
    }
}

/**
 * Takes a token, and returns a new token with updated
 * `accessToken` and `accessTokenExpires`. If an error occurs,
 * returns the old token and an error property
 */
async function refreshAccessToken(apiUrl: string, token: any) {
    try {
  
      const response = await axios.post(`${apiUrl}/oauth/token`, {
        client_id: process.env.ISC_CLIENT_ID!,
        client_secret: process.env.ISC_CLIENT_SECRET!,
        grant_type: "refresh_token",
        refresh_token: token.refreshToken,
      }, {
        headers: {
          'Content-Type': "application/x-www-form-urlencoded"
        }
      })
  
      const { access_token, expires_in, token_type, refresh_token } = response.data;
  
      return {
        ...token,
        accessToken: access_token,
        accessTokenExpires: Date.now() + expires_in,
        refreshToken: refresh_token ?? token.refreshToken, // Fall back to old refresh token
      }
    } catch (error) {
      console.error('Error refreshing access token:', error);
      throw error;
    }
}
  
  • The authOptions is a NextAuthOptions object, which contains an array of providers. We first define SailPoint (our custom OAuth provider) in the providers list.
  • In order to implement the practice of refresh tokens, we need to further define the callback functions: jwt() and session(). The jwt() function is called once Next-Auth retrieves the access_token from ISC. On the first login, we store all required user information into the token object. We then check if the current token expiration timestamp has already been exceeded. If yes, then we call the refreshAccessToken() function.
  • The refreshAccessToken() function uses axios to request a new access_token from ISC. Once successfully requested, it updates the accessToken, accessTokenExpires, and refreshToken in the token object.
  • The session() function is used to store additional information in the session, and can also be used in server or client components within the React/Next.js project.

Note: Here we may find that session.user may not contain the additional attributes we want to store in the session. To resolve this TypeScript error, we shall create a folder and file under the root folder of the project: /types/next-auth.d.ts

import NextAuth from "next-auth/next";

declare module "next-auth" {
    interface Session {
        user: {
            id: string;
            name: string;
            email: string; 
            tenant: string;
            uid: string;
            capabilities: string[];
            displayName: string;
        }
        accessToken: string;
    }
    
    interface Profile {
        tenant: string;
        id: string;
        uid: string;
        email: string;
        phone: string;
        workPhone: string;
        firstname: string;
        lastname: string;
        capabilities: string[];
        displayName: string;
    }

    interface User {
        id: string;
    }
}

Create a middleware.ts to protect the routing pages

Under the Next.js framework, we simply create a file middleware.ts under the root directory.

export { default } from "next-auth/middleware"

// Uncomment below to specify proectection on specific pages. 
// export const config = { matcher: ["/dashboard/*"] }

In this example, simply exporting the default function will protect all web pages. We can also define a matcher regex to mark which pages require authentication.

Configure Environment Variables

As you might notice, under auhtOptions.ts, there are some variables with the process.env.* prefix. Those are referencing the environment variables. To configure the variables under the dev server, first create a file .env. There we can put the corresponding variables in the file:

ISC_BASE_API_URL=https://{tenant}.api.identitynow.com
ISC_BASE_URL=https://{tenant}.identitynow.com
ISC_CLIENT_ID={clientId}
ISC_CLIENT_SECRET={clientSecret}
NEXTAUTH_SECRET={random long string}
NEXTAUTH_URL=http://localhost:3000

Note that NEXTAUTH_SECRET is required.

Get Server Session

Under the React component or any server-side pages, we can use getServerSession(authOptions) to retrieve the session user information. In this practice, we will simply demo with the default home page of Next.js, which is /app/page.tsx

import { getServerSession } from "next-auth";
import { authOptions } from "./api/auth/authOptions";

export default async function Home() {

  const session = await getServerSession(authOptions);

  return (
    <>
      <div className="flex flex-col">
        <div className="py-2 container">
          <span className="text-2xl">Hello {session?.user.displayName}</span>
        </div>
      </div>

    </>
  );
}

Testing

Run npm run dev in the Terminal and access http://localhost:3000. We shall see the page redirect to http://localhost:3000/api/auth/signin?callbackUrl=%2F

image

Once select the Sing In option, it will then redriect to ISC login page

Once the Sign In option is selected, it will then redirect to the ISC login page.

image

Conclusion

So here we have successfully demonstrated how to use Next-Auth within React to conduct OAuth authentication integrated with ISC. There are many use cases that can be extended later, such as custom sign-in/sign-out pages. Nevertheless, this could be a good starting point for your first Next.js web application to authenticate with an Identity Provider such as ISC.

At the time of writing this blog, Next-Auth is at stable version 4, and Next-Auth will then be renamed to Auth.js in version 5. However, it’s still in beta version. I will keep this post updated on the future case for Auth.js.

Reference

Auth.js - Providers - SailPoint

4 Likes

Hey Mike, Good to see your work, hope all is well.

Do you know if we have to use sp:scopes:all, or can we use a lesser permission?

We definitely can use lower permission scope, but this is depending on your use case( e.g. if you only want your application to read the data), otherwise, using so:scopes:all mainly imply user is authorized under his/her “User Level”

Alternatively, sp:scopes:all grants access to all the rights appropriate for the user level. For example, a user with the Admin user level has access to all APIs, so sp:scopes:all grants Admin users access to all APIs. A user with the Cert Admin user level, however, has access to only a subset of APIs necessary to perform their role, most notably the certification APIs, so sp:scopes:all grants Cert Admin users access to only that subset of APIs.

1 Like

There is only one thing, I am not 100% sure is whether we need so:scopes:all on the oauth client … but somehow I believe the scope in the rest call and the one defined in the oauth client shall be aligned, also below information is mentioned in the documentation:

SailPoint is working to define scopes for every endpoint, but you may encounter a scenario where you need access to an endpoint that does not yet have a scope defined. Until a scope is defined for the endpoint, you can assign sp:scopes:all to ensure that your credentials can access the necessary endpoints. Once all of the endpoints necessary for your use case have scopes defined, you can update or create a new PAT with the appropriate scopes in place.

Therefore, I think using so:scopes:all is not a bad idea for common use cases, and just allow User Level to control the authorization.

2 Likes