diff --git a/README.md b/README.md index 9a0e963b0..a574dc7a2 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,10 @@ Hangar is a hackathon management platform that can help with everything from pro - [Updating Mikro-ORM](#updating-mikro-orm) - [Restoring a Production DB locally](#restoring-a-production-db-locally) - [Restarting Table Sequences](#restarting-table-sequences) - - [Slack](#slack) + - [SSO](#sso) + - [Ping Federate](#ping-federate) + - [Slack](#slack) + - [Starting the App](#starting-the-app) - [Containerization](#containerization) - [Building and Running Docker Locally](#building-and-running-docker-locally) - [Environment Variables](#environment-variables) @@ -71,7 +74,7 @@ Hangar is containerized via Docker; simply build the docker image and deploy to #### Authentication -Authentication uses OAuth via Slack but it can be easily modified to use a new callback from a different OAuth provider. See the [Slack](#slack) section below for full details on how to setup and configure your Slack app. +Authentication uses OAuth via Ping Federate OR Slack but it can be easily modified to use a new callback from a different OAuth provider. See the [Ping Federate](#ping-federate) or [Slack](#slack) below for full details on how to setup and configure your SSO solution. #### Feature Utilization @@ -210,9 +213,20 @@ In order to modify the content of the homepage and to make other modifications t - ### Slack + ### SSO - To configure Slack notifications for certain events, create a Slack app use the following manifest after selecting `From an app manifest` (NOTE: watch out for whitespace issues when copying): + #### Ping Federate + + Ping Federate is enabled by default for SSO. To configure it, simply add the following environment variables to your `.env.local`: + + - `PINGFED_CLIENT_ID`: Your client ID + - `PINGFED_CLIENT_SECRET`: Your client secret + - `PINGFED_AUTH_BASE_URL`: The URL which starts the authentication flow + - `PINGFED_TOKEN_BASE_URL`: The URL from which the token containing user data can be retrieved + + #### Slack + + To configure the app to use Slack for SSO Authentication, modify the `packages/shared/src/config/auth.ts` and set `slack` as the auth method. Next, create a Slack app using the following manifest after selecting `From an app manifest` (NOTE: watch out for whitespace issues when copying): ```yml display_information: @@ -248,7 +262,7 @@ In order to modify the content of the homepage and to make other modifications t For Slack OAuth to work, your bot needs to be configured with your redirect URL. Go to `OAuth & Permissions` if you need to update your Redirect URL (e.g., `[YOUR_NGROK_URL]/api/auth/callback/slack`). - Channel IDs can be obtained by right clicking a channel in the sidebar and removing the last path value from the URL. +### Starting the App 1. Start the development server diff --git a/cspell.json b/cspell.json index d59bc6478..a648b26ed 100644 --- a/cspell.json +++ b/cspell.json @@ -30,6 +30,7 @@ "mikro", "mrkdwn", "msapplication", + "pingfed", "seedrandom", "SSRF", "tablist", diff --git a/packages/api/.env.sample b/packages/api/.env.sample index a6aa92a89..b5046ab48 100644 --- a/packages/api/.env.sample +++ b/packages/api/.env.sample @@ -1,16 +1,14 @@ # cSpell:disable +# NOTE: All commented out variables below are optional but some are required when running docker locally + #### Generic options #### NODE_ENV=development PORT=3000 -# No ending slash -NEXT_PUBLIC_BASE_URL=localhost:3000 -NEXT_PUBLIC_SLACK_WORKSPACE_NAME=your-slack-workspace -# NEXT_PUBLIC_SLACK_INVITE_URL=https://join.slack.com/XXX SESSION_SECRET=XXXXX -# For images when testing locally -# NOTE: All commented out variables below are optional but some are required when running docker locally +# No ending slash +NEXT_PUBLIC_BASE_URL="http://localhost:3000" #### Database #### DATABASE_URL=postgresql://localhost:5432/hangar @@ -18,10 +16,22 @@ DB_LOGGING_ENABLED=true DISABLE_DATABASE_SSL=true # REQUIRED FOR PC DEVS AND DOCKER, macOS DEVS MAY NEED USER: # DATABASE_PASS= -# DATABASE_USER=aa0000000 +# DATABASE_USER= + +#### Ping Federate Auth #### +PINGFED_CLIENT_ID="XXXXXXXXXXXX" +PINGFED_CLIENT_SECRET="XXXXXXXX" +PINGFED_AUTH_BASE_URL="XXXXXXXX" +PINGFED_TOKEN_BASE_URL="XXXXXXX" + +#### Slack Workspace Invite Button #### +# Set if you'd like a "Join Slack" button in the UI +# NEXT_PUBLIC_SLACK_INVITE_URL=https://join.slack.com/XXX + +#### Slack Auth - See README #### +# NEXT_PUBLIC_SLACK_WORKSPACE_NAME=your-slack-workspace +# SLACK_BOT_TOKEN="xoxb-XXXXXXXX" +# SLACK_SIGNING_SECRET="XXXXX" +# SLACK_CLIENT_ID="XXXXX" +# SLACK_CLIENT_SECRET="XXXXXX" -#### Slack API #### -SLACK_BOT_TOKEN="xoxb-XXXXXXXX" -SLACK_SIGNING_SECRET="XXXXX" -SLACK_CLIENT_ID="XXXXX" -SLACK_CLIENT_SECRET="XXXXXX" diff --git a/packages/api/src/api/auth/callback/index.ts b/packages/api/src/api/auth/callback/index.ts index eafb2885e..3869e7324 100644 --- a/packages/api/src/api/auth/callback/index.ts +++ b/packages/api/src/api/auth/callback/index.ts @@ -1,6 +1,14 @@ +import { Config } from '@hangar/shared'; import { Router } from 'express'; import { slack } from './slack'; +import { pingfed } from './pingfed'; export const callback = Router(); -callback.use('/slack', slack); +// Register the appropriate callback route based on the auth method +const { method: authMethod } = Config.Auth; +if (authMethod === 'slack') { + callback.use('/slack', slack); +} else if (authMethod === 'pingfed') { + callback.use('/pingfed', pingfed); +} diff --git a/packages/api/src/api/auth/callback/pingfed/get.ts b/packages/api/src/api/auth/callback/pingfed/get.ts new file mode 100644 index 000000000..578d657d7 --- /dev/null +++ b/packages/api/src/api/auth/callback/pingfed/get.ts @@ -0,0 +1,54 @@ +import { Config } from '@hangar/shared'; +import axios from 'axios'; +import jwt_decode from 'jwt-decode'; +import { Request, Response } from 'express'; +import { pingfedAuth } from '../../../../env/auth'; +import { formatRedirectUri } from '../../utils/formatRedirectUri'; +import { authenticateUser } from '../../utils/authenticateUser'; + +type TokenResponse = { + access_token: string; +}; +type TokenValues = { + first_name: string; + last_name: string; + Email: string; +}; + +export const get = async (req: Request, res: Response) => { + const returnTo = req.query[Config.global.authReturnUriParamName] as string | undefined; + const { code } = req.query; + + if (!code) { + res.redirect(`/error?description=${encodeURIComponent('Bad Auth Callback')}`); + return; + } + + try { + const body = new URLSearchParams({ + client_id: pingfedAuth.clientId, + client_secret: pingfedAuth.clientSecret, + grant_type: 'authorization_code', + redirect_uri: formatRedirectUri({ returnTo }), + code: code as string, + }).toString(); + + const response = await axios.post(pingfedAuth.tokenBaseUrl, body, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'hangar-server', // Cannot be `axios/*` or PingFed will reject with 403 + }, + }); + const { access_token: token } = response.data as TokenResponse; + + const { + first_name: firstName, + last_name: lastName, + Email: email, + } = jwt_decode(token as string); + + void authenticateUser({ req, res, data: { firstName, lastName, email, returnTo } }); + } catch (error) { + res.redirect(`/error?description=${encodeURIComponent('Failed to get auth token')}`); + } +}; diff --git a/packages/api/src/api/auth/callback/pingfed/index.ts b/packages/api/src/api/auth/callback/pingfed/index.ts new file mode 100644 index 000000000..8d425a0b3 --- /dev/null +++ b/packages/api/src/api/auth/callback/pingfed/index.ts @@ -0,0 +1,6 @@ +import { Router } from 'express'; +import { get } from './get'; + +export const pingfed = Router(); + +pingfed.get('', get); diff --git a/packages/api/src/api/auth/callback/slack/get.ts b/packages/api/src/api/auth/callback/slack/get.ts index 0e164d24a..8e5bec70a 100644 --- a/packages/api/src/api/auth/callback/slack/get.ts +++ b/packages/api/src/api/auth/callback/slack/get.ts @@ -2,10 +2,10 @@ import { WebClient } from '@slack/web-api'; import { Request, Response } from 'express'; import jwt_decode from 'jwt-decode'; import { Config } from '@hangar/shared'; -import { env } from '../../../../env'; -import { authenticateUser } from '../../../../utils/authenticateUser'; +import { authenticateUser } from '../../utils/authenticateUser'; import { logger } from '../../../../utils/logger'; -import { formatSlackRedirectUri } from '../../../../slack/formatSlackRedirectUri'; +import { slackAuth } from '../../../../env/auth'; +import { formatRedirectUri } from '../../utils/formatRedirectUri'; const codeQueryParam = 'code'; export type SlackTokenData = { @@ -16,17 +16,17 @@ export type SlackTokenData = { export const get = async (req: Request, res: Response) => { const myCode: string = req.query[codeQueryParam] as string; - const { slackClientID, slackClientSecret } = env; + const { clientId, clientSecret, botToken } = slackAuth; const returnTo = req.query[Config.global.authReturnUriParamName] as string | undefined; - const client = new WebClient(env.slackBotToken); + const client = new WebClient(botToken); try { const fullToken = await client.openid.connect.token({ code: myCode, - client_id: slackClientID, - client_secret: slackClientSecret, - redirect_uri: formatSlackRedirectUri({ returnTo }), + client_id: clientId, + client_secret: clientSecret, + redirect_uri: formatRedirectUri({ returnTo }), }); const { diff --git a/packages/api/src/api/auth/get.ts b/packages/api/src/api/auth/get.ts index 4cbd5be8b..7d36a6e96 100644 --- a/packages/api/src/api/auth/get.ts +++ b/packages/api/src/api/auth/get.ts @@ -1,18 +1,22 @@ import { Request, Response } from 'express'; import { Config } from '@hangar/shared'; -import { env } from '../../env'; -import { formatSlackRedirectUri } from '../../slack/formatSlackRedirectUri'; +import { formatSlackAuthUrl, formatPingfedAuthUrl } from './utils/authUrlFormatters'; -const slackAuthBaseUrl: string = - 'https://slack.com/openid/connect/authorize?scope=openid%20email%20profile&response_type=code&'; +const { method: authMethod } = Config.Auth; +/** + * Express handler that redirects to the appropriate auth url based on the auth method + */ export const get = async (req: Request, res: Response) => { const { [Config.global.authReturnUriParamName]: returnTo } = req.query as Record; - const queryArgs = new URLSearchParams({ - redirect_uri: formatSlackRedirectUri({ returnTo }), - client_id: env.slackClientID, - }).toString(); + let authUrl: string; + if (authMethod === 'slack') { + authUrl = formatSlackAuthUrl({ returnTo }); + } else { + // Pingfed + authUrl = formatPingfedAuthUrl({ returnTo }); + } - res.redirect(`${slackAuthBaseUrl}${queryArgs}`); + res.redirect(authUrl); }; diff --git a/packages/api/src/api/auth/utils/authUrlFormatters/formatPingfedAuthUrl.ts b/packages/api/src/api/auth/utils/authUrlFormatters/formatPingfedAuthUrl.ts new file mode 100644 index 000000000..bae665a58 --- /dev/null +++ b/packages/api/src/api/auth/utils/authUrlFormatters/formatPingfedAuthUrl.ts @@ -0,0 +1,18 @@ +import { pingfedAuth } from '../../../../env/auth'; +import { formatRedirectUri } from '../formatRedirectUri'; +import { AuthUrlFormatter } from './types'; + +/** + * + * @returns The URL to redirect to for the initial step of the auth flow + */ +export const formatPingfedAuthUrl: AuthUrlFormatter = ({ returnTo }) => { + const queryArgs = new URLSearchParams({ + redirect_uri: formatRedirectUri({ returnTo }), + client_id: pingfedAuth.clientId, + client_password: pingfedAuth.clientSecret, + response_type: 'code', + }).toString(); + + return `${pingfedAuth.authBaseUrl}?${queryArgs}`; +}; diff --git a/packages/api/src/api/auth/utils/authUrlFormatters/formatSlackAuthUrl.ts b/packages/api/src/api/auth/utils/authUrlFormatters/formatSlackAuthUrl.ts new file mode 100644 index 000000000..ec522794e --- /dev/null +++ b/packages/api/src/api/auth/utils/authUrlFormatters/formatSlackAuthUrl.ts @@ -0,0 +1,19 @@ +import { slackAuth } from '../../../../env/auth'; +import { formatRedirectUri } from '../formatRedirectUri'; +import { AuthUrlFormatter } from './types'; + +export const slackAuthBaseUrl: string = + 'https://slack.com/openid/connect/authorize?scope=openid%20email%20profile&response_type=code&'; + +/** + * + * @returns {string} The URL to redirect to for the initial step of the auth flow + */ +export const formatSlackAuthUrl: AuthUrlFormatter = ({ returnTo }) => { + const queryArgs = new URLSearchParams({ + redirect_uri: formatRedirectUri({ returnTo }), + client_id: slackAuth.clientId, + }).toString(); + + return `${slackAuthBaseUrl}${queryArgs}`; +}; diff --git a/packages/api/src/api/auth/utils/authUrlFormatters/index.ts b/packages/api/src/api/auth/utils/authUrlFormatters/index.ts new file mode 100644 index 000000000..c29b4a92d --- /dev/null +++ b/packages/api/src/api/auth/utils/authUrlFormatters/index.ts @@ -0,0 +1,4 @@ +export * from './formatPingfedAuthUrl'; +export * from './formatSlackAuthUrl'; + +// This directory houses the formatters that create URLs for redirection that triggers the initial step of the auth flow diff --git a/packages/api/src/api/auth/utils/authUrlFormatters/types.ts b/packages/api/src/api/auth/utils/authUrlFormatters/types.ts new file mode 100644 index 000000000..9f9b25c2e --- /dev/null +++ b/packages/api/src/api/auth/utils/authUrlFormatters/types.ts @@ -0,0 +1 @@ +export type AuthUrlFormatter = (args: { returnTo?: string }) => string; diff --git a/packages/api/src/utils/authenticateUser.ts b/packages/api/src/api/auth/utils/authenticateUser.ts similarity index 96% rename from packages/api/src/utils/authenticateUser.ts rename to packages/api/src/api/auth/utils/authenticateUser.ts index 391571e06..f3f40c32d 100644 --- a/packages/api/src/utils/authenticateUser.ts +++ b/packages/api/src/api/auth/utils/authenticateUser.ts @@ -1,6 +1,6 @@ import { User } from '@hangar/database'; import { Response, Request } from 'express'; -import { logger } from './logger'; +import { logger } from '../../../utils/logger'; export type OAuthUserData = { email: string; diff --git a/packages/api/src/api/auth/utils/formatRedirectUri.ts b/packages/api/src/api/auth/utils/formatRedirectUri.ts new file mode 100644 index 000000000..f2b41da0e --- /dev/null +++ b/packages/api/src/api/auth/utils/formatRedirectUri.ts @@ -0,0 +1,25 @@ +import { Config } from '@hangar/shared'; +import { env } from '../../../env'; + +type FormatSlackRedirectUriArgs = { + returnTo?: string; +}; + +const { method } = Config.Auth; + +/** + * A method to format a URL encoded redirect URI based on the configured auth method + * @param {string} args.returnTo the uri to return the user to post-auth + * @returns + */ +export const formatRedirectUri = ({ returnTo }: FormatSlackRedirectUriArgs = {}) => { + const params = new URLSearchParams(); + if (returnTo) { + params.append(Config.global.authReturnUriParamName, returnTo); + } + + const paramsString = params.toString(); + const returnToQuery = paramsString ? `?${paramsString}` : ''; + + return `${env.baseUrl ?? ''}/api/auth/callback/${method}/${returnToQuery}`; +}; diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts deleted file mode 100644 index afcbfef2b..000000000 --- a/packages/api/src/env.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { config } from 'dotenv-flow'; -import setEnv from '@americanairlines/simple-env'; - -config(); - -export const env = setEnv({ - required: { - nodeEnv: 'NODE_ENV', - port: 'PORT', - baseUrl: 'NEXT_PUBLIC_BASE_URL', - sessionSecret: 'SESSION_SECRET', - slackBotToken: 'SLACK_BOT_TOKEN', - slackSigningSecret: 'SLACK_SIGNING_SECRET', - slackClientID: 'SLACK_CLIENT_ID', - slackClientSecret: 'SLACK_CLIENT_SECRET', - }, - optional: { - slackLogLevel: 'SLACK_LOG_LEVEL', - }, -}); diff --git a/packages/api/src/env/auth/index.ts b/packages/api/src/env/auth/index.ts new file mode 100644 index 000000000..e8477b251 --- /dev/null +++ b/packages/api/src/env/auth/index.ts @@ -0,0 +1,2 @@ +export * from './pingfedAuth'; +export * from './slackAuth'; diff --git a/packages/api/src/env/auth/pingfedAuth.ts b/packages/api/src/env/auth/pingfedAuth.ts new file mode 100644 index 000000000..ca82ca26d --- /dev/null +++ b/packages/api/src/env/auth/pingfedAuth.ts @@ -0,0 +1,10 @@ +import setEnv from '@americanairlines/simple-env'; + +export const pingfedAuth = setEnv({ + required: { + clientId: 'PINGFED_CLIENT_ID', + clientSecret: 'PINGFED_CLIENT_SECRET', + authBaseUrl: 'PINGFED_AUTH_BASE_URL', + tokenBaseUrl: 'PINGFED_TOKEN_BASE_URL', + }, +}); diff --git a/packages/api/src/env/auth/slackAuth.ts b/packages/api/src/env/auth/slackAuth.ts new file mode 100644 index 000000000..354639f99 --- /dev/null +++ b/packages/api/src/env/auth/slackAuth.ts @@ -0,0 +1,10 @@ +import setEnv from '@americanairlines/simple-env'; + +export const slackAuth = setEnv({ + required: { + botToken: 'SLACK_BOT_TOKEN', + signingSecret: 'SLACK_SIGNING_SECRET', + clientId: 'SLACK_CLIENT_ID', + clientSecret: 'SLACK_CLIENT_SECRET', + }, +}); diff --git a/packages/api/src/env/env.ts b/packages/api/src/env/env.ts new file mode 100644 index 000000000..380444a3b --- /dev/null +++ b/packages/api/src/env/env.ts @@ -0,0 +1,13 @@ +import setEnv from '@americanairlines/simple-env'; + +export const env = setEnv({ + required: { + nodeEnv: 'NODE_ENV', + port: 'PORT', + baseUrl: 'NEXT_PUBLIC_BASE_URL', + sessionSecret: 'SESSION_SECRET', + }, + optional: { + // Add optional env vars here + }, +}); diff --git a/packages/api/src/env/index.ts b/packages/api/src/env/index.ts new file mode 100644 index 000000000..af7962e45 --- /dev/null +++ b/packages/api/src/env/index.ts @@ -0,0 +1,8 @@ +import { config } from 'dotenv-flow'; + +config(); // Must be called before exports + +/** + * All core environment variables + */ +export * from './env'; diff --git a/packages/api/src/slack/formatSlackRedirectUri.ts b/packages/api/src/slack/formatSlackRedirectUri.ts deleted file mode 100644 index 407e1059d..000000000 --- a/packages/api/src/slack/formatSlackRedirectUri.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Config } from '@hangar/shared'; -import { env } from '../env'; - -type FormatSlackRedirectUriArgs = { - returnTo?: string; -}; - -export const formatSlackRedirectUri = ({ returnTo }: FormatSlackRedirectUriArgs = {}) => { - const params = new URLSearchParams(); - if (returnTo) { - params.append(Config.global.authReturnUriParamName, returnTo); - } - - const paramsString = params.toString(); - const returnToQuery = paramsString ? `?${paramsString}` : ''; - return `${env.baseUrl ?? ''}/api/auth/callback/slack${returnToQuery}`; -}; diff --git a/packages/api/tests/api/auth/callback/index.test.ts b/packages/api/tests/api/auth/callback/index.test.ts new file mode 100644 index 000000000..c62f2d65d --- /dev/null +++ b/packages/api/tests/api/auth/callback/index.test.ts @@ -0,0 +1,37 @@ +import { Config } from '@hangar/shared'; +import { Router } from 'express'; +import { slack } from '../../../../src/api/auth/callback/slack'; +import { pingfed } from '../../../../src/api/auth/callback/pingfed'; + +jest.mock('../../../../src/api/auth/callback/slack', () => ({})); // TODO: Investigate why this requires a blank factory +jest.mock('../../../../src/api/auth/callback/pingfed', () => ({})); +jest.mock('@hangar/shared'); +jest.mock('express'); + +describe('callback router registrations', () => { + describe('slack router', () => { + it('registers the slack callback router', async () => { + Config.Auth.method = 'slack'; + const mockRouter = { use: jest.fn() }; + Router.prototype.constructor.mockReturnValueOnce(mockRouter); + + await jest.isolateModulesAsync(async () => { + await import('../../../../src/api/auth/callback'); + expect(mockRouter.use).toHaveBeenCalledWith('/slack', slack); + }); + }); + }); + + describe('pingfed router', () => { + it('registers the pingfed callback router', async () => { + Config.Auth.method = 'pingfed'; + const mockRouter = { use: jest.fn() }; + Router.prototype.constructor.mockReturnValueOnce(mockRouter); + + await jest.isolateModulesAsync(async () => { + await import('../../../../src/api/auth/callback'); + expect(mockRouter.use).toHaveBeenCalledWith('/pingfed', pingfed); + }); + }); + }); +}); diff --git a/packages/api/tests/api/auth/callback/pingfed/get.test.ts b/packages/api/tests/api/auth/callback/pingfed/get.test.ts new file mode 100644 index 000000000..a4def33ff --- /dev/null +++ b/packages/api/tests/api/auth/callback/pingfed/get.test.ts @@ -0,0 +1,69 @@ +import axios from 'axios'; +import jwt_decode from 'jwt-decode'; +import { get } from '../../../../../src/api/auth/callback/pingfed/get'; +import { createMockRequest } from '../../../../testUtils/expressHelpers/createMockRequest'; +import { createMockResponse } from '../../../../testUtils/expressHelpers/createMockResponse'; +import { getMock } from '../../../../testUtils/getMock'; +import { authenticateUser } from '../../../../../src/api/auth/utils/authenticateUser'; + +jest.mock('axios', () => ({ + __esModule: true, + default: { post: jest.fn() }, +})); +jest.mock('jwt-decode'); +jest.mock('../../../../../src/api/auth/utils/authenticateUser'); +const jwtDecodeMock = getMock(jwt_decode); + +describe('Pingfed auth callback', () => { + it('parses the code correctly, fetches a token, and decodes it into user properties', async () => { + const mockCode = 'mockCode'; + const mockReq = createMockRequest({ + query: { code: mockCode }, + }); + const mockRes = createMockResponse(); + + const mockToken = 'mockToken'; + (axios.post as jest.Mock).mockResolvedValueOnce({ data: { access_token: mockToken } }); + const mockTokenValues = { + first_name: 'mockFirstName', + last_name: 'mockLastName', + Email: 'mockEmail', + }; + jwtDecodeMock.mockReturnValueOnce(mockTokenValues); + + await get(mockReq as any, mockRes as any); + + expect(jwtDecodeMock).toHaveBeenCalledWith(mockToken); + expect(authenticateUser).toHaveBeenCalledWith({ + req: mockReq, + res: mockRes, + data: expect.objectContaining({ + firstName: mockTokenValues.first_name, + lastName: mockTokenValues.last_name, + email: mockTokenValues.Email, + }), + }); + }); + + it('redirects to an error page if a code is not present', async () => { + const mockReq = createMockRequest(); + const mockRes = createMockResponse(); + + await get(mockReq as any, mockRes as any); + + expect(mockRes.redirect).toBeCalledWith(`/error?description=Bad%20Auth%20Callback`); + }); + + it('redirects to an error page if the token could not be fetched', async () => { + const mockReq = createMockRequest({ + query: { code: 'mockCode' }, + }); + const mockRes = createMockResponse(); + + (axios.post as jest.Mock).mockRejectedValueOnce(new Error('mockError')); + + await get(mockReq as any, mockRes as any); + + expect(mockRes.redirect).toBeCalledWith(`/error?description=Failed%20to%20get%20auth%20token`); + }); +}); diff --git a/packages/api/tests/api/auth/callback/slack/get.test.ts b/packages/api/tests/api/auth/callback/slack/get.test.ts index 8a1d087c0..cc5a41749 100644 --- a/packages/api/tests/api/auth/callback/slack/get.test.ts +++ b/packages/api/tests/api/auth/callback/slack/get.test.ts @@ -3,15 +3,23 @@ import { Config } from '@hangar/shared'; import jwt_decode from 'jwt-decode'; import { SlackTokenData, get } from '../../../../../src/api/auth/callback/slack/get'; import { getMock } from '../../../../testUtils/getMock'; -import { authenticateUser } from '../../../../../src/utils/authenticateUser'; +import { authenticateUser } from '../../../../../src/api/auth/utils/authenticateUser'; import { createMockRequest } from '../../../../testUtils/expressHelpers/createMockRequest'; import { createMockResponse } from '../../../../testUtils/expressHelpers/createMockResponse'; -import { formatSlackRedirectUri } from '../../../../../src/slack/formatSlackRedirectUri'; +import { formatRedirectUri } from '../../../../../src/api/auth/utils/formatRedirectUri'; +import { slackAuth } from '../../../../../src/env/auth'; jest.mock('@slack/web-api'); jest.mock('jwt-decode'); -jest.mock('../../../../../src/utils/authenticateUser'); -jest.mock('../../../../../src/slack/formatSlackRedirectUri'); +jest.mock('../../../../../src/api/auth/utils/authenticateUser'); +jest.mock('../../../../../src/api/auth/utils/formatRedirectUri'); +jest.mock('../../../../../src/env/auth'); + +(slackAuth as Partial) = { + clientId: 'mockClientID', + botToken: 'mockBotToken', + clientSecret: 'mockClientSecret', +}; const mockToken = { ok: true, @@ -22,7 +30,7 @@ const mockToken = { const jwtDecodeMock = getMock(jwt_decode); const webClientSpy = jest.spyOn(Slack, 'WebClient'); const authenticateUserMock = getMock(authenticateUser); -const formatSlackRedirectUriMock = getMock(formatSlackRedirectUri); +const formatRedirectUriMock = getMock(formatRedirectUri); describe('Slack auth callback', () => { describe('handler', () => { @@ -41,13 +49,16 @@ describe('Slack auth callback', () => { query: { code: 'mockCode', [Config.global.authReturnUriParamName]: returnToMock }, }); const mockRedirectUri = 'pancakes'; - formatSlackRedirectUriMock.mockReturnValueOnce(mockRedirectUri); + formatRedirectUriMock.mockReturnValueOnce(mockRedirectUri); await get(mockReq as any, {} as any); + expect(webClientSpy).toBeCalledWith(slackAuth.botToken); expect(mockTokenMethod).toHaveBeenCalledTimes(1); expect(mockTokenMethod).toHaveBeenCalledWith( expect.objectContaining({ + client_id: slackAuth.clientId, + client_secret: slackAuth.clientSecret, code: mockReq.query.code, redirect_uri: mockRedirectUri, }), diff --git a/packages/api/tests/api/auth/callback/slack/utils/authUrlFormatters/formatPingfedAuthUrl.test.ts b/packages/api/tests/api/auth/callback/slack/utils/authUrlFormatters/formatPingfedAuthUrl.test.ts new file mode 100644 index 000000000..67f65cb1e --- /dev/null +++ b/packages/api/tests/api/auth/callback/slack/utils/authUrlFormatters/formatPingfedAuthUrl.test.ts @@ -0,0 +1,31 @@ +import { formatPingfedAuthUrl } from '../../../../../../../src/api/auth/utils/authUrlFormatters/formatPingfedAuthUrl'; +import { formatRedirectUri } from '../../../../../../../src/api/auth/utils/formatRedirectUri'; +import { pingfedAuth } from '../../../../../../../src/env/auth/pingfedAuth'; +import { getMock } from '../../../../../../testUtils/getMock'; + +jest.mock('../../../../../../../src/env/auth/pingfedAuth'); +jest.mock('../../../../../../../src/api/auth/utils/formatRedirectUri'); +const formatRedirectUriMock = getMock(formatRedirectUri); + +(pingfedAuth as Partial) = { + authBaseUrl: 'https://mock.com', + clientId: 'mockClientId', + clientSecret: 'mockClientSecret', +}; + +describe('formatPingfedAuthUrl', () => { + it('formats the pingfed auth url correctly', () => { + const returnTo = '/api/health'; + const mockRedirectUri = 'mockRedirectUri'; + formatRedirectUriMock.mockReturnValueOnce(mockRedirectUri); + + const redirectUri = formatPingfedAuthUrl({ returnTo }); + + expect(formatRedirectUriMock).toBeCalledTimes(1); + expect(formatRedirectUriMock).toBeCalledWith({ returnTo }); + + expect(redirectUri).toEqual( + `${pingfedAuth.authBaseUrl}?redirect_uri=${mockRedirectUri}&client_id=${pingfedAuth.clientId}&client_password=${pingfedAuth.clientSecret}&response_type=code`, + ); + }); +}); diff --git a/packages/api/tests/api/auth/callback/slack/utils/authUrlFormatters/formatSlackAuthUrl.test.ts b/packages/api/tests/api/auth/callback/slack/utils/authUrlFormatters/formatSlackAuthUrl.test.ts new file mode 100644 index 000000000..02d2a5a3e --- /dev/null +++ b/packages/api/tests/api/auth/callback/slack/utils/authUrlFormatters/formatSlackAuthUrl.test.ts @@ -0,0 +1,32 @@ +import { + formatSlackAuthUrl, + slackAuthBaseUrl, +} from '../../../../../../../src/api/auth/utils/authUrlFormatters/formatSlackAuthUrl'; +import { formatRedirectUri } from '../../../../../../../src/api/auth/utils/formatRedirectUri'; +import { slackAuth } from '../../../../../../../src/env/auth/slackAuth'; +import { getMock } from '../../../../../../testUtils/getMock'; + +jest.mock('../../../../../../../src/env/auth/slackAuth'); +jest.mock('../../../../../../../src/api/auth/utils/formatRedirectUri'); +const formatRedirectUriMock = getMock(formatRedirectUri); + +(slackAuth as Partial) = { + clientId: 'mockClientId', +}; + +describe('formatSlackAuthUrl', () => { + it('formats the slack auth url correctly', () => { + const returnTo = '/api/health'; + const mockRedirectUri = 'mockRedirectUri'; + formatRedirectUriMock.mockReturnValueOnce(mockRedirectUri); + + const redirectUri = formatSlackAuthUrl({ returnTo }); + + expect(formatRedirectUriMock).toBeCalledTimes(1); + expect(formatRedirectUriMock).toBeCalledWith({ returnTo }); + + expect(redirectUri).toEqual( + `${slackAuthBaseUrl}redirect_uri=${mockRedirectUri}&client_id=${slackAuth.clientId}`, + ); + }); +}); diff --git a/packages/api/tests/api/auth/get.test.ts b/packages/api/tests/api/auth/get.test.ts index b3f0af500..d3087783d 100644 --- a/packages/api/tests/api/auth/get.test.ts +++ b/packages/api/tests/api/auth/get.test.ts @@ -1,35 +1,74 @@ import { Config } from '@hangar/shared'; -import { get } from '../../../src/api/auth/get'; import { createMockRequest } from '../../testUtils/expressHelpers/createMockRequest'; import { createMockResponse } from '../../testUtils/expressHelpers/createMockResponse'; -import { formatSlackRedirectUri } from '../../../src/slack/formatSlackRedirectUri'; import { getMock } from '../../testUtils/getMock'; - -const slackAuthBaseUrl: string = - 'https://slack.com/openid/connect/authorize?scope=openid%20email%20profile&response_type=code&'; +import { + formatPingfedAuthUrl, + formatSlackAuthUrl, +} from '../../../src/api/auth/utils/authUrlFormatters'; const returnTo = '/api/expoJudgingSession'; -jest.mock('../../../src/slack/formatSlackRedirectUri'); -const formatSlackRedirectUriMock = getMock(formatSlackRedirectUri); +jest.mock('@hangar/shared'); +jest.mock('../../../src/api/auth/utils/authUrlFormatters'); +jest.mock('../../../src/env/auth', () => ({ slackAuth: {}, pingfedAuth: {} })); +const formatSlackAuthUrlMock = getMock(formatSlackAuthUrl); +const formatPingfedAuthUrlMock = getMock(formatPingfedAuthUrl); + +describe('auth login redirect', () => { + describe('slack redirect', () => { + beforeEach(() => { + Config.Auth.method = 'slack'; + }); + + it('redirects to correct url for happy path', async () => { + await jest.isolateModulesAsync(async () => { + const { get } = await import('../../../src/api/auth/get'); + const redirectUri = 'waffles'; + formatSlackAuthUrlMock.mockReturnValueOnce(redirectUri); + + const mockReq = createMockRequest({ + query: { + [Config.global.authReturnUriParamName]: returnTo, + }, + }); + const mockRes = createMockResponse(); -describe('auth SLACK', () => { - it('redirects to correct url for happy path', async () => { - const redirectUri = 'waffles'; - formatSlackRedirectUriMock.mockReturnValueOnce(redirectUri); - const fullLink = `${slackAuthBaseUrl}redirect_uri=${redirectUri}&client_id=undefined`; + await get(mockReq as any, mockRes as any); - const mockReq = createMockRequest({ - query: { - [Config.global.authReturnUriParamName]: returnTo, - }, + expect(formatSlackAuthUrlMock).toBeCalledTimes(1); + expect(formatSlackAuthUrlMock).toBeCalledWith(expect.objectContaining({ returnTo })); + expect(mockRes.redirect).toHaveBeenCalledTimes(1); + expect(mockRes.redirect).toHaveBeenCalledWith(redirectUri); + }); }); - const mockRes = createMockResponse(); + }); - await get(mockReq as any, mockRes as any); + describe('pingfed redirect', () => { + beforeEach(() => { + Config.Auth.method = 'pingfed'; + }); + + it('redirects to correct url for happy path', async () => { + await jest.isolateModulesAsync(async () => { + const { get } = await import('../../../src/api/auth/get'); + const redirectUri = 'pancakes'; + formatPingfedAuthUrlMock.mockReturnValueOnce(redirectUri); + + const mockReq = createMockRequest({ + query: { + [Config.global.authReturnUriParamName]: returnTo, + }, + }); + const mockRes = createMockResponse(); - expect(formatSlackRedirectUriMock).toBeCalledWith(expect.objectContaining({ returnTo })); - expect(mockRes.redirect).toHaveBeenCalledTimes(1); - expect(mockRes.redirect).toHaveBeenCalledWith(fullLink); + await get(mockReq as any, mockRes as any); + + expect(formatPingfedAuthUrlMock).toBeCalledTimes(1); + expect(formatPingfedAuthUrlMock).toBeCalledWith(expect.objectContaining({ returnTo })); + expect(mockRes.redirect).toHaveBeenCalledTimes(1); + expect(mockRes.redirect).toHaveBeenCalledWith(redirectUri); + }); + }); }); }); diff --git a/packages/api/tests/utils/authenticateUser.test.ts b/packages/api/tests/api/auth/utils/authenticateUser.test.ts similarity index 90% rename from packages/api/tests/utils/authenticateUser.test.ts rename to packages/api/tests/api/auth/utils/authenticateUser.test.ts index 1eff4bbb3..26c2da039 100644 --- a/packages/api/tests/utils/authenticateUser.test.ts +++ b/packages/api/tests/api/auth/utils/authenticateUser.test.ts @@ -1,8 +1,8 @@ import { User } from '@hangar/database'; -import { authenticateUser, OAuthUserData } from '../../src/utils/authenticateUser'; -import { logger } from '../../src/utils/logger'; -import { createMockRequest } from '../testUtils/expressHelpers/createMockRequest'; -import { createMockResponse } from '../testUtils/expressHelpers/createMockResponse'; +import { authenticateUser, OAuthUserData } from '../../../../src/api/auth/utils/authenticateUser'; +import { logger } from '../../../../src/utils/logger'; +import { createMockRequest } from '../../../testUtils/expressHelpers/createMockRequest'; +import { createMockResponse } from '../../../testUtils/expressHelpers/createMockResponse'; const loggerErrorSpy = jest.spyOn(logger, 'error').mockImplementation(); diff --git a/packages/api/tests/api/auth/utils/formatRedirectUri.test.ts b/packages/api/tests/api/auth/utils/formatRedirectUri.test.ts new file mode 100644 index 000000000..b92ffee86 --- /dev/null +++ b/packages/api/tests/api/auth/utils/formatRedirectUri.test.ts @@ -0,0 +1,36 @@ +import { Config } from '@hangar/shared'; +import { mockEnv } from '../../../testUtils/mockEnv'; +import { formatRedirectUri } from '../../../../src/api/auth/utils/formatRedirectUri'; + +describe('formatRedirectUri', () => { + it('uses a returnTo if provided', () => { + const baseUrl = 'abc'; + mockEnv({ baseUrl }); + const returnTo = '/api/health'; + const redirectUri = formatRedirectUri({ returnTo }); + const encodedQueryValue = encodeURIComponent(returnTo); + expect(redirectUri).toEqual( + `${baseUrl}/api/auth/callback/${Config.Auth.method}/?${Config.global.authReturnUriParamName}=${encodedQueryValue}`, + ); + }); + + it('skips the returnTo if omitted', () => { + const baseUrl = 'abc'; + mockEnv({ baseUrl }); + const redirectUri = formatRedirectUri(); + expect(redirectUri).toEqual(`${baseUrl}/api/auth/callback/${Config.Auth.method}/`); + }); + + it('skips the returnTo if empty', () => { + const baseUrl = 'abc'; + mockEnv({ baseUrl }); + const redirectUri = formatRedirectUri({ returnTo: '' }); + expect(redirectUri).toEqual(`${baseUrl}/api/auth/callback/${Config.Auth.method}/`); + }); + + it('excludes a base url if it is not set', () => { + mockEnv({ baseUrl: undefined }); + const redirectUri = formatRedirectUri({ returnTo: '' }); + expect(redirectUri).toEqual(`/api/auth/callback/${Config.Auth.method}/`); + }); +}); diff --git a/packages/api/tests/slack/formatSlackRedirectUri.test.ts b/packages/api/tests/slack/formatSlackRedirectUri.test.ts deleted file mode 100644 index f7a95c93c..000000000 --- a/packages/api/tests/slack/formatSlackRedirectUri.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Config } from '@hangar/shared'; -import { formatSlackRedirectUri } from '../../src/slack/formatSlackRedirectUri'; -import { mockEnv } from '../testUtils/mockEnv'; - -describe('formatSlackRedirectUri', () => { - it('uses a returnTo if provided', () => { - const baseUrl = 'abc'; - mockEnv({ baseUrl }); - const returnTo = '/api/health'; - const redirectUri = formatSlackRedirectUri({ returnTo }); - const encodedQueryValue = encodeURIComponent(returnTo); - expect(redirectUri).toEqual( - `${baseUrl}/api/auth/callback/slack?${Config.global.authReturnUriParamName}=${encodedQueryValue}`, - ); - }); - - it('skips the returnTo if omitted', () => { - const baseUrl = 'abc'; - mockEnv({ baseUrl }); - const redirectUri = formatSlackRedirectUri(); - expect(redirectUri).toEqual(`${baseUrl}/api/auth/callback/slack`); - }); - - it('skips the returnTo if empty', () => { - const baseUrl = 'abc'; - mockEnv({ baseUrl }); - const redirectUri = formatSlackRedirectUri({ returnTo: '' }); - expect(redirectUri).toEqual(`${baseUrl}/api/auth/callback/slack`); - }); - - it('excludes a base url if it is not set', () => { - mockEnv({ baseUrl: undefined }); - const redirectUri = formatSlackRedirectUri({ returnTo: '' }); - expect(redirectUri).toEqual(`/api/auth/callback/slack`); - }); -}); diff --git a/packages/shared/src/config/auth.ts b/packages/shared/src/config/auth.ts new file mode 100644 index 000000000..724789755 --- /dev/null +++ b/packages/shared/src/config/auth.ts @@ -0,0 +1,11 @@ +type AuthMethod = 'slack' | 'pingfed'; // To support new methods, add them here +type AuthData = { + method: AuthMethod; +}; + +/** + * Configuration for authentication throughout the app + */ +export const Auth: AuthData = { + method: 'pingfed', // Change this value to drive auth throughout the app +}; diff --git a/packages/shared/src/config/index.ts b/packages/shared/src/config/index.ts index b62ea006e..f70412452 100644 --- a/packages/shared/src/config/index.ts +++ b/packages/shared/src/config/index.ts @@ -1,3 +1,4 @@ +export * from './auth'; export * from './global'; export * from './homepage'; export * from './project'; diff --git a/packages/web/src/components/layout/RedirectToAuthModal/PingfedContent/PingfedContent.tsx b/packages/web/src/components/layout/RedirectToAuthModal/PingfedContent/PingfedContent.tsx new file mode 100644 index 000000000..82e54c48e --- /dev/null +++ b/packages/web/src/components/layout/RedirectToAuthModal/PingfedContent/PingfedContent.tsx @@ -0,0 +1,35 @@ +import { Flex, Heading, Button } from '@chakra-ui/react'; + +type PingfedContentProps = { + secondsRemaining?: number; + onContinue: () => void; +}; + +export const PingfedContent: React.FC = ({ secondsRemaining, onContinue }) => ( + + + {secondsRemaining !== undefined + ? `Redirecting to login in ${secondsRemaining} seconds...` + : 'Redirecting...'} + + + +); diff --git a/packages/web/src/components/layout/RedirectToAuthModal/PingfedContent/index.ts b/packages/web/src/components/layout/RedirectToAuthModal/PingfedContent/index.ts new file mode 100644 index 000000000..63da4bae5 --- /dev/null +++ b/packages/web/src/components/layout/RedirectToAuthModal/PingfedContent/index.ts @@ -0,0 +1 @@ +export * from './PingfedContent'; diff --git a/packages/web/src/components/layout/RedirectToAuthModal/RedirectToAuthModal.tsx b/packages/web/src/components/layout/RedirectToAuthModal/RedirectToAuthModal.tsx index 803bf82d2..8e24864a5 100644 --- a/packages/web/src/components/layout/RedirectToAuthModal/RedirectToAuthModal.tsx +++ b/packages/web/src/components/layout/RedirectToAuthModal/RedirectToAuthModal.tsx @@ -1,28 +1,15 @@ /* eslint-disable max-lines */ import React from 'react'; -import { - Button, - Code, - Flex, - Heading, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalOverlay, - Text, - useClipboard, -} from '@chakra-ui/react'; -import { wait } from '@hangar/shared'; +import { Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from '@chakra-ui/react'; +import { Config, wait } from '@hangar/shared'; import { useRedirectToAuth } from './useRedirectToAuth'; -import { env } from '../../../env'; -import { JoinSlackButton } from '../../JoinSlackButton'; +import { SlackContent } from './SlackContent'; +import { PingfedContent } from './PingfedContent'; -const countdownDurationSeconds = 15; +const countdownDurationSeconds = Config.Auth.method === 'slack' ? 15 : 5; export const RedirectToAuthModal: React.FC = () => { const { redirect } = useRedirectToAuth(); - const { onCopy } = useClipboard(env.slackWorkspaceName ?? ''); const { isOpen, closeModal } = useRedirectToAuth(); const [secondsRemaining, setSecondsRemaining] = React.useState(); @@ -53,49 +40,24 @@ export const RedirectToAuthModal: React.FC = () => { }; }, [isOpen, redirect]); + const onContinue = () => { + redirect(); + setSecondsRemaining(undefined); + }; + return ( - - - {secondsRemaining !== undefined - ? `Redirecting to login in ${secondsRemaining} seconds...` - : 'Redirecting...'} - - - The next screen will ask for a Slack Workspace Name - - - Workspace Name: - {env.slackWorkspaceName} - - + {Config.Auth.method === 'slack' && ( + + )} - - + {Config.Auth.method === 'pingfed' && ( + + )} diff --git a/packages/web/src/components/layout/RedirectToAuthModal/SlackContent/SlackContent.tsx b/packages/web/src/components/layout/RedirectToAuthModal/SlackContent/SlackContent.tsx new file mode 100644 index 000000000..e24257625 --- /dev/null +++ b/packages/web/src/components/layout/RedirectToAuthModal/SlackContent/SlackContent.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { Flex, Heading, Code, Button, Text, useClipboard } from '@chakra-ui/react'; +import { env } from '../../../../env'; +import { JoinSlackButton } from '../../../JoinSlackButton'; + +type SlackContentProps = { + secondsRemaining?: number; + onContinue: () => void; +}; + +export const SlackContent: React.FC = ({ secondsRemaining, onContinue }) => { + const { onCopy } = useClipboard(env.slackWorkspaceName ?? ''); + + return ( + + + {secondsRemaining !== undefined + ? `Redirecting to login in ${secondsRemaining} seconds...` + : 'Redirecting...'} + + + The next screen will ask for a Slack Workspace Name + + + Workspace Name: + {env.slackWorkspaceName} + + + + + + ); +}; diff --git a/packages/web/src/components/layout/RedirectToAuthModal/SlackContent/index.ts b/packages/web/src/components/layout/RedirectToAuthModal/SlackContent/index.ts new file mode 100644 index 000000000..dfbd310dc --- /dev/null +++ b/packages/web/src/components/layout/RedirectToAuthModal/SlackContent/index.ts @@ -0,0 +1 @@ +export * from './SlackContent';