diff options
| author | 2021-03-07 15:27:35 +0000 | |
|---|---|---|
| committer | 2021-03-07 15:27:35 +0000 | |
| commit | 4a704239573e578c592a4dca2668e1b025745c98 (patch) | |
| tree | ad5f40e8e76cf4ca5abda43e1198ddee721418f5 /src/api | |
| parent | Makes Code Inputs TextAreas (diff) | |
| parent | Removes Unused OAuth Cleaning (diff) | |
Merge pull request #141 from python-discord/discord-oauth
Diffstat (limited to 'src/api')
| -rw-r--r-- | src/api/auth.ts | 244 | ||||
| -rw-r--r-- | src/api/client.ts | 3 | 
2 files changed, 246 insertions, 1 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  }); | 
