Skip to content

Commit

Permalink
[Refactor]: Changes to Github Authentication (#5371)
Browse files Browse the repository at this point in the history
Co-authored-by: openhands <[email protected]>
Co-authored-by: sp.wack <[email protected]>
Co-authored-by: Engel Nyst <[email protected]>
  • Loading branch information
4 people authored Dec 17, 2024
1 parent dc3e43b commit f9d052c
Show file tree
Hide file tree
Showing 35 changed files with 618 additions and 453 deletions.
5 changes: 0 additions & 5 deletions frontend/public/config.json

This file was deleted.

86 changes: 84 additions & 2 deletions frontend/src/api/github-axios-instance.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import axios from "axios";
import axios, { AxiosError } from "axios";

const github = axios.create({
baseURL: "https://api.github.com",
Expand All @@ -18,4 +18,86 @@ const removeAuthTokenHeader = () => {
}
};

export { github, setAuthTokenHeader, removeAuthTokenHeader };
/**
* Checks if response has attributes to perform refresh
*/
const canRefresh = (error: unknown): boolean =>
!!(
error instanceof AxiosError &&
error.config &&
error.response &&
error.response.status
);

/**
* Checks if the data is a GitHub error response
* @param data The data to check
* @returns Boolean indicating if the data is a GitHub error response
*/
export const isGitHubErrorReponse = <T extends object | Array<unknown>>(
data: T | GitHubErrorReponse | null,
): data is GitHubErrorReponse =>
!!data && "message" in data && data.message !== undefined;

// Axios interceptor to handle token refresh
const setupAxiosInterceptors = (
refreshToken: () => Promise<boolean>,
logout: () => void,
) => {
github.interceptors.response.use(
// Pass successful responses through
(response) => {
const parsedData = response.data;
if (isGitHubErrorReponse(parsedData)) {
const error = new AxiosError(
"Failed",
"",
response.config,
response.request,
response,
);
throw error;
}
return response;
},
// Retry request exactly once if token is expired
async (error) => {
if (!canRefresh(error)) {
return Promise.reject(new Error("Failed to refresh token"));
}

const originalRequest = error.config;

// Check if the error is due to an expired token
if (
error.response.status === 401 &&
!originalRequest._retry // Prevent infinite retry loops
) {
originalRequest._retry = true;
try {
const refreshed = await refreshToken();
if (refreshed) {
return await github(originalRequest);
}

logout();
return await Promise.reject(new Error("Failed to refresh token"));
} catch (refreshError) {
// If token refresh fails, evict the user
logout();
return Promise.reject(refreshError);
}
}

// If the error is not due to an expired token, propagate the error
return Promise.reject(error);
},
);
};

export {
github,
setAuthTokenHeader,
removeAuthTokenHeader,
setupAxiosInterceptors,
};
114 changes: 66 additions & 48 deletions frontend/src/api/github.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,81 @@
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
import { github } from "./github-axios-instance";
import { openHands } from "./open-hands-axios";

/**
* Checks if the data is a GitHub error response
* @param data The data to check
* @returns Boolean indicating if the data is a GitHub error response
* Given the user, retrieves app installations IDs for OpenHands Github App
* Uses user access token for Github App
*/
export const isGitHubErrorReponse = <T extends object | Array<unknown>>(
data: T | GitHubErrorReponse | null,
): data is GitHubErrorReponse =>
!!data && "message" in data && data.message !== undefined;
export const retrieveGitHubAppInstallations = async (): Promise<number[]> => {
const response = await github.get<GithubAppInstallation>(
"/user/installations",
);

return response.data.installations.map((installation) => installation.id);
};

/**
* Given a GitHub token, retrieves the repositories of the authenticated user
* @param token The GitHub token
* @returns A list of repositories or an error response
* Retrieves repositories where OpenHands Github App has been installed
* @param installationIndex Pagination cursor position for app installation IDs
* @param installations Collection of all App installation IDs for OpenHands Github App
* @returns A list of repositories
*/
export const retrieveGitHubUserRepositories = async (
export const retrieveGitHubAppRepositories = async (
installationIndex: number,
installations: number[],
page = 1,
per_page = 30,
) => {
const response = await github.get<GitHubRepository[]>("/user/repos", {
params: {
sort: "pushed",
page,
per_page,
const installationId = installations[installationIndex];
const response = await openHands.get<GitHubAppRepository>(
"/api/github/repositories",
{
params: {
sort: "pushed",
page,
per_page,
installation_id: installationId,
},
},
transformResponse: (data) => {
const parsedData: GitHubRepository[] | GitHubErrorReponse =
JSON.parse(data);
);

if (isGitHubErrorReponse(parsedData)) {
throw new Error(parsedData.message);
}
const link = response.headers.link ?? "";
const nextPage = extractNextPageFromLink(link);
let nextInstallation: number | null;

if (nextPage) {
nextInstallation = installationIndex;
} else if (installationIndex + 1 < installations.length) {
nextInstallation = installationIndex + 1;
} else {
nextInstallation = null;
}

return {
data: response.data.repositories,
nextPage,
installationIndex: nextInstallation,
};
};

return parsedData;
/**
* Given a PAT, retrieves the repositories of the user
* @returns A list of repositories
*/
export const retrieveGitHubUserRepositories = async (
page = 1,
per_page = 30,
) => {
const response = await openHands.get<GitHubRepository[]>(
"/api/github/repositories",
{
params: {
sort: "pushed",
page,
per_page,
},
},
});
);

const link = response.headers.link ?? "";
const nextPage = extractNextPageFromLink(link);
Expand All @@ -46,21 +85,10 @@ export const retrieveGitHubUserRepositories = async (

/**
* Given a GitHub token, retrieves the authenticated user
* @param token The GitHub token
* @returns The authenticated user or an error response
*/
export const retrieveGitHubUser = async () => {
const response = await github.get<GitHubUser>("/user", {
transformResponse: (data) => {
const parsedData: GitHubUser | GitHubErrorReponse = JSON.parse(data);

if (isGitHubErrorReponse(parsedData)) {
throw new Error(parsedData.message);
}

return parsedData;
},
});
const response = await github.get<GitHubUser>("/user");

const { data } = response;

Expand All @@ -79,24 +107,14 @@ export const retrieveGitHubUser = async () => {
export const retrieveLatestGitHubCommit = async (
repository: string,
): Promise<GitHubCommit> => {
const response = await github.get<GitHubCommit>(
const response = await github.get<GitHubCommit[]>(
`/repos/${repository}/commits`,
{
params: {
per_page: 1,
},
transformResponse: (data) => {
const parsedData: GitHubCommit[] | GitHubErrorReponse =
JSON.parse(data);

if (isGitHubErrorReponse(parsedData)) {
throw new Error(parsedData.message);
}

return parsedData[0];
},
},
);

return response.data;
return response.data[0];
};
18 changes: 17 additions & 1 deletion frontend/src/api/open-hands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ class OpenHands {
}

static async getConfig(): Promise<GetConfigResponse> {
const { data } = await openHands.get<GetConfigResponse>("/config.json");
const { data } = await openHands.get<GetConfigResponse>(
"/api/options/config",
);
return data;
}

Expand Down Expand Up @@ -136,6 +138,20 @@ class OpenHands {
return response.status === 200;
}

/**
* Refresh Github Token
* @returns Refreshed Github access token
*/
static async refreshToken(
appMode: GetConfigResponse["APP_MODE"],
): Promise<string> {
if (appMode === "oss") return "";

const response =
await openHands.post<GitHubAccessTokenResponse>("/api/refresh-token");
return response.data.access_token;
}

/**
* Get the blob of the workspace zip
* @returns Blob of the workspace zip
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/open-hands.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface Feedback {

export interface GetConfigResponse {
APP_MODE: "saas" | "oss";
APP_SLUG?: string;
GITHUB_CLIENT_ID: string;
POSTHOG_CLIENT_KEY: string;
}
Expand Down
37 changes: 34 additions & 3 deletions frontend/src/components/features/github/github-repo-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Autocomplete, AutocompleteItem } from "@nextui-org/react";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { setSelectedRepository } from "#/state/initial-query-slice";
import { useConfig } from "#/hooks/query/use-config";

interface GitHubRepositorySelectorProps {
onSelect: () => void;
Expand All @@ -12,11 +13,25 @@ export function GitHubRepositorySelector({
onSelect,
repositories,
}: GitHubRepositorySelectorProps) {
const { data: config } = useConfig();

// Add option to install app onto more repos
const finalRepositories =
config?.APP_MODE === "saas"
? [{ id: -1000, full_name: "Add more repositories..." }, ...repositories]
: repositories;

const dispatch = useDispatch();

const handleRepoSelection = (id: string | null) => {
const repo = repositories.find((r) => r.id.toString() === id);
if (repo) {
const repo = finalRepositories.find((r) => r.id.toString() === id);
if (id === "-1000") {
if (config?.APP_SLUG)
window.open(
`https://github.com/apps/${config.APP_SLUG}/installations/new`,
"_blank",
);
} else if (repo) {
// set query param
dispatch(setSelectedRepository(repo.full_name));
posthog.capture("repository_selected");
Expand All @@ -29,6 +44,19 @@ export function GitHubRepositorySelector({
dispatch(setSelectedRepository(null));
};

const emptyContent = config?.APP_SLUG ? (
<a
href={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
target="_blank"
rel="noreferrer noopener"
className="underline"
>
Add more repositories...
</a>
) : (
"No results found."
);

return (
<Autocomplete
data-testid="github-repo-selector"
Expand All @@ -43,8 +71,11 @@ export function GitHubRepositorySelector({
}}
onSelectionChange={(id) => handleRepoSelection(id?.toString() ?? null)}
clearButtonProps={{ onClick: handleClearSelection }}
listboxProps={{
emptyContent,
}}
>
{repositories.map((repo) => (
{finalRepositories.map((repo) => (
<AutocompleteItem
data-testid="github-repo-item"
key={repo.id}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import React from "react";
import { isGitHubErrorReponse } from "#/api/github";
import { SuggestionBox } from "#/components/features/suggestions/suggestion-box";
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
import { GitHubRepositorySelector } from "./github-repo-selector";
import { ModalButton } from "#/components/shared/buttons/modal-button";
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { isGitHubErrorReponse } from "#/api/github-axios-instance";

interface GitHubRepositoriesSuggestionBoxProps {
handleSubmit: () => void;
Expand Down
Loading

0 comments on commit f9d052c

Please sign in to comment.