diff options
Diffstat (limited to 'src')
| -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 | 
9 files changed, 376 insertions, 120 deletions
| 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" +    });  }); | 
