Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SDK-2405] Organisations support #343

Merged
merged 5 commits into from
Mar 18, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 24 additions & 11 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -456,30 +469,30 @@ 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 || '/',
transient: bool(AUTH0_COOKIE_TRANSIENT),
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 };
Expand Down
49 changes: 44 additions & 5 deletions src/handlers/callback.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
});
};
}
60 changes: 56 additions & 4 deletions src/handlers/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 });
Expand All @@ -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.
*
Expand All @@ -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}
Expand All @@ -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;
Expand All @@ -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);
};
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down
50 changes: 28 additions & 22 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ describe('config params', () => {
login: '/api/auth/login',
callback: '/api/auth/callback',
postLogoutRedirect: ''
}
},
organization: undefined
});
});

Expand Down Expand Up @@ -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'
Expand All @@ -183,6 +186,9 @@ describe('config params', () => {
name: 'quuuux'
}
});
expect(nextConfig).toMatchObject({
organization: 'bar'
});
});

test('should allow hostnames as baseURL', () => {
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const setup = async (
idTokenClaims,
callbackOptions,
logoutOptions,
loginOptions = { returnTo: '/custom-url' },
loginOptions,
profileOptions,
withPageAuthRequiredOptions,
getAccessTokenOptions,
Expand Down
Loading