aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--package.json1
-rw-r--r--src/api/auth.ts208
-rw-r--r--src/pages/CallbackPage.tsx5
-rw-r--r--src/tests/pages/CallbackPage.test.tsx15
-rw-r--r--yarn.lock18
5 files changed, 237 insertions, 10 deletions
diff --git a/package.json b/package.json
index f4b6b12..8c5b69d 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"smoothscroll-polyfill": "0.4.4",
"swc-loader": "0.1.12",
"typescript": "4.1.4",
+ "universal-cookie": "4.0.4",
"webpack": "5.21.2",
"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..ae20236
--- /dev/null
+++ b/src/api/auth.ts
@@ -0,0 +1,208 @@
+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 {
+ token: string,
+ expiry: string
+}
+
+interface JWTResponse {
+ JWT: string,
+ Expiry: Date
+}
+
+/**
+ * Name properties for authorization cookies.
+ */
+enum CookieNames {
+ Scopes = "DiscordOAuthScopes",
+ Token = "FormBackendToken"
+}
+
+/**
+ * [Reference]{@link https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes}
+ *
+ * Commented out enums are locked behind whitelists.
+ */
+export enum OAuthScopes {
+ Bot = "bot",
+ Connections = "connections",
+ Email = "email",
+ Identify = "identify",
+ Guilds = "guilds",
+ GuildsJoin = "guilds.join",
+ GDMJoin = "gdm.join",
+ MessagesRead = "messages.read",
+ // RPC = "rpc",
+ // RPC_API = "rpc.api",
+ // RPCNotifRead = "rpc.notifications.read",
+ WebhookIncoming = "webhook.incoming",
+ // AppsBuildsUpload = "applications.builds.upload",
+ AppsBuildsRead = "applications.builds.read",
+ AppsStoreUpdate = "applications.store.update",
+ AppsEntitlements = "applications.entitlements",
+ // RelationshipsRead = "relationships.read",
+ // ActivitiesRead = "activities.read",
+ // ActivitiesWrite = "activities.write",
+ AppsCommands = "applications.commands",
+ AppsCommandsUpdate = "applications.commands.update"
+}
+
+/**
+ * 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[], path = ""): boolean {
+ const cleanedScopes = ensureMinimumScopes(scopes, OAuthScopes.Identify);
+
+ // Get Active Scopes And Ensure Type
+ const cookies = new Cookies().get(CookieNames.Scopes + path);
+ 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.
+ *
+ * If disable function is passed, the component will be disabled while the login is ongoing.
+ *
+ * @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[], disableFunction?: (newState: boolean) => void): Promise<{code: string, cleanedScopes: OAuthScopes[]}> {
+ const cleanedScopes = ensureMinimumScopes(scopes, OAuthScopes.Identify);
+ const disable = (newState: boolean) => { if (disableFunction) disableFunction(newState); };
+
+ disable(true);
+
+ // 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"
+ );
+
+ // Clean up on login
+ const interval = setInterval(() => {
+ if (windowRef?.closed) {
+ clearInterval(interval);
+ disable(false);
+ }
+ }, 500);
+
+ // 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();
+
+ clearInterval(interval);
+ disable(false);
+
+ // 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 from a given path to the backend,
+ * and returns the resultant JWT and expiry date.
+ */
+export async function requestBackendJWT(code: string): Promise<JWTResponse> {
+ const result = await APIClient.post("/auth/authorize", {token: code})
+ .catch(reason => { throw reason; }) // TODO: Show some sort of authentication failure here
+ .then((response: AxiosResponse<AuthResult>) => {
+ const _expiry = new Date();
+ _expiry.setTime(Date.parse(response.data.expiry));
+
+ return {JWT: response.data.token, Expiry: _expiry};
+ });
+
+ if (!result.JWT || !result.Expiry) {
+ throw Error("Could not fetch OAuth code.");
+ }
+
+ return result;
+}
+
+/**
+ * Handle a full authorization flow. Sets a token for the specified path 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 path The site path to save the token under.
+ */
+export default function authorize(scopes?: OAuthScopes[], disableFunction?: (newState: boolean) => void, path = "/"): void {
+ if (!checkScopes(scopes, path)) {
+ const cookies = new Cookies;
+ cookies.remove(CookieNames.Token + path);
+ cookies.remove(CookieNames.Scopes + path);
+
+ getDiscordCode(scopes || [], disableFunction).then(discord_response =>{
+ requestBackendJWT(discord_response.code).then(backend_response => {
+ const options: CookieSetOptions = {sameSite: "strict", expires: backend_response.Expiry, secure: PRODUCTION, path: path};
+
+ cookies.set(CookieNames.Token + path, backend_response.JWT, options);
+ cookies.set(CookieNames.Scopes + path, discord_response.cleanedScopes, options);
+ });
+ });
+ }
+}
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/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"
+ });
});
diff --git a/yarn.lock b/yarn.lock
index cf04f93..131a86c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1970,6 +1970,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"
@@ -3318,6 +3323,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"
@@ -8739,6 +8749,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"