diff options
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | src/api/auth.ts | 244 | ||||
-rw-r--r-- | src/api/client.ts | 3 | ||||
-rw-r--r-- | src/commonStyles.tsx | 32 | ||||
-rw-r--r-- | src/components/OAuth2Button.tsx | 120 | ||||
-rw-r--r-- | src/pages/CallbackPage.tsx | 5 | ||||
-rw-r--r-- | src/pages/FormPage.tsx | 64 | ||||
-rw-r--r-- | src/pages/LandingPage.tsx | 11 | ||||
-rw-r--r-- | src/tests/components/OAuth2Button.test.tsx | 2 | ||||
-rw-r--r-- | src/tests/pages/CallbackPage.test.tsx | 15 | ||||
-rw-r--r-- | yarn.lock | 18 |
11 files changed, 395 insertions, 120 deletions
diff --git a/package.json b/package.json index 9284410..2e5bf34 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "smoothscroll-polyfill": "0.4.4", "swc-loader": "0.1.12", "typescript": "4.2.3", + "universal-cookie": "4.0.4", "webpack": "5.24.3", "webpack-cli": "4.5.0", "webpack-manifest-plugin": "3.0.0", diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..160c832 --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,244 @@ +import Cookies, { CookieSetOptions } from "universal-cookie"; +import { AxiosResponse } from "axios"; + +import APIClient from "./client"; + +const OAUTH2_CLIENT_ID = process.env.REACT_APP_OAUTH2_CLIENT_ID; +const PRODUCTION = process.env.NODE_ENV !== "development"; + +/** + * Authorization result as returned from the backend. + */ +interface AuthResult { + username: string, + expiry: string +} + +/** + * Name properties for authorization cookies. + */ +enum CookieNames { + Scopes = "DiscordOAuthScopes", + Username = "DiscordUsername" +} + +export interface APIErrors { + Message: APIErrorMessages, + Error: any, /* eslint-disable-line @typescript-eslint/no-explicit-any */ +} + +export enum APIErrorMessages { + BackendValidation = "Backend could not authorize with Discord. Please contact the forms team.", + BackendValidationDev = "Backend could not authorize with Discord, possibly due to being on a preview branch. Please contact the forms team.", + BackendUnresponsive = "Unable to reach the backend, please retry, or contact the forms team.", + BadResponse = "The server returned a bad response, please contact the forms team.", + Unknown = "An unknown error occurred, please contact the forms team." +} + +/** + * [Reference]{@link https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes} + * + * Commented out enums are locked behind whitelists. + */ +export enum OAuthScopes { + Connections = "connections", + Email = "email", + Identify = "identify", + Guilds = "guilds" +} + +/** + * Helper method to ensure the minimum required scopes + * for the application to function exist in a list. + */ +function ensureMinimumScopes(scopes: unknown, expected: OAuthScopes | OAuthScopes[]): OAuthScopes[] { + let result: OAuthScopes[] = []; + if (scopes && Array.isArray(scopes)) { + result = scopes; + } + + if (Array.isArray(expected)) { + expected.forEach(scope => { if (!result.includes(scope)) result.push(scope); }); + } else { + if (!result.includes(expected)) result.push(expected); + } + + return result; +} + +/** + * Return true if the program has the requested scopes or higher. + */ +export function checkScopes(scopes?: OAuthScopes[]): boolean { + const cleanedScopes = ensureMinimumScopes(scopes, OAuthScopes.Identify); + + // Get Active Scopes And Ensure Type + const cookies = new Cookies().get(CookieNames.Scopes); + if (!cookies || !Array.isArray(cookies)) { + return false; + } + + // Check For Scope Existence + for (const scope of cleanedScopes) { + if (!cookies.includes(scope)) { + return false; + } + } + + return true; +} + +/*** + * Request authorization code from the discord api with the provided scopes. + * + * @returns {code, cleanedScopes} The discord authorization code and the scopes the code is granted for. + * @throws {Error} Indicates that an integrity check failed. + */ +export async function getDiscordCode(scopes: OAuthScopes[]): Promise<{code: string, cleanedScopes: OAuthScopes[]}> { + const cleanedScopes = ensureMinimumScopes(scopes, OAuthScopes.Identify); + + // Generate a new user state + const state = crypto.getRandomValues(new Uint32Array(1))[0]; + + const scopeString = encodeURIComponent(cleanedScopes.join(" ")); + const redirectURI = encodeURIComponent(document.location.protocol + "//" + document.location.host + "/callback"); + + // Open login window + const windowRef = window.open( + `https://discord.com/api/oauth2/authorize?client_id=${OAUTH2_CLIENT_ID}&state=${state}&response_type=code&scope=${scopeString}&redirect_uri=${redirectURI}&prompt=none`, + "Discord_OAuth2", + "height=700,width=500,location=no,menubar=no,resizable=no,status=no,titlebar=no,left=300,top=300" + ); + + // Handle response + const code = await new Promise<string>(resolve => { + window.onmessage = (message: MessageEvent) => { + if (message.data.source) { + // React DevTools has a habit of sending messages, ignore them. + return; + } + + if (message.isTrusted) { + windowRef?.close(); + + // State integrity check + if (message.data.state !== state.toString()) { + // This indicates a lack of integrity + throw Error("Integrity check failed."); + } + + // Remove handler + window.onmessage = null; + resolve(message.data.code); + } + }; + }); + + return {code: code, cleanedScopes: cleanedScopes}; +} + +/** + * Sends a discord code to the backend, which sets an authentication JWT + * and returns the Discord username. + * + * @throws { APIErrors } On error, the APIErrors.Message is set, and an APIErrors object is thrown. + */ +export async function requestBackendJWT(code: string): Promise<{username: string, maxAge: number}> { + const reason: APIErrors = { Message: APIErrorMessages.Unknown, Error: null }; + let result; + + try { + result = await APIClient.post("/auth/authorize", {token: code}) + .catch(error => { + reason.Error = error; + + if (error.response) { + // API Responded with a non-2xx Response + if (error.response.status === 400) { + reason.Message = process.env.CONTEXT === "deploy-preview" ? APIErrorMessages.BackendValidationDev : APIErrorMessages.BackendValidation; + } + } else if (error.request) { + // API did not respond + reason.Message = APIErrorMessages.BackendUnresponsive; + } + + throw error; + + }).then((response: AxiosResponse<AuthResult>) => { + const expiry = Date.parse(response.data.expiry); + return {username: response.data.username, maxAge: (expiry - Date.now()) / 1000}; + }); + } catch (e) { + if (reason.Error === null) { + reason.Error = e; + } + + throw reason; + } + + if (!result || !result.username || !result.maxAge) { + reason.Message = APIErrorMessages.BadResponse; + throw reason; + } + + return result; +} + +/** + * Refresh the backend authentication JWT. Returns the success of the operation, and silently handles denied requests. + */ +export async function refreshBackendJWT(): Promise<boolean> { + const cookies = new Cookies(); + + let pass = true; + APIClient.post("/auth/refresh").then((response: AxiosResponse<AuthResult>) => { + cookies.set(CookieNames.Username, response.data.username, {sameSite: "strict", secure: PRODUCTION, path: "/", expires: new Date(3000, 1)}); + + const expiry = Date.parse(response.data.expiry); + setTimeout(refreshBackendJWT, (expiry * 0.9)); + }).catch(() => { + pass = false; + cookies.remove(CookieNames.Scopes); + }); + + return new Promise(resolve => resolve(pass)); +} + +/** + * Handle a full authorization flow. Sets a cookie with the JWT and scopes. + * + * @param scopes The scopes that should be authorized for the application. + * @param disableFunction An optional function that can disable a component while processing. + * @param refresh If true, the token refresh will be scehduled automatically + * + * @throws { APIErrors } See documentation on { requestBackendJWT }. + */ +export default async function authorize(scopes: OAuthScopes[] = [], disableFunction?: (newState: boolean) => void, refresh = true): Promise<void> { + if (checkScopes(scopes)) { + return; + } + + const cookies = new Cookies; + cookies.remove(CookieNames.Scopes); + + if (disableFunction) { disableFunction(true); } + await getDiscordCode(scopes).then(async discord_response =>{ + await requestBackendJWT(discord_response.code).then(backend_response => { + const options: CookieSetOptions = {sameSite: "strict", secure: PRODUCTION, path: "/", expires: new Date(3000, 1)}; + cookies.set(CookieNames.Username, backend_response.username, options); + + options.maxAge = backend_response.maxAge; + cookies.set(CookieNames.Scopes, discord_response.cleanedScopes, options); + + if (refresh) { + // Schedule refresh after 90% of it's age + setTimeout(refreshBackendJWT, (backend_response.maxAge * 0.9) * 1000); + } + }); + }).finally(() => { + if (disableFunction) { disableFunction(false); } + }); + + + return new Promise<void>(resolve => resolve()); +} diff --git a/src/api/client.ts b/src/api/client.ts index b534938..a9499cc 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -2,5 +2,6 @@ import axios from "axios"; export default axios.create({ - baseURL: process.env.BACKEND_URL + baseURL: process.env.BACKEND_URL, + withCredentials: true }); diff --git a/src/commonStyles.tsx b/src/commonStyles.tsx index b2969f8..bfae17e 100644 --- a/src/commonStyles.tsx +++ b/src/commonStyles.tsx @@ -51,6 +51,37 @@ const textInputs = css` border-radius: 8px; `; +const submitStyles = css` + text-align: right; + white-space: nowrap; + + button:disabled { + background-color: ${colors.greyple}; + cursor: default; + } + + button { + cursor: pointer; + + border: none; + border-radius: 8px; + + color: white; + font: inherit; + + background-color: ${colors.blurple}; + transition: background-color 300ms; + } + + button[type="submit"] { + padding: 0.55rem 4.25rem; + } + + button:enabled:hover { + background-color: ${colors.darkerBlurple}; + } +`; + const invalidStyles = css` .invalid-box { -webkit-appearance: none; @@ -66,5 +97,6 @@ export { hiddenInput, multiSelectInput, textInputs, + submitStyles, invalidStyles }; diff --git a/src/components/OAuth2Button.tsx b/src/components/OAuth2Button.tsx index 4fa3f61..885c080 100644 --- a/src/components/OAuth2Button.tsx +++ b/src/components/OAuth2Button.tsx @@ -1,88 +1,80 @@ /** @jsx jsx */ import { css, jsx } from "@emotion/react"; +import React, { useState } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faDiscord } from "@fortawesome/free-brands-svg-icons"; -import colors from "../colors"; -import { useState } from "react"; - -const OAUTH2_CLIENT_ID = process.env.REACT_APP_OAUTH2_CLIENT_ID; - -const buttonStyling = css` -display: flex; -background-color: ${colors.blurple}; -border: none; -color: white; -font-family: "Hind", "Helvetica", "Arial", sans-serif; -border-radius: 5px; -padding-top: 10px; -padding-bottom: 10px; -padding-right: 20px; -padding-left: 20px; -outline: none; -transition: filter 100ms; -font-size: 1.2em; -align-items: center; - -span { - vertical-align: middle; -} +import authenticate, { APIErrors, checkScopes, OAuthScopes } from "../api/auth"; +import { selectable } from "../commonStyles"; -&:hover:enabled { - filter: brightness(110%); - cursor: pointer; -} -&:disabled { - background-color: ${colors.greyple}; +interface OAuth2ButtonProps { + scopes?: OAuthScopes[], + rerender: () => void } + +const iconStyles = css` + position: relative; + top: 0.3rem; + padding-left: 0.65rem; + font-size: 1.2em; `; -function doLogin(disableFunction: (newState: boolean) => void) { - disableFunction(true); +const textStyles = css` + display: inline-block; + padding: 0.5rem 0.75rem 0.5rem 0.5rem; +`; - const redirectURI = encodeURIComponent(document.location.protocol + "//" + document.location.host + "/callback"); +const errorStyles = css` + position: absolute; + visibility: hidden; + width: 100%; + left: 0; - const windowRef = window.open( - `https://discord.com/api/oauth2/authorize?client_id=${OAUTH2_CLIENT_ID}&response_type=code&scope=identify&redirect_uri=${redirectURI}&prompt=none`, - "Discord_OAuth2", - "height=700,width=500,location=no,menubar=no,resizable=no,status=no,titlebar=no,left=300,top=300" - ); + text-align: center; + white-space: normal; + box-sizing: border-box; - const interval = setInterval(() => { - if (windowRef?.closed) { - clearInterval(interval); - disableFunction(false); - } - }, 500); + padding: 0 15rem; + @media (max-width: 750px) { + padding: 0 5rem; + } - window.onmessage = (code: MessageEvent) => { - if (code.data.source) { - // React DevTools has a habit of sending messages, ignore them. - return; - } - - if (code.isTrusted) { - windowRef?.close(); + color: red; + margin-top: 2.5rem; +`; - console.log("Code received:", code.data); +async function login(props: OAuth2ButtonProps, errorDialog: React.RefObject<HTMLDivElement>, setDisabled: (newState: boolean) => void) { + await authenticate(props.scopes, setDisabled).catch((reason: APIErrors) => { + // Display Error Message + if (errorDialog.current) { + errorDialog.current.style.visibility = "visible"; + errorDialog.current.textContent = reason.Message; + errorDialog.current.scrollIntoView({behavior: "smooth"}); + } - disableFunction(false); - clearInterval(interval); + // Propagate to sentry + reason.Error.stack = new Error(`OAuth: ${reason.Message}`).stack + "\n" + reason.Error.stack; + throw reason.Error; + }); - window.onmessage = null; - } - }; + if (checkScopes(props.scopes)) { + props.rerender(); + } } -function OAuth2Button(): JSX.Element { +function OAuth2Button(props: OAuth2ButtonProps): JSX.Element { const [disabled, setDisabled] = useState<boolean>(false); - - return <button disabled={disabled} onClick={() => doLogin(setDisabled)} css={buttonStyling}> - <span css={{marginRight: "10px"}}><FontAwesomeIcon icon={faDiscord} css={{fontSize: "2em", marginTop: "3px"}}/></span> - <span>Sign in with Discord</span> - </button>; + const errorDialog: React.RefObject<HTMLDivElement> = React.useRef(null); + + return <span> + <button disabled={disabled} onClick={() => login(props, errorDialog, setDisabled)}> + <FontAwesomeIcon icon={faDiscord} css={iconStyles}/> + <span css={textStyles}>Discord Login</span> + </button> + <div css={[errorStyles, selectable]} ref={errorDialog}/> + </span>; } export default OAuth2Button; diff --git a/src/pages/CallbackPage.tsx b/src/pages/CallbackPage.tsx index fab2086..00feb76 100644 --- a/src/pages/CallbackPage.tsx +++ b/src/pages/CallbackPage.tsx @@ -7,11 +7,12 @@ export default function CallbackPage(): JSX.Element { const params = new URLSearchParams(location.search); const code = params.get("code"); + const state = params.get("state"); if (!hasSent) { setHasSent(true); - window.opener.postMessage(code); + window.opener.postMessage({code: code, state: state}); } - return <p>Code is {code}</p>; + return <div/>; } diff --git a/src/pages/FormPage.tsx b/src/pages/FormPage.tsx index 1e331b9..8852ac5 100644 --- a/src/pages/FormPage.tsx +++ b/src/pages/FormPage.tsx @@ -10,10 +10,12 @@ import HeaderBar from "../components/HeaderBar"; import RenderedQuestion from "../components/Question"; import Loading from "../components/Loading"; import ScrollToTop from "../components/ScrollToTop"; +import OAuth2Button from "../components/OAuth2Button"; import { Form, FormFeatures, getForm } from "../api/forms"; +import { OAuthScopes, checkScopes } from "../api/auth"; import colors from "../colors"; -import { unselectable } from "../commonStyles"; +import { submitStyles, unselectable } from "../commonStyles"; import { Question, QuestionType } from "../api/question"; import ApiClient from "../api/client"; @@ -22,7 +24,8 @@ interface PathParams { } interface NavigationProps { - form_state: boolean // Whether the form is open or not + form_state: boolean, // Whether the form is open or not + scopes: OAuthScopes[] } class Navigation extends React.Component<NavigationProps> { @@ -39,7 +42,7 @@ class Navigation extends React.Component<NavigationProps> { width: 50%; } - @media (max-width: 850px) { + @media (max-width: 870px) { width: 100%; > div { @@ -63,13 +66,13 @@ class Navigation extends React.Component<NavigationProps> { height: 0; display: none; - @media (max-width: 850px) { + @media (max-width: 870px) { display: block; } `; static returnStyles = css` - padding: 0.5rem 2rem; + padding: 0.5rem 2.2rem; border-radius: 8px; color: white; @@ -84,37 +87,23 @@ class Navigation extends React.Component<NavigationProps> { } `; - submitStyles = css` - text-align: right; - white-space: nowrap; - - button { - padding: 0.5rem 4rem; - cursor: pointer; - - border: none; - border-radius: 8px; - - color: white; - font: inherit; - - background-color: ${colors.blurple}; - transition: background-color 300ms; - } - - button:hover { - background-color: ${colors.darkerBlurple}; - } - `; + constructor(props: NavigationProps) { + super(props); + this.setState({"logged_in": false}); + } render(): JSX.Element { let submit = null; + if (this.props.form_state) { - submit = ( - <div css={this.submitStyles}> - <button form="form" type="submit">Submit</button> - </div> - ); + let inner_submit; + if (this.props.scopes.includes(OAuthScopes.Identify) && !checkScopes(this.props.scopes)) { + // Render OAuth button if login is required, and the scopes needed are not available + inner_submit = <OAuth2Button scopes={this.props.scopes} rerender={() => this.setState({"logged_in": true})}/>; + } else { + inner_submit = <button form="form" type="submit">Submit</button>; + } + submit = <div css={submitStyles}>{ inner_submit }</div>; } return ( @@ -148,7 +137,7 @@ const closedHeaderStyles = css` font-size: 1.5rem; background-color: ${colors.error}; - + @media (max-width: 500px) { padding: 1rem 1.5rem; } @@ -282,6 +271,13 @@ function FormPage(): JSX.Element { } const open: boolean = form.features.includes(FormFeatures.Open); + const require_auth: boolean = form.features.includes(FormFeatures.RequiresLogin); + + const scopes = []; + if (require_auth) { + scopes.push(OAuthScopes.Identify); + if (form.features.includes(FormFeatures.CollectEmail)) { scopes.push(OAuthScopes.Email); } + } let closed_header = null; if (!open) { @@ -297,7 +293,7 @@ function FormPage(): JSX.Element { { closed_header } { questions } </form> - <Navigation form_state={open}/> + <Navigation form_state={open} scopes={scopes}/> </div> <div css={css`margin-bottom: 10rem`}/> diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 06fef46..efb3f05 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -4,7 +4,6 @@ import { useEffect, useState } from "react"; import HeaderBar from "../components/HeaderBar"; import FormListing from "../components/FormListing"; -import OAuth2Button from "../components/OAuth2Button"; import Loading from "../components/Loading"; import ScrollToTop from "../components/ScrollToTop"; @@ -28,16 +27,8 @@ function LandingPage(): JSX.Element { <HeaderBar/> <ScrollToTop/> <div> - <div css={css` - display: flex; - align-items: center; - flex-direction: column; - `}> + <div css={css`display: flex; align-items: center; flex-direction: column;`}> <h1>Available Forms</h1> - - <OAuth2Button/> - - {forms.map(form => ( <FormListing key={form.id} form={form}/> ))} diff --git a/src/tests/components/OAuth2Button.test.tsx b/src/tests/components/OAuth2Button.test.tsx index f05159f..a773686 100644 --- a/src/tests/components/OAuth2Button.test.tsx +++ b/src/tests/components/OAuth2Button.test.tsx @@ -4,7 +4,7 @@ import OAuth2Button from "../../components/OAuth2Button"; test("renders oauth2 sign in button text", () => { const { getByText } = render(<OAuth2Button />); - const button = getByText(/Sign in with Discord/i); + const button = getByText(/Discord Login/i); expect(button).toBeInTheDocument(); }); diff --git a/src/tests/pages/CallbackPage.test.tsx b/src/tests/pages/CallbackPage.test.tsx index 9049ca3..70f2fed 100644 --- a/src/tests/pages/CallbackPage.test.tsx +++ b/src/tests/pages/CallbackPage.test.tsx @@ -3,21 +3,20 @@ import { render } from "@testing-library/react"; import CallbackPage from "../../pages/CallbackPage"; -test("callback page renders provided code", () => { +test("callback page sends provided code", () => { global.opener = { postMessage: jest.fn() }; - const mockLocation = new URL("https://forms.pythondiscord.com/authorize?code=abcdef"); + const mockLocation = new URL("https://forms.pythondiscord.com/authorize?code=abcde_code&state=abcde_state"); Object.defineProperty(global, "location", {value: mockLocation}); - const comp = <CallbackPage />; + render(<CallbackPage/>); - const { getByText } = render(comp); - - - const codeText = getByText(/Code is abcdef/); - expect(codeText).toBeInTheDocument(); expect(global.opener.postMessage).toBeCalledTimes(1); + expect(global.opener.postMessage).toBeCalledWith({ + code: "abcde_code", + state: "abcde_state" + }); }); @@ -1969,6 +1969,11 @@ dependencies: "@babel/types" "^7.3.0" +"@types/cookie@^0.3.3": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803" + integrity sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow== + "@types/eslint-scope@^3.7.0": version "3.7.0" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.0.tgz#4792816e31119ebd506902a482caec4951fabd86" @@ -3312,6 +3317,11 @@ [email protected]: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== +cookie@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" + integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== + copy-descriptor@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" @@ -8656,6 +8666,14 @@ unique-string@^2.0.0: dependencies: crypto-random-string "^2.0.0" + version "4.0.4" + resolved "https://registry.yarnpkg.com/universal-cookie/-/universal-cookie-4.0.4.tgz#06e8b3625bf9af049569ef97109b4bb226ad798d" + integrity sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw== + dependencies: + "@types/cookie" "^0.3.3" + cookie "^0.4.0" + universalify@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" |