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.