Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(platform): List and revoke credentials in user profile #8207

Merged
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions autogpt_platform/backend/backend/integrations/oauth/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ def exchange_code_for_tokens(self, code: str) -> OAuth2Credentials:
"""Exchanges the acquired authorization code from login for a set of tokens"""
...

@abstractmethod
def revoke_tokens(self, credentials: OAuth2Credentials) -> None:
"""Revokes the given tokens"""
...

@abstractmethod
def _refresh_tokens(self, credentials: OAuth2Credentials) -> OAuth2Credentials:
"""Implements the token refresh mechanism"""
Expand Down
19 changes: 18 additions & 1 deletion autogpt_platform/backend/backend/integrations/oauth/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ class GitHubOAuthHandler(BaseOAuthHandler):
""" # noqa

PROVIDER_NAME = "github"
EMAIL_ENDPOINT = "https://api.github.com/user/emails"

def __init__(self, client_id: str, client_secret: str, redirect_uri: str):
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.auth_base_url = "https://github.com/login/oauth/authorize"
self.token_url = "https://github.com/login/oauth/access_token"
self.revoke_url = "https://api.github.com/applications/{client_id}/token"

def get_login_url(self, scopes: list[str], state: str) -> str:
params = {
Expand All @@ -44,6 +44,23 @@ def get_login_url(self, scopes: list[str], state: str) -> str:
def exchange_code_for_tokens(self, code: str) -> OAuth2Credentials:
return self._request_tokens({"code": code, "redirect_uri": self.redirect_uri})

def revoke_tokens(self, credentials: OAuth2Credentials) -> None:
if not credentials.access_token:
raise ValueError("No access token to revoke")

headers = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}

response = requests.delete(
url=self.revoke_url.format(client_id=self.client_id),
auth=(self.client_id, self.client_secret),
headers=headers,
json={"access_token": credentials.access_token.get_secret_value()},
)
response.raise_for_status()

def _refresh_tokens(self, credentials: OAuth2Credentials) -> OAuth2Credentials:
if not credentials.refresh_token:
return credentials
Expand Down
14 changes: 12 additions & 2 deletions autogpt_platform/backend/backend/integrations/oauth/google.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@ class GoogleOAuthHandler(BaseOAuthHandler):
""" # noqa

PROVIDER_NAME = "google"
EMAIL_ENDPOINT = "https://www.googleapis.com/oauth2/v2/userinfo"

def __init__(self, client_id: str, client_secret: str, redirect_uri: str):
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.token_uri = "https://oauth2.googleapis.com/token"
self.email_uri = "https://www.googleapis.com/oauth2/v2/userinfo"
self.revoke_uri = "https://oauth2.googleapis.com/revoke"

def get_login_url(self, scopes: list[str], state: str) -> str:
flow = self._setup_oauth_flow(scopes)
Expand Down Expand Up @@ -59,11 +60,20 @@ def exchange_code_for_tokens(self, code: str) -> OAuth2Credentials:
scopes=google_creds.scopes,
)

def revoke_tokens(self, credentials: OAuth2Credentials) -> None:
session = AuthorizedSession(credentials)
response = session.post(
self.revoke_uri,
params={"token": credentials.access_token.get_secret_value()},
headers={"content-type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()

def _request_email(
self, creds: Credentials | ExternalAccountCredentials
) -> str | None:
session = AuthorizedSession(creds)
response = session.get(self.EMAIL_ENDPOINT)
response = session.get(self.email_uri)
if not response.ok:
return None
return response.json()["email"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ def exchange_code_for_tokens(self, code: str) -> OAuth2Credentials:
},
)

def revoke_tokens(self, credentials: OAuth2Credentials) -> None:
# Notion doesn't support token revocation
return

def _refresh_tokens(self, credentials: OAuth2Credentials) -> OAuth2Credentials:
# Notion doesn't support token refresh
return credentials
Expand Down
Pwuts marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ async def create_api_key_credentials(

@router.delete("/{provider}/credentials/{cred_id}", status_code=204)
async def delete_credential(
request: Request,
provider: Annotated[str, Path(title="The provider to delete credentials for")],
cred_id: Annotated[str, Path(title="The ID of the credentials to delete")],
user_id: Annotated[str, Depends(get_user_id)],
Expand All @@ -181,6 +182,10 @@ async def delete_credential(
status_code=404, detail="Credentials do not match the specified provider"
)

if isinstance(creds, OAuth2Credentials):
handler = _get_provider_oauth_handler(request, provider)
handler.revoke_tokens(creds)

store.delete_creds_by_id(user_id, cred_id)
return Response(status_code=204)

Expand Down
100 changes: 99 additions & 1 deletion autogpt_platform/frontend/src/app/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,46 @@ import { useSupabase } from "@/components/SupabaseProvider";
import { Button } from "@/components/ui/button";
import useUser from "@/hooks/useUser";
import { useRouter } from "next/navigation";
import { useCallback, useContext, useEffect, useMemo } from "react";
import { FaSpinner } from "react-icons/fa";
import {
CredentialsProviderData,
CredentialsProvidersContext,
} from "@/components/integrations/credentials-provider";
import { Separator } from "@/components/ui/separator";
import AutoGPTServerAPI from "@/lib/autogpt-server-api";
import { useToast } from "@/components/ui/use-toast";
import { Alert, AlertDescription } from "@/components/ui/alert";

export default function PrivatePage() {
const { user, isLoading, error } = useUser();
const { supabase } = useSupabase();
const router = useRouter();
const providers = useContext(CredentialsProvidersContext);
const api = useMemo(() => new AutoGPTServerAPI(), []);
const { toast } = useToast();

if (isLoading) {
const removeCredentials = useCallback(
async (provider: string, id: string) => {
try {
const response = await api.deleteCredentials(provider, id);
console.log("response", response);
toast({
title: "Credentials deleted",
duration: 2000,
});
} catch (error) {
toast({
title: "Something went wrong when deleting credentials " + error,
variant: "destructive",
duration: 2000,
});
}
},
[api],
);

if (isLoading || !providers || !providers) {
return (
<div className="flex h-[80vh] items-center justify-center">
<FaSpinner className="mr-2 h-16 w-16 animate-spin" />
Expand All @@ -24,10 +56,76 @@ export default function PrivatePage() {
return null;
}

//TODO: Remove this once we have more providers
delete providers["notion"];
delete providers["google"];
Pwuts marked this conversation as resolved.
Show resolved Hide resolved

return (
<div>
<p>Hello {user.email}</p>
<Button onClick={() => supabase.auth.signOut()}>Log out</Button>
<div>
{/* <Alert className="mb-2 mt-2">
<AlertDescription>Heads up!</AlertDescription>
<AlertDescription>
<p>
You need to manually remove credentials from the Notion after
deleting them here, see{" "}
</p>
<a href="https://www.notion.so/help/add-and-manage-connections-with-the-api#manage-connections-in-your-workspace">
Notion documentation
</a>
</AlertDescription>
</Alert> */}
Pwuts marked this conversation as resolved.
Show resolved Hide resolved
{Object.entries(providers).map(([providerName, provider]) => {
return (
<div key={provider.provider} className="mh-2">
<Separator />
<div className="text-xl">{provider.providerName}</div>
{provider.savedApiKeys.length > 0 && (
<div>
<div className="text-md">API Keys</div>
{provider.savedApiKeys.map((apiKey) => (
<div key={apiKey.id} className="flex flex-row">
<p className="p-2">
{apiKey.id} - {apiKey.title}
</p>
<Button
variant="destructive"
onClick={() =>
removeCredentials(providerName, apiKey.id)
}
>
Delete
</Button>
</div>
))}
</div>
)}
{provider.savedOAuthCredentials.length > 0 && (
<div>
<div className="text-md">OAuth Credentials</div>
{provider.savedOAuthCredentials.map((oauth) => (
<div key={oauth.id} className="flex flex-row">
<p className="p-2">
{oauth.id} - {oauth.title}
</p>
<Button
variant="destructive"
onClick={() =>
removeCredentials(providerName, oauth.id)
}
>
Delete
</Button>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ export default class BaseAutoGPTServerAPI {
path: string,
payload?: Record<string, any>,
) {
if (method != "GET") {
if (method !== "GET") {
console.debug(`${method} ${path} payload:`, payload);
}

Expand All @@ -249,36 +249,56 @@ export default class BaseAutoGPTServerAPI {
const hasRequestBody = method !== "GET" && payload !== undefined;
const response = await fetch(url, {
method,
headers: hasRequestBody
? {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
}
: {
Authorization: token ? `Bearer ${token}` : "",
},
headers: {
...(hasRequestBody && { "Content-Type": "application/json" }),
...(token && { Authorization: `Bearer ${token}` }),
Pwuts marked this conversation as resolved.
Show resolved Hide resolved
},
body: hasRequestBody ? JSON.stringify(payload) : undefined,
});
const response_data = await response.json();

if (!response.ok) {
console.warn(
`${method} ${path} returned non-OK response:`,
response_data.detail,
response,
response.status,
response.statusText,
Pwuts marked this conversation as resolved.
Show resolved Hide resolved
);

if (
response.status === 403 &&
response_data.detail === "Not authenticated" &&
window // Browser environment only: redirect to login page.
response.statusText === "Not authenticated" &&
typeof window !== "undefined" // Check if in browser environment
) {
window.location.href = "/login";
}

throw new Error(`HTTP error ${response.status}! ${response_data.detail}`);
let errorDetail;
try {
const errorData = await response.json();
errorDetail = errorData.detail || response.statusText;
} catch (e) {
errorDetail = response.statusText;
}

throw new Error(`HTTP error ${response.status}! ${errorDetail}`);
}

// Handle responses with no content (like DELETE requests)
if (
response.status === 204 ||
response.headers.get("Content-Length") === "0"
) {
return null;
}

try {
return await response.json();
} catch (e) {
if (e instanceof SyntaxError) {
console.warn(`${method} ${path} returned invalid JSON:`, e);
return null;
}
throw e;
}
return response_data;
}

async connectWebSocket(): Promise<void> {
Expand Down
Loading