How to set up Payload CMS authentication with Okta via OpenID Connect (OIDC/OAuth 2.0)?

default discord avatar
hdodovlast year
3 2

My organization requires the use of Okta, so I have to set it up with Payload.

I've read the Payload docs about authentication strategies and saw that it uses Passport. There's a passport-okta-oauth package, but I experience some issues.

Context

The first thing I did was to create the strategy:

src\collections\auth\OktaStrategy.ts

const OktaStrategy = require("passport-okta-oauth").Strategy;

export default (ctx) => {
    return new OktaStrategy(
        {
            audience: process.env["OKTA_BASE_URL"],
            clientID: process.env["OKTA_CLIENT_ID"],
            clientSecret: process.env["OKTA_CLIENT_SECRET"],
            scope: ["openid", "email", "profile"],
            response_type: "code",
            callbackURL: process.env["OKTA_CALLBACK_URL"],
        },
        function (accessToken, refreshToken, profile, done) {
            console.log(`logged in as ${profile.displayName}`);
            return done(null, {
                collection: "users",
                email: "xxxxxxx@gmail.com",
                id: "62fb90287bed9b334e530765",
            });
        }
    );
};

…and add it to my Users collection:

src\collections\Users.ts

import OktaStrategy from "./auth/OktaStrategy";

const Users: CollectionConfig = {
    auth: {
        disableLocalStrategy: true,
        strategies: [
            {
                name: "okta",
                strategy: OktaStrategy,
            },
        ],
    },
};

Then, I started getting errors on build:

ERROR in ./node_modules/oauth/lib/oauth.js 5:9-23
Module not found: Error: Can't resolve 'url' in 'C:\Projects\payload-project\node_modules\oauth\lib'

[...]

ERROR in ./node_modules/passport-okta-oauth/lib/passport-okta-oauth/oauth2.js 6:17-39
Module not found: Error: Can't resolve 'querystring' in 'C:\Projects\payload-project\node_modules\passport-okta-oauth\lib\passport-okta-oauth'

BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
This is no longer the case. Verify if you need this module and configure a polyfill for it.

If you want to include a polyfill, you need to:
        - add a fallback 'resolve.fallback: { "querystring": require.resolve("querystring-es3") }'
        - install 'querystring-es3'
If you don't want to include a polyfill, you can use an empty module like this:
        resolve.fallback: { "querystring": false }

I found out in the Payload docs for Webpack:

To avoid problems with server code making it to your Webpack bundle, you can use the alias Webpack feature to tell Webpack to avoid importing the modules you want to restrict to server-only.

So I aliased the module to a new, empty one:

const oktaStrategyPath = path.resolve(__dirname, "collections/auth/OktaStrategy.ts");
const mockModulePath = path.resolve(__dirname, "mocks/emptyObject.ts");

export default buildConfig({
    admin: {
        webpack: (config) => {
            return {
                ...config,
                resolve: {
                    ...config.resolve,
                    alias: {
                        ...config.resolve.alias,
                        [oktaStrategyPath]: mockModulePath,
                    },
                },
            };
        },
    },
});

Now I was able to start Payload, but got the following error:

[15:34:18] ERROR (payload): Error: OAuth 2.0 authentication requires session support when using state. Did you forget to use express-session middleware?

Looking at the Payload source, I could see that it doesn't really like sessions, because it has session: false in its call to Passport:

const authenticate = passport.authenticate(methods, { session: false });

Anyway, I installed express-session and added it to my config:

import express from "express";
import session from "express-session";
import passport from "passport";
import payload from "payload";

require("dotenv").config();
const app = express();

app.use(
    session({
        secret: "some-secret",
        resave: true,
        saveUninitialized: true,
    })
);

app.get("/", (_, res) => {
    res.redirect("/admin");
});

payload.init({
    secret: process.env.PAYLOAD_SECRET,
    mongoURL: process.env.MONGODB_URI,
    express: app,
    onInit: () => {
        payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`);
    },
});

app.get(
    "/authorization-code/callback",
    passport.authenticate("users-okta"),
    function (req, res, next) {
        req.user = {
            collection: "users",
            email: "xxxxxxx@gmail.com",
            id: "62fb90287bed9b334e530765",
        };

        res.redirect("/admin");
    }
);

app.listen(process.env.PORT || 3000);

Notice: I had to use passport.authenticate("users-okta"), rather than just "okta", because Payload prepends the collection slug to the auth strategy name.

At this point, I started getting a blank Payload admin and a request to http://localhost:3000/api/users/me that got redirected to https://trial-XXXXXXX.okta.com/oauth2/v1/authorize?response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauthorization-code%2Fcallback&scope=openid%20email%20profile&state=4zVzKTk57O2wV14e3qXECGu9&client_id=0oa3gcuk9uIqaHVzU697.

Okta correctly responds with the sign-in page, but that's an AJAX call and instead of redirecting, Payload expects JSON, which leads to this error:

VM751:1 Uncaught (in promise) SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON

image

Luckily, the response is still valid and comes back with status code 200:

image

Just for the sake of testing, I double-click on the request to open it in the browser and check if the rest follows through. It doesn't, of course, and I get this error:

Error: Failed to serialize user into session
    at pass (C:\Projects\payload-project\node_modules\passport\lib\authenticator.js:278:19)
    at Authenticator.serializeUser (C:\Projects\payload-project\node_modules\passport\lib\authenticator.js:296:5)
    at C:\Projects\payload-project\node_modules\passport\lib\sessionmanager.js:33:10
    at Immediate.<anonymous> (C:\Projects\payload-project\node_modules\express-session\session\store.js:54:5)
    at processImmediate (node:internal/timers:471:21)

After checking the Passport docs about sessions, I add this below app.use(session({ ... })):

passport.serializeUser(function (user, done) {
    done(null, user);
});

passport.deserializeUser(function (user, done) {
    done(null, user);
});

After retrying, I could finally see that the authentication goes through, because the OktaStrategy verify function logged:

logged in as Hristiyan Dodov

I was redirected back to http://localhost:3000/admin, but wasn't logged in. I still had a blank screen with the failing request to /me.

I poked around the source code and found out that Payload uses a JWT to authenticate, so I decided to modify my callback handler to create the same token:

import getCookieExpiration from "payload/dist/utilities/getCookieExpiration";
import jwt from "jsonwebtoken";

// ...

app.get(
    "/authorization-code/callback",
    passport.authenticate("users-okta"),
    function (req, res, next) {
        const collectionConfig = payload.collections.users.config;
        const token = jwt.sign(
            {
                collection: "users",
                email: "xxxxxxx@gmail.com",
                id: "62fb90287bed9b334e530765",
            },
            payload.secret,
            {
                expiresIn: collectionConfig.auth.tokenExpiration,
            }
        );

        const cookieOptions: CookieOptions = {
            path: "/",
            httpOnly: true,
            expires: getCookieExpiration(collectionConfig.auth.tokenExpiration),
            secure: collectionConfig.auth.cookies.secure,
            sameSite: collectionConfig.auth.cookies.sameSite,
            domain: undefined,
        };

        if (collectionConfig.auth.cookies.domain)
            cookieOptions.domain = collectionConfig.auth.cookies.domain;

        res.cookie(`${payload.config.cookiePrefix}-token`, token, cookieOptions);

        req.user = {
            collection: "users",
            email: "xxxxxxx@gmail.com",
            id: "62fb90287bed9b334e530765",
        };

        res.redirect("/admin");
    }
);

…but nothing changed. I was still getting a blank screen. To verify that the generated token is in fact correct, I temporarily re-enabled the default auth strategy, opened an incognito window, and manually added the cookie with the token:

document.cookie = "payload-token=eyJhbGciOiJIUz…RFic";

When I navigated to Payload, I was logged in, therefore my JWT was correct.

Questions

It appears that there's some incompatibility between Payload and this particular Passport.js setup. How am I supposed to implement authentication in this case? I think there are two approaches:

  1. Somehow disable Payload's JWT auth and use only the session-based OAuth strategy. I'm not a huge fan of this, though. I think it's better to use a session just for the initial front-channel communication between Payload and Okta, then ending the session, generating a JWT, and using that instead. Is this possible?
  2. Create completely separate Express routes for authentication and handle it entirely out of the context of Payload. Then, generate the JWT as I do now, redirect to the admin, and the user should be logged in. I'm not sure how to approach this in terms of Payload config, though. On one hand, I don't want the default login strategy with password, so I must have disableLocalStrategy: true. On the other hand, I want the default JWT validation.

Does anyone have ideas?

  • Selected Answer
    discord user avatar
    jmikrut
    last year

    Hey @hdodov - first up, I just want to note that I know exactly what you're facing here, and the truth is that implementing SSO with any application that offers an admin UI as well as API endpoints presents quite a challenge for many reasons.

    The Passport strategy that you've found, indeed, is not 100% applicable to Payload because we are more than a "SaaS" app. As in, all of our API endpoints also need to be able to authenticate a user, and you can't just simply redirect a non-authenticated user over to an Okta screen to authenticate. You might be able to do that for any admin panel routes, but for all API routes, they should not redirect and just return normally as if the user is not logged in.

    As you expect, you need to rather consider some type of "bearer strategy" where you can provide a token via cookie or a header that will allow the user to identify themselves. And then you'd need to, as you mention, add your own Express routes for redirection from your identity provider, setting HTTP-only cookie tokens, logging out, refreshing, etc.

    Long story short, this is challenging stuff—especially if you want to ensure that provided tokens are verified using the encryption protocol that the identity provider offers.

    I'm not sure if you've seen, but all of this complexity is why we offer an official Payload SSO plugin for our enterprise customers. Our SSO plugin handles all of this seamlessly and is tested with Okta out of the box. We built our own Passport strategy and wrote token verification, automatic silent refreshing, etc. all into it but it was no small task.

    You can indeed build this via a plugin yourself if you're up for the challenge but there is certainly a good amount of work involved.

    If you're up for learning more about our enterprise offering, including our SSO plugin, I'd be happy to set up a meeting sometime!

  • default discord avatar
    finkinfridom3 months ago

    hey @hdodov, I have to admit I faced similar issues when implementing a strategy for auth0.
    the documentation on this very advanced topic is not really straightforward (or probably almost completely missing).
    I am now working in integrating another platform for users' management and I have to admit I was able to reuse 99.99% of the code written for auth0.
    If you want, you can have a look at the plugins here:

    where maybe you can find some good tips/info

Star on GitHub

Star

Chat on Discord

Discord

online

Can't find what you're looking for?

Get help straight from the Payload team with an Enterprise License.