aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorGravatar Joe Banks <[email protected]>2021-03-07 15:27:35 +0000
committerGravatar GitHub <[email protected]>2021-03-07 15:27:35 +0000
commit4a704239573e578c592a4dca2668e1b025745c98 (patch)
treead5f40e8e76cf4ca5abda43e1198ddee721418f5 /src
parentMakes Code Inputs TextAreas (diff)
parentRemoves Unused OAuth Cleaning (diff)
Merge pull request #141 from python-discord/discord-oauth
Diffstat (limited to 'src')
-rw-r--r--src/api/auth.ts244
-rw-r--r--src/api/client.ts3
-rw-r--r--src/commonStyles.tsx32
-rw-r--r--src/components/OAuth2Button.tsx120
-rw-r--r--src/pages/CallbackPage.tsx5
-rw-r--r--src/pages/FormPage.tsx64
-rw-r--r--src/pages/LandingPage.tsx11
-rw-r--r--src/tests/components/OAuth2Button.test.tsx2
-rw-r--r--src/tests/pages/CallbackPage.test.tsx15
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"
+ });
});