Repository: auth0/nextjs-auth0
Pull request #343 (2021-03-17 11:15:43)

[SDK-2405] Organisations support
### Description

Add support for Organisations (currently a Closed Beta)

- supports an `organization` config or `AUTH0_ORGANIZATION` environment variable which, when specified, passes the `organization` query param to the `/authorize` endpoint and validates it against the user's `org_id` ID Token claim.
- Allows the `organization` query param to be specified in the `loginHandler` and `callbackHandler` so that app's can support multiple Organzations.
- Document the `invitation` query parameter for the `/authorize` endpoint and add an example of creating a route that accepts Organization invites.


Patch:
diff --git a/src/config.ts b/src/config.ts
index b4af5c76..a28704a1 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -276,9 +276,18 @@ export interface AuthorizationParameters extends OidcAuthorizationParameters {
 }
 
 /**
- * @ignore
+ * @category server
  */
 export interface NextConfig extends Pick<BaseConfig, 'identityClaimFilter'> {
+  /**
+   * Log users in to a specific organization (Organizations is currently a Closed Beta).
+   *
+   * This will specify an `organization` parameter in your user's login request and will add a step to validate
+   * the `org_id` claim in your user's ID Token.
+   *
+   * If your app supports multiple organizations, you should take a look at {@Link AuthorizationParams.organization}
+   */
+  organization?: string;
   routes: {
     login: string;
   };
@@ -326,6 +335,7 @@ export interface NextConfig extends Pick<BaseConfig, 'identityClaimFilter'> {
  * - `AUTH0_POST_LOGOUT_REDIRECT`: See {@link BaseConfig.routes}
  * - `AUTH0_AUDIENCE`: See {@link BaseConfig.authorizationParams}
  * - `AUTH0_SCOPE`: See {@link BaseConfig.authorizationParams}
+ * - `AUTH0_ORGANIZATION`: See {@link NextConfig.organization}
  * - `AUTH0_SESSION_NAME`: See {@link SessionConfig.name}
  * - `AUTH0_SESSION_ROLLING`: See {@link SessionConfig.rolling}
  * - `AUTH0_SESSION_ROLLING_DURATION`: See {@link SessionConfig.rollingDuration}
@@ -396,7 +406,7 @@ export const getLoginUrl = (): string => {
 /**
  * @ignore
  */
-export const getConfig = (params?: ConfigParameters): { baseConfig: BaseConfig; nextConfig: NextConfig } => {
+export const getConfig = (params: ConfigParameters = {}): { baseConfig: BaseConfig; nextConfig: NextConfig } => {
   const {
     AUTH0_SECRET,
     AUTH0_ISSUER_BASE_URL,
@@ -413,6 +423,7 @@ export const getConfig = (params?: ConfigParameters): { baseConfig: BaseConfig;
     AUTH0_POST_LOGOUT_REDIRECT,
     AUTH0_AUDIENCE,
     AUTH0_SCOPE,
+    AUTH0_ORGANIZATION,
     AUTH0_SESSION_NAME,
     AUTH0_SESSION_ROLLING,
     AUTH0_SESSION_ROLLING_DURATION,
@@ -428,6 +439,8 @@ export const getConfig = (params?: ConfigParameters): { baseConfig: BaseConfig;
   const baseURL =
     AUTH0_BASE_URL && !/^https?:\/\//.test(AUTH0_BASE_URL as string) ? `https://${AUTH0_BASE_URL}` : AUTH0_BASE_URL;
 
+  const { organization, ...baseParams } = params;
+
   const baseConfig = getBaseConfig({
     secret: AUTH0_SECRET,
     issuerBaseURL: AUTH0_ISSUER_BASE_URL,
@@ -441,12 +454,12 @@ export const getConfig = (params?: ConfigParameters): { baseConfig: BaseConfig;
     auth0Logout: bool(AUTH0_IDP_LOGOUT, true),
     idTokenSigningAlg: AUTH0_ID_TOKEN_SIGNING_ALG,
     legacySameSiteCookie: bool(AUTH0_LEGACY_SAME_SITE_COOKIE),
-    ...params,
+    ...baseParams,
     authorizationParams: {
       response_type: 'code',
       audience: AUTH0_AUDIENCE,
       scope: AUTH0_SCOPE,
-      ...params?.authorizationParams
+      ...baseParams.authorizationParams
     },
     session: {
       name: AUTH0_SESSION_NAME,
@@ -456,7 +469,7 @@ export const getConfig = (params?: ConfigParameters): { baseConfig: BaseConfig;
         AUTH0_SESSION_ABSOLUTE_DURATION && isNaN(Number(AUTH0_SESSION_ABSOLUTE_DURATION))
           ? bool(AUTH0_SESSION_ABSOLUTE_DURATION)
           : num(AUTH0_SESSION_ABSOLUTE_DURATION),
-      ...params?.session,
+      ...baseParams.session,
       cookie: {
         domain: AUTH0_COOKIE_DOMAIN,
         path: AUTH0_COOKIE_PATH || '/',
@@ -464,22 +477,22 @@ export const getConfig = (params?: ConfigParameters): { baseConfig: BaseConfig;
         httpOnly: bool(AUTH0_COOKIE_HTTP_ONLY),
         secure: bool(AUTH0_COOKIE_SECURE),
         sameSite: AUTH0_COOKIE_SAME_SITE as 'lax' | 'strict' | 'none' | undefined,
-        ...params?.session?.cookie
+        ...baseParams.session?.cookie
       }
     },
     routes: {
-      callback: params?.routes?.callback || AUTH0_CALLBACK || '/api/auth/callback',
-      postLogoutRedirect: params?.routes?.postLogoutRedirect || AUTH0_POST_LOGOUT_REDIRECT
+      callback: baseParams.routes?.callback || AUTH0_CALLBACK || '/api/auth/callback',
+      postLogoutRedirect: baseParams.routes?.postLogoutRedirect || AUTH0_POST_LOGOUT_REDIRECT
     }
   });
 
   const nextConfig = {
     routes: {
       ...baseConfig.routes,
-      // Other NextConfig Routes go here
-      login: params?.routes?.login || getLoginUrl()
+      login: baseParams.routes?.login || getLoginUrl()
     },
-    identityClaimFilter: baseConfig.identityClaimFilter
+    identityClaimFilter: baseConfig.identityClaimFilter,
+    organization: organization || AUTH0_ORGANIZATION
   };
 
   return { baseConfig, nextConfig };
diff --git a/src/handlers/callback.ts b/src/handlers/callback.ts
index a7353560..badf0f76 100644
--- a/src/handlers/callback.ts
+++ b/src/handlers/callback.ts
@@ -1,7 +1,9 @@
+import { strict as assert } from 'assert';
 import { NextApiResponse, NextApiRequest } from 'next';
 import { HandleCallback as BaseHandleCallback } from '../auth0-session';
 import { Session } from '../session';
 import { assertReqRes } from '../utils/assert';
+import { NextConfig } from '../config';
 
 /**
  * Use this function for validating additional claims on the user's ID Token or adding removing items from
@@ -68,11 +70,21 @@ export type AfterCallback = (
  *
  * @category Server
  */
-export type CallbackOptions = {
+export interface CallbackOptions {
   afterCallback?: AfterCallback;
 
+  /**
+   * This is useful to specify in addition to {@Link BaseConfig.baseURL} when your app runs on multiple domains,
+   * it should match {@Link LoginOptions.authorizationParams.redirect_uri}.
+   */
   redirectUri?: string;
-};
+
+  /**
+   * This is useful to specify instead of {@Link NextConfig.organization} when your app has multiple
+   * organizations, it should match {@Link LoginOptions.authorizationParams}.
+   */
+  organization?: string;
+}
 
 /**
  * The handler for the `api/auth/callback` route.
@@ -84,9 +96,36 @@ export type HandleCallback = (req: NextApiRequest, res: NextApiResponse, options
 /**
  * @ignore
  */
-export default function handleCallbackFactory(handler: BaseHandleCallback): HandleCallback {
-  return async (req, res, options): Promise<void> => {
+const idTokenValidator = (afterCallback?: AfterCallback, organization?: string): AfterCallback => (
+  req,
+  res,
+  session,
+  state
+) => {
+  if (organization) {
+    assert(session.user.org_id, 'Organization Id (org_id) claim must be a string present in the ID token');
+    assert.equal(
+      session.user.org_id,
+      organization,
+      `Organization Id (org_id) claim value mismatch in the ID token; ` +
+        `expected "${organization}", found "${session.user.org_id}"`
+    );
+  }
+  if (afterCallback) {
+    return afterCallback(req, res, session, state);
+  }
+  return session;
+};
+
+/**
+ * @ignore
+ */
+export default function handleCallbackFactory(handler: BaseHandleCallback, config: NextConfig): HandleCallback {
+  return async (req, res, options = {}): Promise<void> => {
     assertReqRes(req, res);
-    return handler(req, res, options);
+    return handler(req, res, {
+      ...options,
+      afterCallback: idTokenValidator(options.afterCallback, options.organization || config.organization)
+    });
   };
 }
diff --git a/src/handlers/login.ts b/src/handlers/login.ts
index f24145ca..b371f910 100644
--- a/src/handlers/login.ts
+++ b/src/handlers/login.ts
@@ -2,6 +2,7 @@ import { NextApiResponse, NextApiRequest } from 'next';
 import { AuthorizationParameters, HandleLogin as BaseHandleLogin } from '../auth0-session';
 import isSafeRedirect from '../utils/url-helpers';
 import { assertReqRes } from '../utils/assert';
+import { NextConfig } from '../config';
 
 /**
  * Use this to store additional state for the user before they visit the Identity Provider to login.
@@ -14,7 +15,7 @@ import { assertReqRes } from '../utils/assert';
  *   return { basket_id: getBasketId(req) };
  * };
  *
- * export handleAuth({
+ * export default handleAuth({
  *   async login(req, res) {
  *     try {
  *       await handleLogin(req, res, { getLoginState });
@@ -29,6 +30,51 @@ import { assertReqRes } from '../utils/assert';
  */
 export type GetLoginState = (req: NextApiRequest, options: LoginOptions) => { [key: string]: any };
 
+/**
+ * Authorization params to pass to the login handler.
+ *
+ * @category Server
+ */
+export interface AuthorizationParams extends Partial<AuthorizationParameters> {
+  /**
+   * The invitation id to join an organization (Organizations is currently a Closed Beta).
+   *
+   * To create a link for your user's to accept an organization invite, read the `invitation` and `organization`
+   * query params and pass them to the authorization server to log the user in:
+   *
+   * ```js
+   * // pages/api/invite.js
+   * import { handleLogin } from '@auth0/nextjs-auth0';
+   *
+   * export default async function invite(req, res) {
+   *   try {
+   *     const { invitation, organization } = req.query;
+   *     if (!invitation) {
+   *       res.status(400).end('Missing "invitation" parameter');
+   *     }
+   *     await handleLogin(req, res, {
+   *       authorizationParams: {
+   *         invitation,
+   *         organization
+   *       }
+   *     });
+   *   } catch (error) {
+   *     res.status(error.status || 500).end(error.message);
+   *   }
+   * } ;
+   * ```
+   *
+   * Your invite url can then take the format:
+   * `https://example.com/api/invite?invitation=invitation_id&organization=org_id`
+   */
+  invitation?: string;
+  /**
+   * This is useful to specify instead of {@Link NextConfig.organization} when your app has multiple
+   * organizations, it should match {@Link CallbackOptions.organization}.
+   */
+  organization?: string;
+}
+
 /**
  * Custom options to pass to login.
  *
@@ -38,7 +84,7 @@ export interface LoginOptions {
   /**
    * Override the default {@link BaseConfig.authorizationParams authorizationParams}
    */
-  authorizationParams?: Partial<AuthorizationParameters>;
+  authorizationParams?: AuthorizationParams;
 
   /**
    *  URL to return to after login, overrides the Default is {@link BaseConfig.baseURL}
@@ -61,8 +107,8 @@ export type HandleLogin = (req: NextApiRequest, res: NextApiResponse, options?:
 /**
  * @ignore
  */
-export default function handleLoginFactory(handler: BaseHandleLogin): HandleLogin {
-  return async (req, res, options): Promise<void> => {
+export default function handleLoginFactory(handler: BaseHandleLogin, nextConfig: NextConfig): HandleLogin {
+  return async (req, res, options = {}): Promise<void> => {
     assertReqRes(req, res);
     if (req.query.returnTo) {
       const returnTo = Array.isArray(req.query.returnTo) ? req.query.returnTo[0] : req.query.returnTo;
@@ -73,6 +119,12 @@ export default function handleLoginFactory(handler: BaseHandleLogin): HandleLogi
 
       options = { ...options, returnTo };
     }
+    if (nextConfig.organization) {
+      options = {
+        ...options,
+        authorizationParams: { organization: nextConfig.organization, ...options.authorizationParams }
+      };
+    }
 
     return handler(req, res, options);
   };
diff --git a/src/index.ts b/src/index.ts
index fbdf18ee..e079bd93 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -77,9 +77,9 @@ export const initAuth0: InitAuth0 = (params) => {
   const getAccessToken = accessTokenFactory(nextConfig, getClient, sessionCache);
   const withApiAuthRequired = withApiAuthRequiredFactory(sessionCache);
   const withPageAuthRequired = withPageAuthRequiredFactory(nextConfig.routes.login, getSession);
-  const handleLogin = loginHandler(baseHandleLogin);
+  const handleLogin = loginHandler(baseHandleLogin, nextConfig);
   const handleLogout = logoutHandler(baseHandleLogout);
-  const handleCallback = callbackHandler(baseHandleCallback);
+  const handleCallback = callbackHandler(baseHandleCallback, nextConfig);
   const handleProfile = profileHandler(getClient, getAccessToken, sessionCache);
   const handleAuth = handlerFactory({ handleLogin, handleLogout, handleCallback, handleProfile });
 


Test patch:
diff --git a/tests/config.test.ts b/tests/config.test.ts
index 3095d579..1b2331a9 100644
--- a/tests/config.test.ts
+++ b/tests/config.test.ts
@@ -92,7 +92,8 @@ describe('config params', () => {
         login: '/api/auth/login',
         callback: '/api/auth/callback',
         postLogoutRedirect: ''
-      }
+      },
+      organization: undefined
     });
   });
 
@@ -145,28 +146,30 @@ describe('config params', () => {
   });
 
   test('passed in arguments should take precedence', () => {
-    expect(
-      getConfigWithEnv(
-        {},
-        {
-          authorizationParams: {
-            audience: 'foo',
-            scope: 'openid bar'
-          },
-          baseURL: 'https://baz.com',
-          routes: {
-            callback: 'qux'
+    const { baseConfig, nextConfig } = getConfigWithEnv(
+      {
+        AUTH0_ORGANIZATION: 'foo'
+      },
+      {
+        authorizationParams: {
+          audience: 'foo',
+          scope: 'openid bar'
+        },
+        baseURL: 'https://baz.com',
+        routes: {
+          callback: 'qux'
+        },
+        session: {
+          absoluteDuration: 100,
+          cookie: {
+            transient: false
           },
-          session: {
-            absoluteDuration: 100,
-            cookie: {
-              transient: false
-            },
-            name: 'quuuux'
-          }
-        }
-      ).baseConfig
-    ).toMatchObject({
+          name: 'quuuux'
+        },
+        organization: 'bar'
+      }
+    );
+    expect(baseConfig).toMatchObject({
       authorizationParams: {
         audience: 'foo',
         scope: 'openid bar'
@@ -183,6 +186,9 @@ describe('config params', () => {
         name: 'quuuux'
       }
     });
+    expect(nextConfig).toMatchObject({
+      organization: 'bar'
+    });
   });
 
   test('should allow hostnames as baseURL', () => {
diff --git a/tests/fixtures/setup.ts b/tests/fixtures/setup.ts
index ad3e215f..23b7e319 100644
--- a/tests/fixtures/setup.ts
+++ b/tests/fixtures/setup.ts
@@ -38,7 +38,7 @@ export const setup = async (
     idTokenClaims,
     callbackOptions,
     logoutOptions,
-    loginOptions = { returnTo: '/custom-url' },
+    loginOptions,
     profileOptions,
     withPageAuthRequiredOptions,
     getAccessTokenOptions,
diff --git a/tests/handlers/callback.test.ts b/tests/handlers/callback.test.ts
index 246aed97..95390dae 100644
--- a/tests/handlers/callback.test.ts
+++ b/tests/handlers/callback.test.ts
@@ -280,4 +280,76 @@ describe('callback handler', () => {
       )
     ).rejects.toThrow('some validation error.');
   });
+
+  test('throws for missing org_id claim', async () => {
+    const baseUrl = await setup({ ...withApi, organization: 'foo' });
+    const state = encodeState({ returnTo: baseUrl });
+    const cookieJar = toSignedCookieJar(
+      {
+        state,
+        nonce: '__test_nonce__'
+      },
+      baseUrl
+    );
+    await expect(
+      callback(
+        baseUrl,
+        {
+          state,
+          code: 'code'
+        },
+        cookieJar
+      )
+    ).rejects.toThrow('Organization Id (org_id) claim must be a string present in the ID token');
+  });
+
+  test('throws for org_id claim mismatch', async () => {
+    const baseUrl = await setup({ ...withApi, organization: 'foo' }, { idTokenClaims: { org_id: 'bar' } });
+    const state = encodeState({ returnTo: baseUrl });
+    const cookieJar = toSignedCookieJar(
+      {
+        state,
+        nonce: '__test_nonce__'
+      },
+      baseUrl
+    );
+    await expect(
+      callback(
+        baseUrl,
+        {
+          state,
+          code: 'code'
+        },
+        cookieJar
+      )
+    ).rejects.toThrow('Organization Id (org_id) claim value mismatch in the ID token; expected "foo", found "bar"');
+  });
+
+  test('accepts a valid organization', async () => {
+    const baseUrl = await setup(withApi, {
+      idTokenClaims: { org_id: 'foo' },
+      callbackOptions: { organization: 'foo' }
+    });
+    const state = encodeState({ returnTo: baseUrl });
+    const cookieJar = toSignedCookieJar(
+      {
+        state,
+        nonce: '__test_nonce__'
+      },
+      baseUrl
+    );
+    await expect(
+      callback(
+        baseUrl,
+        {
+          state,
+          code: 'code'
+        },
+        cookieJar
+      )
+    ).resolves.not.toThrow();
+    const session = await get(baseUrl, '/api/session', { cookieJar });
+
+    expect(session.user.org_id).toEqual('foo');
+  });
 });
diff --git a/tests/handlers/login.test.ts b/tests/handlers/login.test.ts
index b8ad365c..52fdedc5 100644
--- a/tests/handlers/login.test.ts
+++ b/tests/handlers/login.test.ts
@@ -37,8 +37,8 @@ describe('login handler', () => {
     );
   });
 
-  test('should add redirectTo to the state', async () => {
-    const baseUrl = await setup(withoutApi);
+  test('should add returnTo to the state', async () => {
+    const baseUrl = await setup(withoutApi, { loginOptions: { returnTo: '/custom-url' } });
     const cookieJar = new CookieJar();
     await get(baseUrl, '/api/auth/login', { cookieJar });
 
@@ -84,7 +84,9 @@ describe('login handler', () => {
         login_hint: 'foo@acme.com',
         ui_locales: 'nl',
         scope: 'some other scope openid',
-        foo: 'bar'
+        foo: 'bar',
+        organization: 'foo',
+        invitation: 'bar'
       }
     };
     const baseUrl = await setup(withoutApi, { loginOptions });
@@ -102,6 +104,39 @@ describe('login handler', () => {
     });
   });
 
+  test('should pass organization config to the authorization server', async () => {
+    const baseUrl = await setup({ ...withoutApi, organization: 'foo' });
+    const cookieJar = new CookieJar();
+    const {
+      res: { statusCode, headers }
+    } = await get(baseUrl, '/api/auth/login', { cookieJar, fullResponse: true });
+
+    expect(statusCode).toBe(302);
+    expect(urlParse(headers.location, true)).toMatchObject({
+      query: {
+        organization: 'foo'
+      }
+    });
+  });
+
+  test('should prefer organization auth param to config', async () => {
+    const baseUrl = await setup(
+      { ...withoutApi, organization: 'foo' },
+      { loginOptions: { authorizationParams: { organization: 'bar' } } }
+    );
+    const cookieJar = new CookieJar();
+    const {
+      res: { statusCode, headers }
+    } = await get(baseUrl, '/api/auth/login', { cookieJar, fullResponse: true });
+
+    expect(statusCode).toBe(302);
+    expect(urlParse(headers.location, true)).toMatchObject({
+      query: {
+        organization: 'bar'
+      }
+    });
+  });
+
   test('should allow adding custom data to the state', async () => {
     const loginOptions = {
       getLoginState: (): Record<string, any> => {