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

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
pull/8331/head^2
Krzysztof Czerwinski 2024-10-14 16:50:55 +01:00 committed by GitHub
parent 8502928a21
commit bd5d2b1e86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 270 additions and 46 deletions

View File

@ -43,6 +43,14 @@ class BaseOAuthHandler(ABC):
"""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(

View File

@ -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 @@ class GitHubOAuthHandler(BaseOAuthHandler):
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 @@ class GitHubOAuthHandler(BaseOAuthHandler):
) -> 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

View File

@ -34,6 +34,7 @@ class GoogleOAuthHandler(BaseOAuthHandler):
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 @@ class GoogleOAuthHandler(BaseOAuthHandler):
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:

View File

@ -77,6 +77,10 @@ class NotionOAuthHandler(BaseOAuthHandler):
},
)
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

View File

@ -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 @@ from fastapi import (
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 --------- #

View File

@ -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" />
@ -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>
);
}

View File

@ -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<string, React.FC<{ className?: string }>> = {
export const providerIcons: Record<string, React.FC<{ className?: string }>> = {
github: FaGithub,
google: FaGoogle,
notion: NotionLogoIcon,

View File

@ -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<CredentialsProviderName, string> = {
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<CredentialsMetaResponse>;
deleteCredentials: (id: string) => Promise<CredentialsDeleteResponse>;
};
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<CredentialsDeleteResponse> => {
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 (
<CredentialsProvidersContext.Provider value={providers}>

View File

@ -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<void> {
deleteCredentials(
provider: string,
id: string,
): Promise<CredentialsDeleteResponse> {
return this._request(
"DELETE",
`/integrations/${provider}/credentials/${id}`,
@ -239,7 +243,7 @@ export default class BaseAutoGPTServerAPI {
path: string,
payload?: Record<string, any>,
) {
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<void> {

View File

@ -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;

View File

@ -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.