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 responsespull/8331/head^2
parent
8502928a21
commit
bd5d2b1e86
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 --------- #
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in New Issue