diff --git a/autogpt_platform/backend/backend/integrations/oauth/base.py b/autogpt_platform/backend/backend/integrations/oauth/base.py index 61ea7e5b4925..a12200af6590 100644 --- a/autogpt_platform/backend/backend/integrations/oauth/base.py +++ b/autogpt_platform/backend/backend/integrations/oauth/base.py @@ -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( diff --git a/autogpt_platform/backend/backend/integrations/oauth/github.py b/autogpt_platform/backend/backend/integrations/oauth/github.py index 03da15f40357..ebd5ff9e327b 100644 --- a/autogpt_platform/backend/backend/integrations/oauth/github.py +++ b/autogpt_platform/backend/backend/integrations/oauth/github.py @@ -24,7 +24,6 @@ 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 @@ -32,6 +31,7 @@ def __init__(self, client_id: str, client_secret: str, redirect_uri: str): 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 = { @@ -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 diff --git a/autogpt_platform/backend/backend/integrations/oauth/google.py b/autogpt_platform/backend/backend/integrations/oauth/google.py index 642627d29e74..810892188d2a 100644 --- a/autogpt_platform/backend/backend/integrations/oauth/google.py +++ b/autogpt_platform/backend/backend/integrations/oauth/google.py @@ -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)) @@ -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: diff --git a/autogpt_platform/backend/backend/integrations/oauth/notion.py b/autogpt_platform/backend/backend/integrations/oauth/notion.py index 3a26d1f3f103..c485d3bec30e 100644 --- a/autogpt_platform/backend/backend/integrations/oauth/notion.py +++ b/autogpt_platform/backend/backend/integrations/oauth/notion.py @@ -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 diff --git a/autogpt_platform/backend/backend/server/integrations/router.py b/autogpt_platform/backend/backend/server/integrations/router.py index a24141ad0b9d..c9436003788e 100644 --- a/autogpt_platform/backend/backend/server/integrations/router.py +++ b/autogpt_platform/backend/backend/server/integrations/router.py @@ -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, @@ -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 @@ -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") @@ -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 --------- # diff --git a/autogpt_platform/frontend/src/app/profile/page.tsx b/autogpt_platform/frontend/src/app/profile/page.tsx index a4b3831efa68..97c1b2b3ae36 100644 --- a/autogpt_platform/frontend/src/app/profile/page.tsx +++ b/autogpt_platform/frontend/src/app/profile/page.tsx @@ -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 (
@@ -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 ( -
-

Hello {user.email}

- +
+
+

Hello {user.email}

+ +
+ +

Connections & Credentials

+ + + + Provider + Name + Actions + + + + {allCredentials.map((cred) => ( + + +
+ + {cred.providerName} +
+
+ +
+ + {cred.title || cred.username} +
+ + { + { + oauth2: "OAuth2 credentials", + api_key: "API key", + }[cred.type] + }{" "} + - {cred.id} + +
+ + + +
+ ))} +
+
); } diff --git a/autogpt_platform/frontend/src/components/integrations/credentials-input.tsx b/autogpt_platform/frontend/src/components/integrations/credentials-input.tsx index dca42cb1bcdc..811e37d809bd 100644 --- a/autogpt_platform/frontend/src/components/integrations/credentials-input.tsx +++ b/autogpt_platform/frontend/src/components/integrations/credentials-input.tsx @@ -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, @@ -45,7 +37,7 @@ import { } from "@/components/ui/select"; // --8<-- [start:ProviderIconsEmbed] -const providerIcons: Record> = { +export const providerIcons: Record> = { github: FaGithub, google: FaGoogle, notion: NotionLogoIcon, diff --git a/autogpt_platform/frontend/src/components/integrations/credentials-provider.tsx b/autogpt_platform/frontend/src/components/integrations/credentials-provider.tsx index 4959c80e41eb..ffb62d793e9b 100644 --- a/autogpt_platform/frontend/src/components/integrations/credentials-provider.tsx +++ b/autogpt_platform/frontend/src/components/integrations/credentials-provider.tsx @@ -1,5 +1,6 @@ import AutoGPTServerAPI, { APIKeyCredentials, + CredentialsDeleteResponse, CredentialsMetaResponse, } from "@/lib/autogpt-server-api"; import { @@ -13,7 +14,8 @@ import { // --8<-- [start:CredentialsProviderNames] const CREDENTIALS_PROVIDER_NAMES = ["github", "google", "notion"] as const; -type CredentialsProviderName = (typeof CREDENTIALS_PROVIDER_NAMES)[number]; +export type CredentialsProviderName = + (typeof CREDENTIALS_PROVIDER_NAMES)[number]; const providerDisplayNames: Record = { github: "GitHub", @@ -28,7 +30,7 @@ type APIKeyCredentialsCreatable = Omit< >; export type CredentialsProviderData = { - provider: string; + provider: CredentialsProviderName; providerName: string; savedApiKeys: CredentialsMetaResponse[]; savedOAuthCredentials: CredentialsMetaResponse[]; @@ -39,6 +41,7 @@ export type CredentialsProviderData = { createAPIKeyCredentials: ( credentials: APIKeyCredentialsCreatable, ) => Promise; + deleteCredentials: (id: string) => Promise; }; export type CredentialsProvidersContextType = { @@ -118,6 +121,35 @@ export default function CredentialsProvider({ [api, addCredentials], ); + /** Wraps `AutoGPTServerAPI.deleteCredentials`, and removes the credentials from the internal store. */ + const deleteCredentials = useCallback( + async ( + provider: CredentialsProviderName, + id: string, + ): Promise => { + const result = await api.deleteCredentials(provider, id); + setProviders((prev) => { + if (!prev || !prev[provider]) return prev; + + const updatedProvider = { ...prev[provider] }; + updatedProvider.savedApiKeys = updatedProvider.savedApiKeys.filter( + (cred) => cred.id !== id, + ); + updatedProvider.savedOAuthCredentials = + updatedProvider.savedOAuthCredentials.filter( + (cred) => cred.id !== id, + ); + + return { + ...prev, + [provider]: updatedProvider, + }; + }); + return result; + }, + [api], + ); + useEffect(() => { api.isAuthenticated().then((isAuthenticated) => { if (!isAuthenticated) return; @@ -151,12 +183,14 @@ export default function CredentialsProvider({ createAPIKeyCredentials: ( credentials: APIKeyCredentialsCreatable, ) => createAPIKeyCredentials(provider, credentials), + deleteCredentials: (id: string) => + deleteCredentials(provider, id), }, })); }); }); }); - }, [api, createAPIKeyCredentials, oAuthCallback]); + }, [api, createAPIKeyCredentials, deleteCredentials, oAuthCallback]); return ( diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/baseClient.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/baseClient.ts index 8d0f0066ac60..dc80bb0401d4 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/baseClient.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/baseClient.ts @@ -4,6 +4,7 @@ import { AnalyticsDetails, APIKeyCredentials, Block, + CredentialsDeleteResponse, CredentialsMetaResponse, Graph, GraphCreatable, @@ -215,7 +216,10 @@ export default class BaseAutoGPTServerAPI { return this._get(`/integrations/${provider}/credentials/${id}`); } - deleteCredentials(provider: string, id: string): Promise { + deleteCredentials( + provider: string, + id: string, + ): Promise { return this._request( "DELETE", `/integrations/${provider}/credentials/${id}`, @@ -239,7 +243,7 @@ export default class BaseAutoGPTServerAPI { path: string, payload?: Record, ) { - if (method != "GET") { + if (method !== "GET") { console.debug(`${method} ${path} payload:`, payload); } @@ -257,36 +261,52 @@ 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}` }), + }, 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, - ); + console.warn(`${method} ${path} returned non-OK response:`, response); 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 { diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts index 3ff52839c057..97a6bf4e5046 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts @@ -216,7 +216,7 @@ export type NodeExecutionResult = { end_time?: Date; }; -/* Mirror of backend/server/integrations.py:CredentialsMetaResponse */ +/* Mirror of backend/server/integrations/router.py:CredentialsMetaResponse */ export type CredentialsMetaResponse = { id: string; type: CredentialsType; @@ -225,6 +225,12 @@ export type CredentialsMetaResponse = { username?: string; }; +/* Mirror of backend/server/integrations/router.py:CredentialsDeletionResponse */ +export type CredentialsDeleteResponse = { + deleted: true; + revoked: boolean | null; +}; + /* Mirror of backend/data/model.py:CredentialsMetaInput */ export type CredentialsMetaInput = { id: string; diff --git a/docs/content/server/new_blocks.md b/docs/content/server/new_blocks.md index a56675810b0a..8cc5a5253836 100644 --- a/docs/content/server/new_blocks.md +++ b/docs/content/server/new_blocks.md @@ -240,6 +240,7 @@ Every handler must implement the following parts of the [`BaseOAuthHandler`] int --8<-- "autogpt_platform/backend/backend/integrations/oauth/base.py:BaseOAuthHandler3" --8<-- "autogpt_platform/backend/backend/integrations/oauth/base.py:BaseOAuthHandler4" --8<-- "autogpt_platform/backend/backend/integrations/oauth/base.py:BaseOAuthHandler5" +--8<-- "autogpt_platform/backend/backend/integrations/oauth/base.py:BaseOAuthHandler6" ``` As you can see, this is modeled after the standard OAuth2 flow.