diff options
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | src/api/auth.ts | 208 | ||||
-rw-r--r-- | src/pages/CallbackPage.tsx | 5 | ||||
-rw-r--r-- | src/tests/pages/CallbackPage.test.tsx | 15 | ||||
-rw-r--r-- | yarn.lock | 18 |
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" + }); }); @@ -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" |