Skip to content

Commit

Permalink
feat(platform): List and revoke credentials in user profile (#8207)
Browse files Browse the repository at this point in the history
Display existing credentials (OAuth and API keys) for all current providers: Google, Github, Notion and allow user to remove them. For providers that support it, we also revoke the tokens through the API: of the providers we currently have, Google and GitHub support it; Notion doesn't.

- Add credentials list and `Delete` button in `/profile`
- Add `revoke_tokens` abstract method to `BaseOAuthHandler` and implement it in each provider
- Revoke OAuth tokens for providers on `DELETE` `/{provider}/credentials/{cred_id}`, and return whether tokens could be revoked
   - Update `autogpt-server-api/baseClient.ts:deleteCredentials` with `CredentialsDeleteResponse` return type

Bonus:
- Update `autogpt-server-api/baseClient.ts:_request` to properly handle empty server responses
  • Loading branch information
kcze authored Oct 14, 2024
1 parent 8502928 commit bd5d2b1
Show file tree
Hide file tree
Showing 11 changed files with 270 additions and 46 deletions.
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
4 changes: 4 additions & 0 deletions autogpt_platform/backend/backend/integrations/oauth/notion.py
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

0 comments on commit bd5d2b1

Please sign in to comment.