import ko from "knockout"; import postRobot from "post-robot"; import { GetGithubClientId } from "Utils/GitHubUtils"; import { HttpStatusCodes } from "../Common/Constants"; import { handleError } from "../Common/ErrorHandlingUtils"; import { AuthorizeAccessComponent } from "../Explorer/Controls/GitHub/AuthorizeAccessComponent"; import { JunoClient } from "../Juno/JunoClient"; import { logConsoleInfo } from "../Utils/NotificationConsoleUtils"; import { GitHubConnectorMsgType, IGitHubConnectorParams } from "./GitHubConnector"; postRobot.on( GitHubConnectorMsgType, { domain: window.location.origin, }, (event) => { // Typescript definition for event is wrong. So read params by casting to // eslint-disable-next-line @typescript-eslint/no-explicit-any const params = (event as any).data as IGitHubConnectorParams; window.dataExplorer.notebookManager?.gitHubOAuthService.finishOAuth(params); } ); export interface IGitHubOAuthToken { // API properties access_token?: string; scope?: string; token_type?: string; error?: string; error_description?: string; } export class GitHubOAuthService { private static readonly OAuthEndpoint = "https://github.com/login/oauth/authorize"; private state: string; private token: ko.Observable; constructor(private junoClient: JunoClient) { this.token = ko.observable(); } public async startOAuth(scope: string): Promise { // If attempting to change scope from "Public & private repos" to "Public only" we need to delete app authorization. // Otherwise OAuth app still retains the "public & private repos" permissions. if ( this.token()?.scope === AuthorizeAccessComponent.Scopes.PublicAndPrivate.key && scope === AuthorizeAccessComponent.Scopes.Public.key ) { const logoutSuccessful = await this.logout(); if (!logoutSuccessful) { return undefined; } } const params = { scope, client_id: GetGithubClientId(), redirect_uri: new URL("./connectToGitHub.html", window.location.href).href, state: this.resetState(), }; window.open(`${GitHubOAuthService.OAuthEndpoint}?${new URLSearchParams(params).toString()}`); return params.state; } public async finishOAuth(params: IGitHubConnectorParams): Promise { try { this.validateState(params.state); const response = await this.junoClient.getGitHubToken(params.code); if (response.status === HttpStatusCodes.OK && !response.data.error) { logConsoleInfo("Successfully connected to GitHub"); this.token(response.data); } else { let errorMsg = response.data.error; if (response.data.error_description) { errorMsg = `${errorMsg}: ${response.data.error_description}`; } throw new Error(errorMsg); } } catch (error) { logConsoleInfo(`Failed to connect to GitHub: ${error}`); this.token({ error }); } } public getTokenObservable(): ko.Observable { return this.token; } public async logout(): Promise { try { const response = await this.junoClient.deleteAppAuthorization(this.token()?.access_token); if (response.status !== HttpStatusCodes.NoContent) { throw new Error(`Received HTTP ${response.status}: ${response.data} when deleting app authorization`); } this.resetToken(); return true; } catch (error) { handleError(error, "GitHubOAuthService/logout", "Failed to delete app authorization"); return false; } } public isLoggedIn(): boolean { return !!this.token()?.access_token; } private resetState(): string { this.state = Math.floor(Math.random() * Math.floor(Number.MAX_SAFE_INTEGER)).toString(); return this.state; } public resetToken(): void { this.token(undefined); } private validateState(state: string) { if (state !== this.state) { throw new Error("State didn't match. Possibility of cross-site request forgery attack."); } } }