Account linking from regular web app

Thank you @nik.baleca!

For others who might struggle with setting up account linking with a custom UI using Express.js, here is how I managed to implement a behavior similar to the example with Auth0 SDK (auth0@4.4.0) and express-openid-connect@2.17.1.

Main app code:

import express from 'express';
import oidc from 'express-openid-connect';

app.use(oidc.auth({
    authRequired: false, // Important for bypassing auth on /link route
    secret: process.env.SECRET,
    baseURL: process.env.BASE_URL, // Redirect URL
    clientID: process.env.CLIENT_ID,
    issuerBaseURL: process.env.ISSUER,

    // Extract and store user in session (`req.appSession`)
    afterCallback: (_req, _res, session) => {
        session.user = jwtDecode(session.id_token);
        return session;
    }
}));

// Authenicated routes
app.use('/', oidc.requiresAuth(), homeRouter);

// Link route must not be authenticated with main OIDC config
app.use('/link', linkRouter);

// (...)

You may have as many authenticated routes using the requiresAuth() middleware. One of these routes must lead the end-user to make a POST call to /link. I used an HTML form rendered in a template with a single button. Here’s how it should be handled by the linkRouter:

import express from 'express';
import oidc from 'express-openid-connect';

const linkRouter = express.Router();

// Secondary account OIDC config
linkRouter.use(oidc.auth({
    authRequired: false,
    secret: process.env.SECRET,
    baseURL: process.env.BASE_URL + '/link', // Remember to register with your Auth0 application
    clientID: process.env.CLIENT_ID,
    issuerBaseURL: process.env.ISSUER,
    authorizationParams: {
        connection: 'connection-name', // Set whatever connection the secondary account is authenticated with
    },
    session: {
        name: 'linkSession' // Save data to another session, otherwise appSession will be overwritten
    },
    routes: {
        login: false,
        logout: false,
        postLogoutRedirect: process.env.BASE_URL // After linking, go back to home page
    }
}));

// POST call from end-user (eg. through HTML form)
linkRouter.post(async (req, res, next) => {
    // At this point user should already have authenticated with primary account,
    // so a cookie has been sent and appSession has been populated.
    if (!req.appSession?.user?.email) {
        next(new Error('Invalid session'));
    } else {
        // Redirect user for authentication with target account
        await res.oidc.login({
            returnTo: '/link',
            authorizationParams: {
                login_hint: req.appSession.user.email
            }
        });
    }
});

// GET call after user has authenticated with secondary account
linkRouter.get(oidc.requiresAuth(), async (req, res, next) => {
    const primaryUserId = req.appSession.user.sub; // appSession should be set from cookie related to main account session
    const targetUserId = req.oidc.idTokenClaims.sub;
    try {
        // Link accounts 
        await this.auth0Service.linkUser(primaryUserId, targetUserId);
    } catch (e) {
        next(e);
    } finally {
        // Log out from secondary account, navigate back to home
        await res.oidc.logout();
    }
});

export default linkRouter;

There might be a better solution, I’m open to critics. In the meantime this is the app we use to allow our end-users to link their accounts.