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 all 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
8 changes: 8 additions & 0 deletions autogpt_platform/backend/backend/integrations/oauth/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ def _refresh_tokens(self, credentials: OAuth2Credentials) -> OAuth2Credentials:
"""Implements the token refresh mechanism"""
...

@abstractmethod
# --8<-- [start:BaseOAuthHandler6]
def revoke_tokens(self, credentials: OAuth2Credentials) -> bool:
# --8<-- [end:BaseOAuthHandler6]
"""Revokes the given token at provider,
returns False provider does not support it"""
...

def refresh_tokens(self, credentials: OAuth2Credentials) -> OAuth2Credentials:
if credentials.provider != self.PROVIDER_NAME:
raise ValueError(
Expand Down
20 changes: 19 additions & 1 deletion autogpt_platform/backend/backend/integrations/oauth/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,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 @@ -47,6 +47,24 @@ def exchange_code_for_tokens(
) -> OAuth2Credentials:
return self._request_tokens({"code": code, "redirect_uri": self.redirect_uri})

def revoke_tokens(self, credentials: OAuth2Credentials) -> bool:
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()
return True

def _refresh_tokens(self, credentials: OAuth2Credentials) -> OAuth2Credentials:
if not credentials.refresh_token:
return credentials
Expand Down
11 changes: 11 additions & 0 deletions autogpt_platform/backend/backend/integrations/oauth/google.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def __init__(self, client_id: str, client_secret: str, redirect_uri: str):
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.token_uri = "https://oauth2.googleapis.com/token"
self.revoke_uri = "https://oauth2.googleapis.com/revoke"

def get_login_url(self, scopes: list[str], state: str) -> str:
all_scopes = list(set(scopes + self.DEFAULT_SCOPES))
Expand Down Expand Up @@ -100,6 +101,16 @@ def exchange_code_for_tokens(

return credentials

def revoke_tokens(self, credentials: OAuth2Credentials) -> bool:
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()
return True

def _request_email(
self, creds: Credentials | ExternalAccountCredentials
) -> str | None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ def exchange_code_for_tokens(
},
)

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

def _refresh_tokens(self, credentials: OAuth2Credentials) -> OAuth2Credentials:
# Notion doesn't support token refresh
return credentials
Expand Down
28 changes: 22 additions & 6 deletions autogpt_platform/backend/backend/server/integrations/router.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import logging
from typing import Annotated
from typing import Annotated, Literal

from autogpt_libs.supabase_integration_credentials_store.types import (
APIKeyCredentials,
Expand All @@ -17,7 +17,7 @@
Request,
Response,
)
from pydantic import BaseModel, SecretStr
from pydantic import BaseModel, Field, SecretStr

from backend.integrations.creds_manager import IntegrationCredentialsManager
from backend.integrations.oauth import HANDLERS_BY_NAME, BaseOAuthHandler
Expand Down Expand Up @@ -182,12 +182,22 @@ async def create_api_key_credentials(
return new_credentials


@router.delete("/{provider}/credentials/{cred_id}", status_code=204)
async def delete_credential(
class CredentialsDeletionResponse(BaseModel):
deleted: Literal[True] = True
revoked: bool | None = Field(
description="Indicates whether the credentials were also revoked by their "
"provider. `None`/`null` if not applicable, e.g. when deleting "
"non-revocable credentials such as API keys."
)


@router.delete("/{provider}/credentials/{cred_id}")
async def delete_credentials(
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)],
):
) -> CredentialsDeletionResponse:
creds = creds_manager.store.get_creds_by_id(user_id, cred_id)
if not creds:
raise HTTPException(status_code=404, detail="Credentials not found")
Expand All @@ -197,7 +207,13 @@ async def delete_credential(
)

creds_manager.delete(user_id, cred_id)
return Response(status_code=204)

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

return CredentialsDeletionResponse(revoked=tokens_revoked)


# -------- UTILITIES --------- #
Expand Down
122 changes: 118 additions & 4 deletions autogpt_platform/frontend/src/app/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,65 @@ import { useSupabase } from "@/components/SupabaseProvider";
import { Button } from "@/components/ui/button";
import useUser from "@/hooks/useUser";
import { useRouter } from "next/navigation";
import { useCallback, useContext } from "react";
import { FaSpinner } from "react-icons/fa";
import { Separator } from "@/components/ui/separator";
import { useToast } from "@/components/ui/use-toast";
import { IconKey, IconUser } from "@/components/ui/icons";
import { LogOutIcon, Trash2Icon } from "lucide-react";
import { providerIcons } from "@/components/integrations/credentials-input";
import {
CredentialsProviderName,
CredentialsProvidersContext,
} from "@/components/integrations/credentials-provider";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";

export default function PrivatePage() {
const { user, isLoading, error } = useUser();
const { supabase } = useSupabase();
const router = useRouter();
const providers = useContext(CredentialsProvidersContext);
const { toast } = useToast();

if (isLoading) {
const removeCredentials = useCallback(
async (provider: CredentialsProviderName, id: string) => {
if (!providers || !providers[provider]) {
return;
}

try {
const { revoked } = await providers[provider].deleteCredentials(id);
if (revoked !== false) {
toast({
title: "Credentials deleted",
duration: 2000,
});
} else {
toast({
title: "Credentials deleted from AutoGPT",
description: `You may also manually remove the connection to AutoGPT at ${provider}!`,
duration: 3000,
});
}
} catch (error: any) {
toast({
title: "Something went wrong when deleting credentials: " + error,
variant: "destructive",
duration: 2000,
});
}
},
[providers, toast],
);

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 +75,73 @@ export default function PrivatePage() {
return null;
}

const allCredentials = Object.values(providers).flatMap((provider) =>
[...provider.savedOAuthCredentials, ...provider.savedApiKeys].map(
(credentials) => ({
...credentials,
provider: provider.provider,
providerName: provider.providerName,
ProviderIcon: providerIcons[provider.provider],
TypeIcon: { oauth2: IconUser, api_key: IconKey }[credentials.type],
}),
),
);

return (
<div>
<p>Hello {user.email}</p>
<Button onClick={() => supabase.auth.signOut()}>Log out</Button>
<div className="mx-auto max-w-3xl md:py-8">
<div className="flex items-center justify-between">
<p>Hello {user.email}</p>
<Button onClick={() => supabase.auth.signOut()}>
<LogOutIcon className="mr-1.5 size-4" />
Log out
</Button>
</div>
<Separator className="my-6" />
<h2 className="mb-4 text-lg">Connections & Credentials</h2>
<Table>
<TableHeader>
<TableRow>
<TableHead>Provider</TableHead>
<TableHead>Name</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allCredentials.map((cred) => (
<TableRow key={cred.id}>
<TableCell>
<div className="flex items-center space-x-1.5">
<cred.ProviderIcon className="h-4 w-4" />
<strong>{cred.providerName}</strong>
</div>
</TableCell>
<TableCell>
<div className="flex h-full items-center space-x-1.5">
<cred.TypeIcon />
<span>{cred.title || cred.username}</span>
</div>
<small className="text-muted-foreground">
{
{
oauth2: "OAuth2 credentials",
api_key: "API key",
}[cred.type]
}{" "}
- <code>{cred.id}</code>
</small>
</TableCell>
<TableCell className="w-0 whitespace-nowrap">
<Button
variant="destructive"
onClick={() => removeCredentials(cred.provider, cred.id)}
>
<Trash2Icon className="mr-1.5 size-4" /> Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,8 @@ import AutoGPTServerAPI from "@/lib/autogpt-server-api";
import { NotionLogoIcon } from "@radix-ui/react-icons";
import { FaGithub, FaGoogle } from "react-icons/fa";
import { FC, useMemo, useState } from "react";
import {
APIKeyCredentials,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import {
IconKey,
IconKeyPlus,
IconUser,
IconUserPlus,
} from "@/components/ui/icons";
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { IconKey, IconKeyPlus, IconUserPlus } from "@/components/ui/icons";
import {
Dialog,
DialogContent,
Expand All @@ -45,7 +37,7 @@ import {
} from "@/components/ui/select";

// --8<-- [start:ProviderIconsEmbed]
const providerIcons: Record<string, React.FC<{ className?: string }>> = {
export const providerIcons: Record<string, React.FC<{ className?: string }>> = {
github: FaGithub,
google: FaGoogle,
notion: NotionLogoIcon,
Expand Down
Loading
Loading