feat(platform): OAuth API & Single Sign-On (#11617)

We want to provide Single Sign-On for multiple AutoGPT apps that use the
Platform as their backend.

### Changes 🏗️

Backend:
- DB + logic + API for OAuth flow (w/ tests)
  - DB schema additions for OAuth apps, codes, and tokens
  - Token creation/validation/management logic
- OAuth flow endpoints (app info, authorize, token exchange, introspect,
revoke)
  - E2E OAuth API integration tests
- Other OAuth-related endpoints (upload app logo, list owned apps,
external `/me` endpoint)
    - App logo asset management
  - Adjust external API middleware to support auth with access token
  - Expired token clean-up job
    - Add `OAUTH_TOKEN_CLEANUP_INTERVAL_HOURS` setting (optional)
- `poetry run oauth-tool`: dev tool to test the OAuth flows and register
new OAuth apps
- `poetry run export-api-schema`: dev tool to quickly export the OpenAPI
schema (much quicker than spinning up the backend)

Frontend:
- Frontend UI for app authorization (`/auth/authorize`)
  - Re-redirect after login/signup
- Frontend flow to batch-auth integrations on request of the client app
(`/auth/integrations/setup-wizard`)
  - Debug `CredentialInputs` component
- Add `/profile/oauth-apps` management page
- Add `isOurProblem` flag to `ErrorCard` to hide action buttons when the
error isn't our fault
- Add `showTitle` flag to `CredentialsInput` to hide built-in title for
layout reasons

DX:
- Add [API
guide](https://github.com/Significant-Gravitas/AutoGPT/blob/pwuts/sso/docs/content/platform/integrating/api-guide.md)
and [OAuth
guide](https://github.com/Significant-Gravitas/AutoGPT/blob/pwuts/sso/docs/content/platform/integrating/oauth-guide.md)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Manually verify test coverage of OAuth API tests
  - Test `/auth/authorize` using `poetry run oauth-tool test-server`
    - [x] Works
    - [x] Looks okay
- Test `/auth/integrations/setup-wizard` using `poetry run oauth-tool
test-server`
    - [x] Works
    - [x] Looks okay
  - Test `/profile/oauth-apps` page
    - [x] All owned OAuth apps show up
    - [x] Enabling/disabling apps works
- [ ] ~~Uploading logos works~~ can only test this once deployed to dev

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)
This commit is contained in:
Reinier van der Leer
2025-12-19 21:05:16 +01:00
committed by GitHub
parent b76b5a37c5
commit 3dbc03e488
58 changed files with 7672 additions and 263 deletions

View File

@@ -0,0 +1,296 @@
"use client";
import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { AuthCard } from "@/components/auth/AuthCard";
import { Text } from "@/components/atoms/Text/Text";
import { Button } from "@/components/atoms/Button/Button";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { ImageIcon, SealCheckIcon } from "@phosphor-icons/react";
import {
postOauthAuthorize,
useGetOauthGetOauthAppInfo,
} from "@/app/api/__generated__/endpoints/oauth/oauth";
import type { APIKeyPermission } from "@/app/api/__generated__/models/aPIKeyPermission";
// Human-readable scope descriptions
const SCOPE_DESCRIPTIONS: { [key in APIKeyPermission]: string } = {
IDENTITY: "Read user ID, name, e-mail, and timezone",
EXECUTE_GRAPH: "Run your agents",
READ_GRAPH: "View your agents and their configurations",
EXECUTE_BLOCK: "Execute individual blocks",
READ_BLOCK: "View available blocks",
READ_STORE: "Access the Marketplace",
USE_TOOLS: "Use tools on your behalf",
MANAGE_INTEGRATIONS: "Set up new integrations",
READ_INTEGRATIONS: "View your connected integrations",
DELETE_INTEGRATIONS: "Remove connected integrations",
};
export default function AuthorizePage() {
const searchParams = useSearchParams();
// Extract OAuth parameters from URL
const clientID = searchParams.get("client_id");
const redirectURI = searchParams.get("redirect_uri");
const scope = searchParams.get("scope");
const state = searchParams.get("state");
const codeChallenge = searchParams.get("code_challenge");
const codeChallengeMethod =
searchParams.get("code_challenge_method") || "S256";
const responseType = searchParams.get("response_type") || "code";
// Parse requested scopes
const requestedScopes = scope?.split(" ").filter(Boolean) || [];
// Fetch application info using generated hook
const {
data: appInfoResponse,
isLoading,
error,
refetch,
} = useGetOauthGetOauthAppInfo(clientID || "", {
query: {
enabled: !!clientID,
staleTime: Infinity,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
});
const appInfo = appInfoResponse?.status === 200 ? appInfoResponse.data : null;
// Validate required parameters
const missingParams: string[] = [];
if (!clientID) missingParams.push("client_id");
if (!redirectURI) missingParams.push("redirect_uri");
if (!scope) missingParams.push("scope");
if (!state) missingParams.push("state");
if (!codeChallenge) missingParams.push("code_challenge");
const [isAuthorizing, setIsAuthorizing] = useState(false);
const [authorizeError, setAuthorizeError] = useState<string | null>(null);
async function handleApprove() {
setIsAuthorizing(true);
setAuthorizeError(null);
try {
// Call the backend /oauth/authorize POST endpoint
// Returns JSON with redirect_url that we use to redirect the user
const response = await postOauthAuthorize({
client_id: clientID!,
redirect_uri: redirectURI!,
scopes: requestedScopes,
state: state!,
response_type: responseType,
code_challenge: codeChallenge!,
code_challenge_method: codeChallengeMethod as "S256" | "plain",
});
if (response.status === 200 && response.data.redirect_url) {
window.location.href = response.data.redirect_url;
} else {
setAuthorizeError("Authorization failed: no redirect URL received");
setIsAuthorizing(false);
}
} catch (err) {
console.error("Authorization error:", err);
setAuthorizeError(
err instanceof Error ? err.message : "Authorization failed",
);
setIsAuthorizing(false);
}
}
function handleDeny() {
// Redirect back to client with access_denied error
const params = new URLSearchParams({
error: "access_denied",
error_description: "User denied access",
state: state || "",
});
window.location.href = `${redirectURI}?${params.toString()}`;
}
// Show error if missing required parameters
if (missingParams.length > 0) {
return (
<div className="flex h-full min-h-[85vh] flex-col items-center justify-center py-10">
<AuthCard title="Invalid Request">
<ErrorCard
context="request parameters"
responseError={{
message: `Missing required parameters: ${missingParams.join(", ")}`,
}}
hint="Please contact the administrator of the app that sent you here."
isOurProblem={false}
/>
</AuthCard>
</div>
);
}
// Show loading state
if (isLoading) {
return (
<div className="flex h-full min-h-[85vh] flex-col items-center justify-center py-10">
<AuthCard title="Loading...">
<div className="flex flex-col items-center gap-4 py-8">
<LoadingSpinner size="large" />
<Text variant="body" className="text-center text-slate-500">
Loading application information...
</Text>
</div>
</AuthCard>
</div>
);
}
// Show error if app not found
if (error || !appInfo) {
return (
<div className="flex h-full min-h-[85vh] flex-col items-center justify-center py-10">
<AuthCard title="Application Not Found">
<ErrorCard
context="application"
responseError={
error
? error
: {
message:
"The application you're trying to authorize could not be found or is disabled.",
}
}
onRetry={refetch}
/>
{redirectURI && (
<Button
variant="secondary"
onClick={handleDeny}
className="mt-4 w-full"
>
Return to Application
</Button>
)}
</AuthCard>
</div>
);
}
// Validate that requested scopes are allowed by the app
const invalidScopes = requestedScopes.filter(
(s) => !appInfo.scopes.includes(s),
);
if (invalidScopes.length > 0) {
return (
<div className="flex h-full min-h-[85vh] flex-col items-center justify-center py-10">
<AuthCard title="Invalid Scopes">
<ErrorCard
context="scopes"
responseError={{
message: `The application is requesting scopes it is not authorized for: ${invalidScopes.join(", ")}`,
}}
hint="Please contact the administrator of the app that sent you here."
isOurProblem={false}
/>
<Button
variant="secondary"
onClick={handleDeny}
className="mt-4 w-full"
>
Return to Application
</Button>
</AuthCard>
</div>
);
}
return (
<div className="flex h-full min-h-[85vh] flex-col items-center justify-center py-10">
<AuthCard title="Authorize Application">
<div className="flex w-full flex-col gap-6">
{/* App info */}
<div className="flex flex-col items-center text-center">
{/* App logo */}
<div className="mb-4 flex size-16 items-center justify-center overflow-hidden rounded-xl border bg-slate-100">
{appInfo.logo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={appInfo.logo_url}
alt={`${appInfo.name} logo`}
className="h-full w-full object-cover"
/>
) : (
<ImageIcon className="h-8 w-8 text-slate-400" />
)}
</div>
<Text variant="h4" className="mb-2">
{appInfo.name}
</Text>
{appInfo.description && (
<Text variant="body" className="text-slate-600">
{appInfo.description}
</Text>
)}
</div>
{/* Permissions */}
<div>
<Text variant="body-medium" className="mb-3">
This application is requesting permission to:
</Text>
<ul className="space-y-2">
{requestedScopes.map((scopeKey) => (
<li key={scopeKey} className="flex items-start gap-3">
<SealCheckIcon className="mt-0.5 text-green-600" />
<Text variant="body">
{SCOPE_DESCRIPTIONS[scopeKey as APIKeyPermission] ||
scopeKey}
</Text>
</li>
))}
</ul>
</div>
{/* Error message */}
{authorizeError && (
<ErrorCard
context="authorization"
responseError={{ message: authorizeError }}
/>
)}
{/* Action buttons */}
<div className="flex flex-col gap-3">
<Button
variant="primary"
onClick={handleApprove}
disabled={isAuthorizing}
className="w-full text-lg"
>
{isAuthorizing ? "Authorizing..." : "Authorize"}
</Button>
<Button
variant="secondary"
onClick={handleDeny}
disabled={isAuthorizing}
className="w-full text-lg"
>
Deny
</Button>
</div>
{/* Warning */}
<Text variant="small" className="text-center text-slate-500">
By authorizing, you allow this application to access your AutoGPT
account with the permissions listed above.
</Text>
</div>
</AuthCard>
</div>
);
}

View File

@@ -74,6 +74,9 @@ export async function GET(request: Request) {
);
}
// Get redirect destination from 'next' query parameter
next = searchParams.get("next") || next;
const forwardedHost = request.headers.get("x-forwarded-host"); // original origin before load balancer
const isLocalEnv = process.env.NODE_ENV === "development";
if (isLocalEnv) {

View File

@@ -0,0 +1,331 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useState, useMemo, useRef } from "react";
import { AuthCard } from "@/components/auth/AuthCard";
import { Text } from "@/components/atoms/Text/Text";
import { Button } from "@/components/atoms/Button/Button";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import type {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
CredentialsType,
} from "@/lib/autogpt-server-api";
import { CheckIcon, CircleIcon } from "@phosphor-icons/react";
import { useGetOauthGetOauthAppInfo } from "@/app/api/__generated__/endpoints/oauth/oauth";
import { okData } from "@/app/api/helpers";
import { OAuthApplicationPublicInfo } from "@/app/api/__generated__/models/oAuthApplicationPublicInfo";
// All credential types - we accept any type of credential
const ALL_CREDENTIAL_TYPES: CredentialsType[] = [
"api_key",
"oauth2",
"user_password",
"host_scoped",
];
/**
* Provider configuration for the setup wizard.
*
* Query parameters:
* - `providers`: base64-encoded JSON array of { provider, scopes? } objects
* - `app_name`: (optional) Name of the requesting application
* - `redirect_uri`: Where to redirect after completion
* - `state`: Anti-CSRF token
*
* Example `providers` JSON:
* [
* { "provider": "google", "scopes": ["https://www.googleapis.com/auth/gmail.readonly"] },
* { "provider": "github", "scopes": ["repo"] }
* ]
*
* Example URL:
* /auth/integrations/setup-wizard?app_name=My%20App&providers=W3sicHJvdmlkZXIiOiJnb29nbGUifV0=&redirect_uri=...
*/
interface ProviderConfig {
provider: string;
scopes?: string[];
}
function createSchemaFromProviderConfig(
config: ProviderConfig,
): BlockIOCredentialsSubSchema {
return {
type: "object",
properties: {},
credentials_provider: [config.provider],
credentials_types: ALL_CREDENTIAL_TYPES,
credentials_scopes: config.scopes,
discriminator: undefined,
discriminator_mapping: undefined,
discriminator_values: undefined,
};
}
function toDisplayName(provider: string): string {
// Convert snake_case or kebab-case to Title Case
return provider
.split(/[_-]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
function parseProvidersParam(providersParam: string): ProviderConfig[] {
try {
// Decode base64 and parse JSON
const decoded = atob(providersParam);
const parsed = JSON.parse(decoded);
if (!Array.isArray(parsed)) {
console.warn("providers parameter is not an array");
return [];
}
return parsed.filter(
(item): item is ProviderConfig =>
typeof item === "object" &&
item !== null &&
typeof item.provider === "string",
);
} catch (error) {
console.warn("Failed to parse providers parameter:", error);
return [];
}
}
export default function IntegrationSetupWizardPage() {
const searchParams = useSearchParams();
// Extract query parameters
// `providers` is a base64-encoded JSON array of { provider, scopes?: string[] } objects
const clientID = searchParams.get("client_id");
const providersParam = searchParams.get("providers");
const redirectURI = searchParams.get("redirect_uri");
const state = searchParams.get("state");
const { data: appInfo } = useGetOauthGetOauthAppInfo(clientID || "", {
query: { enabled: !!clientID, select: okData<OAuthApplicationPublicInfo> },
});
// Parse providers from base64-encoded JSON
const providerConfigs = useMemo<ProviderConfig[]>(() => {
if (!providersParam) return [];
return parseProvidersParam(providersParam);
}, [providersParam]);
// Track selected credentials for each provider
const [selectedCredentials, setSelectedCredentials] = useState<
Record<string, CredentialsMetaInput | undefined>
>({});
// Track if we've already redirected
const hasRedirectedRef = useRef(false);
// Check if all providers have credentials
const isAllComplete = useMemo(() => {
if (providerConfigs.length === 0) return false;
return providerConfigs.every(
(config) => selectedCredentials[config.provider],
);
}, [providerConfigs, selectedCredentials]);
// Handle credential selection
const handleCredentialSelect = (
provider: string,
credential?: CredentialsMetaInput,
) => {
setSelectedCredentials((prev) => ({
...prev,
[provider]: credential,
}));
};
// Handle completion - redirect back to client
const handleComplete = () => {
if (!redirectURI || hasRedirectedRef.current) return;
hasRedirectedRef.current = true;
const params = new URLSearchParams({
success: "true",
});
if (state) {
params.set("state", state);
}
window.location.href = `${redirectURI}?${params.toString()}`;
};
// Handle cancel - redirect back to client with error
const handleCancel = () => {
if (!redirectURI || hasRedirectedRef.current) return;
hasRedirectedRef.current = true;
const params = new URLSearchParams({
error: "user_cancelled",
error_description: "User cancelled the integration setup",
});
if (state) {
params.set("state", state);
}
window.location.href = `${redirectURI}?${params.toString()}`;
};
// Validate required parameters
const missingParams: string[] = [];
if (!providersParam) missingParams.push("providers");
if (!redirectURI) missingParams.push("redirect_uri");
if (missingParams.length > 0) {
return (
<div className="flex h-full min-h-[85vh] flex-col items-center justify-center py-10">
<AuthCard title="Invalid Request">
<ErrorCard
context="request details"
responseError={{
message: `Missing required parameters: ${missingParams.join(", ")}`,
}}
hint="Please contact the administrator of the app that sent you here."
isOurProblem={false}
/>
</AuthCard>
</div>
);
}
if (providerConfigs.length === 0) {
return (
<div className="flex h-full min-h-[85vh] flex-col items-center justify-center py-10">
<AuthCard title="Invalid Request">
<ErrorCard
context="providers"
responseError={{ message: "No providers specified" }}
hint="Please contact the administrator of the app that sent you here."
isOurProblem={false}
/>
<Button
variant="secondary"
onClick={handleCancel}
className="mt-4 w-full"
>
Cancel
</Button>
</AuthCard>
</div>
);
}
return (
<div className="flex h-full min-h-[85vh] flex-col items-center justify-center py-10">
<AuthCard title="Connect Your Accounts">
<div className="flex w-full flex-col gap-6">
<Text variant="body" className="text-center text-slate-600">
{appInfo ? (
<>
<strong>{appInfo.name}</strong> is requesting you to connect the
following integrations to your AutoGPT account.
</>
) : (
"Please connect the following integrations to continue."
)}
</Text>
{/* Provider credentials list */}
<div className="space-y-4">
{providerConfigs.map((config) => {
const schema = createSchemaFromProviderConfig(config);
const isSelected = !!selectedCredentials[config.provider];
return (
<div
key={config.provider}
className="relative rounded-xl border border-slate-200 bg-white p-4"
>
<div className="mb-4 flex items-center gap-2">
<div className="relative size-8">
<Image
src={`/integrations/${config.provider}.png`}
alt={`${config.provider} icon`}
fill
className="object-contain group-disabled:opacity-50"
/>
</div>
<Text className="mx-1" variant="large-medium">
{toDisplayName(config.provider)}
</Text>
<div className="grow"></div>
{isSelected ? (
<CheckIcon
size={20}
className="text-green-500"
weight="bold"
/>
) : (
<CircleIcon
size={20}
className="text-slate-300"
weight="bold"
/>
)}
{isSelected && (
<Text variant="small" className="text-green-600">
Connected
</Text>
)}
</div>
<CredentialsInput
schema={schema}
selectedCredentials={selectedCredentials[config.provider]}
onSelectCredentials={(credMeta) =>
handleCredentialSelect(config.provider, credMeta)
}
showTitle={false}
className="mb-0"
/>
</div>
);
})}
</div>
{/* Action buttons */}
<div className="flex flex-col gap-3">
<Button
variant="primary"
onClick={handleComplete}
disabled={!isAllComplete}
className="w-full text-lg"
>
{isAllComplete
? "Continue"
: `Connect ${providerConfigs.length - Object.values(selectedCredentials).filter(Boolean).length} more`}
</Button>
<Button
variant="secondary"
onClick={handleCancel}
className="w-full text-lg"
>
Cancel
</Button>
</div>
{/* Link to integrations settings */}
<Text variant="small" className="text-center text-slate-500">
You can view and manage all your integrations in your{" "}
<Link
href="/profile/integrations"
target="_blank"
className="text-purple-600 underline hover:text-purple-800"
>
integration settings
</Link>
.
</Text>
</div>
</AuthCard>
</div>
);
}

View File

@@ -15,13 +15,14 @@ import { HostScopedCredentialsModal } from "./components/HotScopedCredentialsMod
import { OAuthFlowWaitingModal } from "./components/OAuthWaitingModal/OAuthWaitingModal";
import { PasswordCredentialsModal } from "./components/PasswordCredentialsModal/PasswordCredentialsModal";
import { getCredentialDisplayName } from "./helpers";
import { useCredentialsInputs } from "./useCredentialsInputs";
type UseCredentialsInputsReturn = ReturnType<typeof useCredentialsInputs>;
import {
CredentialsInputState,
useCredentialsInput,
} from "./useCredentialsInput";
function isLoaded(
data: UseCredentialsInputsReturn,
): data is Extract<UseCredentialsInputsReturn, { isLoading: false }> {
data: CredentialsInputState,
): data is Extract<CredentialsInputState, { isLoading: false }> {
return data.isLoading === false;
}
@@ -33,21 +34,23 @@ type Props = {
onSelectCredentials: (newValue?: CredentialsMetaInput) => void;
onLoaded?: (loaded: boolean) => void;
readOnly?: boolean;
showTitle?: boolean;
};
export function CredentialsInput({
schema,
className,
selectedCredentials,
onSelectCredentials,
selectedCredentials: selectedCredential,
onSelectCredentials: onSelectCredential,
siblingInputs,
onLoaded,
readOnly = false,
showTitle = true,
}: Props) {
const hookData = useCredentialsInputs({
const hookData = useCredentialsInput({
schema,
selectedCredentials,
onSelectCredentials,
selectedCredential,
onSelectCredential,
siblingInputs,
onLoaded,
readOnly,
@@ -89,12 +92,14 @@ export function CredentialsInput({
return (
<div className={cn("mb-6", className)}>
<div className="mb-2 flex items-center gap-2">
<Text variant="large-medium">{displayName} credentials</Text>
{schema.description && (
<InformationTooltip description={schema.description} />
)}
</div>
{showTitle && (
<div className="mb-2 flex items-center gap-2">
<Text variant="large-medium">{displayName} credentials</Text>
{schema.description && (
<InformationTooltip description={schema.description} />
)}
</div>
)}
{hasCredentialsToShow ? (
<>
@@ -103,7 +108,7 @@ export function CredentialsInput({
credentials={credentialsToShow}
provider={provider}
displayName={displayName}
selectedCredentials={selectedCredentials}
selectedCredentials={selectedCredential}
onSelectCredential={handleCredentialSelect}
readOnly={readOnly}
/>
@@ -164,7 +169,7 @@ export function CredentialsInput({
open={isAPICredentialsModalOpen}
onClose={() => setAPICredentialsModalOpen(false)}
onCredentialsCreate={(credsMeta) => {
onSelectCredentials(credsMeta);
onSelectCredential(credsMeta);
setAPICredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
@@ -183,7 +188,7 @@ export function CredentialsInput({
open={isUserPasswordCredentialsModalOpen}
onClose={() => setUserPasswordCredentialsModalOpen(false)}
onCredentialsCreate={(creds) => {
onSelectCredentials(creds);
onSelectCredential(creds);
setUserPasswordCredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
@@ -195,7 +200,7 @@ export function CredentialsInput({
open={isHostScopedCredentialsModalOpen}
onClose={() => setHostScopedCredentialsModalOpen(false)}
onCredentialsCreate={(creds) => {
onSelectCredentials(creds);
onSelectCredential(creds);
setHostScopedCredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}

View File

@@ -5,32 +5,33 @@ import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { useQueryClient } from "@tanstack/react-query";
import { useContext, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
getActionButtonText,
OAUTH_TIMEOUT_MS,
OAuthPopupResultMessage,
} from "./helpers";
type Args = {
export type CredentialsInputState = ReturnType<typeof useCredentialsInput>;
type Params = {
schema: BlockIOCredentialsSubSchema;
selectedCredentials?: CredentialsMetaInput;
onSelectCredentials: (newValue?: CredentialsMetaInput) => void;
selectedCredential?: CredentialsMetaInput;
onSelectCredential: (newValue?: CredentialsMetaInput) => void;
siblingInputs?: Record<string, any>;
onLoaded?: (loaded: boolean) => void;
readOnly?: boolean;
};
export function useCredentialsInputs({
export function useCredentialsInput({
schema,
selectedCredentials,
onSelectCredentials,
selectedCredential,
onSelectCredential,
siblingInputs,
onLoaded,
readOnly = false,
}: Args) {
}: Params) {
const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] =
useState(false);
const [
@@ -51,7 +52,6 @@ export function useCredentialsInputs({
const api = useBackendAPI();
const queryClient = useQueryClient();
const credentials = useCredentials(schema, siblingInputs);
const allProviders = useContext(CredentialsProvidersContext);
const deleteCredentialsMutation = useDeleteV1DeleteCredentials({
mutation: {
@@ -63,57 +63,49 @@ export function useCredentialsInputs({
queryKey: [`/api/integrations/${credentials?.provider}/credentials`],
});
setCredentialToDelete(null);
if (selectedCredentials?.id === credentialToDelete?.id) {
onSelectCredentials(undefined);
if (selectedCredential?.id === credentialToDelete?.id) {
onSelectCredential(undefined);
}
},
},
});
const rawProvider = credentials
? allProviders?.[credentials.provider as keyof typeof allProviders]
: null;
useEffect(() => {
if (onLoaded) {
onLoaded(Boolean(credentials && credentials.isLoading === false));
}
}, [credentials, onLoaded]);
// Unselect credential if not available
useEffect(() => {
if (readOnly) return;
if (!credentials || !("savedCredentials" in credentials)) return;
if (
selectedCredentials &&
!credentials.savedCredentials.some((c) => c.id === selectedCredentials.id)
selectedCredential &&
!credentials.savedCredentials.some((c) => c.id === selectedCredential.id)
) {
onSelectCredentials(undefined);
onSelectCredential(undefined);
}
}, [credentials, selectedCredentials, onSelectCredentials, readOnly]);
}, [credentials, selectedCredential, onSelectCredential, readOnly]);
const { singleCredential } = useMemo(() => {
// The available credential, if there is only one
const singleCredential = useMemo(() => {
if (!credentials || !("savedCredentials" in credentials)) {
return {
singleCredential: null,
};
return null;
}
const single =
credentials.savedCredentials.length === 1
? credentials.savedCredentials[0]
: null;
return {
singleCredential: single,
};
return credentials.savedCredentials.length === 1
? credentials.savedCredentials[0]
: null;
}, [credentials]);
// Auto-select the one available credential
useEffect(() => {
if (readOnly) return;
if (singleCredential && !selectedCredentials) {
onSelectCredentials(singleCredential);
if (singleCredential && !selectedCredential) {
onSelectCredential(singleCredential);
}
}, [singleCredential, selectedCredentials, onSelectCredentials, readOnly]);
}, [singleCredential, selectedCredential, onSelectCredential, readOnly]);
if (
!credentials ||
@@ -136,25 +128,6 @@ export function useCredentialsInputs({
oAuthCallback,
} = credentials;
const allSavedCredentials = rawProvider?.savedCredentials || savedCredentials;
const credentialsToShow = (() => {
const creds = [...allSavedCredentials];
if (
!readOnly &&
selectedCredentials &&
!creds.some((c) => c.id === selectedCredentials.id)
) {
creds.push({
id: selectedCredentials.id,
type: selectedCredentials.type,
title: selectedCredentials.title || "Selected credential",
provider: provider,
} as any);
}
return creds;
})();
async function handleOAuthLogin() {
setOAuthError(null);
const { login_url, state_token } = await api.oAuthLogin(
@@ -207,7 +180,31 @@ export function useCredentialsInputs({
console.debug("Processing OAuth callback");
const credentials = await oAuthCallback(e.data.code, e.data.state);
console.debug("OAuth callback processed successfully");
onSelectCredentials({
// Check if the credential's scopes match the required scopes
const requiredScopes = schema.credentials_scopes;
if (requiredScopes && requiredScopes.length > 0) {
const grantedScopes = new Set(credentials.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) {
console.error(
`Newly created OAuth credential for ${providerName} has insufficient scopes. Required:`,
requiredScopes,
"Granted:",
credentials.scopes,
);
setOAuthError(
"Connection failed: the granted permissions don't match what's required. " +
"Please contact the application administrator.",
);
return;
}
}
onSelectCredential({
id: credentials.id,
type: "oauth2",
title: credentials.title,
@@ -253,9 +250,9 @@ export function useCredentialsInputs({
}
function handleCredentialSelect(credentialId: string) {
const selectedCreds = credentialsToShow.find((c) => c.id === credentialId);
const selectedCreds = savedCredentials.find((c) => c.id === credentialId);
if (selectedCreds) {
onSelectCredentials({
onSelectCredential({
id: selectedCreds.id,
type: selectedCreds.type,
provider: provider,
@@ -285,8 +282,8 @@ export function useCredentialsInputs({
supportsOAuth2,
supportsUserPassword,
supportsHostScoped,
credentialsToShow,
selectedCredentials,
credentialsToShow: savedCredentials,
selectedCredential,
oAuthError,
isAPICredentialsModalOpen,
isUserPasswordCredentialsModalOpen,
@@ -300,7 +297,7 @@ export function useCredentialsInputs({
supportsApiKey,
supportsUserPassword,
supportsHostScoped,
credentialsToShow.length > 0,
savedCredentials.length > 0,
),
setAPICredentialsModalOpen,
setUserPasswordCredentialsModalOpen,
@@ -311,7 +308,7 @@ export function useCredentialsInputs({
handleDeleteCredential,
handleDeleteConfirm,
handleOAuthLogin,
onSelectCredentials,
onSelectCredential,
schema,
siblingInputs,
};

View File

@@ -11,8 +11,16 @@ import { environment } from "@/services/environment";
import { LoadingLogin } from "./components/LoadingLogin";
import { useLoginPage } from "./useLoginPage";
import { MobileWarningBanner } from "@/components/auth/MobileWarningBanner";
import { useSearchParams } from "next/navigation";
export default function LoginPage() {
const searchParams = useSearchParams();
const nextUrl = searchParams.get("next");
// Preserve next parameter when switching between login/signup
const signupHref = nextUrl
? `/signup?next=${encodeURIComponent(nextUrl)}`
: "/signup";
const {
user,
form,
@@ -108,7 +116,7 @@ export default function LoginPage() {
</Form>
<AuthCard.BottomText
text="Don't have an account?"
link={{ text: "Sign up", href: "/signup" }}
link={{ text: "Sign up", href: signupHref }}
/>
</AuthCard>
<MobileWarningBanner />

View File

@@ -3,7 +3,7 @@ import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { environment } from "@/services/environment";
import { loginFormSchema, LoginProvider } from "@/types/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import z from "zod";
@@ -13,6 +13,7 @@ export function useLoginPage() {
const { supabase, user, isUserLoading, isLoggedIn } = useSupabase();
const [feedback, setFeedback] = useState<string | null>(null);
const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isLoggingIn, setIsLoggingIn] = useState(false);
@@ -20,11 +21,14 @@ export function useLoginPage() {
const [showNotAllowedModal, setShowNotAllowedModal] = useState(false);
const isCloudEnv = environment.isCloud();
// Get redirect destination from 'next' query parameter
const nextUrl = searchParams.get("next");
useEffect(() => {
if (isLoggedIn && !isLoggingIn) {
router.push("/marketplace");
router.push(nextUrl || "/marketplace");
}
}, [isLoggedIn, isLoggingIn]);
}, [isLoggedIn, isLoggingIn, nextUrl, router]);
const form = useForm<z.infer<typeof loginFormSchema>>({
resolver: zodResolver(loginFormSchema),
@@ -39,10 +43,16 @@ export function useLoginPage() {
setIsLoggingIn(true);
try {
// Include next URL in OAuth flow if present
const callbackUrl = nextUrl
? `/auth/callback?next=${encodeURIComponent(nextUrl)}`
: `/auth/callback`;
const fullCallbackUrl = `${window.location.origin}${callbackUrl}`;
const response = await fetch("/api/auth/provider", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider }),
body: JSON.stringify({ provider, redirectTo: fullCallbackUrl }),
});
if (!response.ok) {
@@ -83,7 +93,9 @@ export function useLoginPage() {
throw new Error(result.error || "Login failed");
}
if (result.onboarding) {
if (nextUrl) {
router.replace(nextUrl);
} else if (result.onboarding) {
router.replace("/onboarding");
} else {
router.replace("/marketplace");

View File

@@ -1,5 +1,5 @@
import { Metadata } from "next/types";
import { APIKeysSection } from "@/app/(platform)/profile/(user)/api_keys/components/APIKeySection/APIKeySection";
import { APIKeysSection } from "@/app/(platform)/profile/(user)/api-keys/components/APIKeySection/APIKeySection";
import {
Card,
CardContent,

View File

@@ -3,13 +3,14 @@
import * as React from "react";
import { Sidebar } from "@/components/__legacy__/Sidebar";
import {
IconDashboardLayout,
IconIntegrations,
IconProfile,
IconSliders,
IconCoin,
} from "@/components/__legacy__/ui/icons";
import { KeyIcon } from "lucide-react";
AppWindowIcon,
CoinsIcon,
KeyIcon,
PlugsIcon,
SlidersHorizontalIcon,
StorefrontIcon,
UserCircleIcon,
} from "@phosphor-icons/react";
import { useGetFlag, Flag } from "@/services/feature-flags/use-get-flag";
export default function Layout({ children }: { children: React.ReactNode }) {
@@ -18,39 +19,44 @@ export default function Layout({ children }: { children: React.ReactNode }) {
const sidebarLinkGroups = [
{
links: [
{
text: "Profile",
href: "/profile",
icon: <UserCircleIcon className="size-5" />,
},
{
text: "Creator Dashboard",
href: "/profile/dashboard",
icon: <IconDashboardLayout className="h-6 w-6" />,
icon: <StorefrontIcon className="size-5" />,
},
...(isPaymentEnabled
...(isPaymentEnabled || true
? [
{
text: "Billing",
href: "/profile/credits",
icon: <IconCoin className="h-6 w-6" />,
icon: <CoinsIcon className="size-5" />,
},
]
: []),
{
text: "Integrations",
href: "/profile/integrations",
icon: <IconIntegrations className="h-6 w-6" />,
},
{
text: "API Keys",
href: "/profile/api_keys",
icon: <KeyIcon className="h-6 w-6" />,
},
{
text: "Profile",
href: "/profile",
icon: <IconProfile className="h-6 w-6" />,
icon: <PlugsIcon className="size-5" />,
},
{
text: "Settings",
href: "/profile/settings",
icon: <IconSliders className="h-6 w-6" />,
icon: <SlidersHorizontalIcon className="size-5" />,
},
{
text: "API Keys",
href: "/profile/api-keys",
icon: <KeyIcon className="size-5" />,
},
{
text: "OAuth Apps",
href: "/profile/oauth-apps",
icon: <AppWindowIcon className="size-5" />,
},
],
},

View File

@@ -0,0 +1,147 @@
"use client";
import { useRef } from "react";
import { UploadIcon, ImageIcon, PowerIcon } from "@phosphor-icons/react";
import { Button } from "@/components/atoms/Button/Button";
import { Badge } from "@/components/atoms/Badge/Badge";
import { useOAuthApps } from "./useOAuthApps";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
export function OAuthAppsSection() {
const {
oauthApps,
isLoading,
updatingAppId,
uploadingAppId,
handleToggleStatus,
handleUploadLogo,
} = useOAuthApps();
const fileInputRefs = useRef<{ [key: string]: HTMLInputElement | null }>({});
const handleFileChange = (
appId: string,
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (file) {
handleUploadLogo(appId, file);
}
// Reset the input so the same file can be selected again
event.target.value = "";
};
if (isLoading) {
return (
<div className="flex justify-center p-4">
<LoadingSpinner size="large" />
</div>
);
}
if (oauthApps.length === 0) {
return (
<div className="py-8 text-center text-muted-foreground">
<p>You don&apos;t have any OAuth applications.</p>
<p className="mt-2 text-sm">
OAuth applications can currently <strong>not</strong> be registered
via the API. Contact the system administrator to request an OAuth app
registration.
</p>
</div>
);
}
return (
<div className="grid gap-4 sm:grid-cols-1 lg:grid-cols-2">
{oauthApps.map((app) => (
<div
key={app.id}
data-testid="oauth-app-card"
className="flex flex-col gap-4 rounded-xl border bg-card p-5"
>
{/* Header: Logo, Name, Status */}
<div className="flex items-start gap-4">
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-xl border bg-muted">
{app.logo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={app.logo_url}
alt={`${app.name} logo`}
className="h-full w-full object-cover"
/>
) : (
<ImageIcon className="h-7 w-7 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-lg font-semibold">{app.name}</h3>
<Badge
className="ml-2"
variant={app.is_active ? "success" : "error"}
>
{app.is_active ? "Active" : "Disabled"}
</Badge>
</div>
{app.description && (
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{app.description}
</p>
)}
</div>
</div>
{/* Client ID */}
<div>
<span className="text-xs font-medium text-muted-foreground">
Client ID
</span>
<code
data-testid="oauth-app-client-id"
className="mt-1 block w-full truncate rounded-md border bg-muted px-3 py-2 text-xs"
>
{app.client_id}
</code>
</div>
{/* Footer: Created date and Actions */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
<span className="text-xs text-muted-foreground">
Created {new Date(app.created_at).toLocaleDateString()}
</span>
<div className="flex items-center gap-3">
<Button
variant={app.is_active ? "outline" : "primary"}
size="small"
onClick={() => handleToggleStatus(app.id, app.is_active)}
loading={updatingAppId === app.id}
leftIcon={<PowerIcon className="h-4 w-4" />}
>
{app.is_active ? "Disable" : "Enable"}
</Button>
<input
type="file"
ref={(el) => {
fileInputRefs.current[app.id] = el;
}}
onChange={(e) => handleFileChange(app.id, e)}
accept="image/jpeg,image/png,image/webp"
className="hidden"
/>
<Button
variant="outline"
size="small"
onClick={() => fileInputRefs.current[app.id]?.click()}
loading={uploadingAppId === app.id}
leftIcon={<UploadIcon className="h-4 w-4" />}
>
{app.logo_url ? "Change " : "Upload "}Logo
</Button>
</div>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import {
useGetOauthListMyOauthApps,
usePatchOauthUpdateAppStatus,
usePostOauthUploadAppLogo,
getGetOauthListMyOauthAppsQueryKey,
} from "@/app/api/__generated__/endpoints/oauth/oauth";
import { OAuthApplicationInfo } from "@/app/api/__generated__/models/oAuthApplicationInfo";
import { okData } from "@/app/api/helpers";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { getQueryClient } from "@/lib/react-query/queryClient";
export const useOAuthApps = () => {
const queryClient = getQueryClient();
const { toast } = useToast();
const [updatingAppId, setUpdatingAppId] = useState<string | null>(null);
const [uploadingAppId, setUploadingAppId] = useState<string | null>(null);
const { data: oauthAppsResponse, isLoading } = useGetOauthListMyOauthApps({
query: { select: okData<OAuthApplicationInfo[]> },
});
const { mutateAsync: updateStatus } = usePatchOauthUpdateAppStatus({
mutation: {
onSettled: () => {
return queryClient.invalidateQueries({
queryKey: getGetOauthListMyOauthAppsQueryKey(),
});
},
},
});
const { mutateAsync: uploadLogo } = usePostOauthUploadAppLogo({
mutation: {
onSettled: () => {
return queryClient.invalidateQueries({
queryKey: getGetOauthListMyOauthAppsQueryKey(),
});
},
},
});
const handleToggleStatus = async (appId: string, currentStatus: boolean) => {
try {
setUpdatingAppId(appId);
const result = await updateStatus({
appId,
data: { is_active: !currentStatus },
});
if (result.status === 200) {
toast({
title: "Success",
description: `Application ${result.data.is_active ? "enabled" : "disabled"} successfully`,
});
} else {
throw new Error("Failed to update status");
}
} catch {
toast({
title: "Error",
description: "Failed to update application status",
variant: "destructive",
});
} finally {
setUpdatingAppId(null);
}
};
const handleUploadLogo = async (appId: string, file: File) => {
try {
setUploadingAppId(appId);
const result = await uploadLogo({
appId,
data: { file },
});
if (result.status === 200) {
toast({
title: "Success",
description: "Logo uploaded successfully",
});
} else {
throw new Error("Failed to upload logo");
}
} catch (error) {
console.error("Failed to upload logo:", error);
const errorMessage =
error instanceof Error ? error.message : "Failed to upload logo";
toast({
title: "Error",
description: errorMessage,
variant: "destructive",
});
} finally {
setUploadingAppId(null);
}
};
return {
oauthApps: oauthAppsResponse ?? [],
isLoading,
updatingAppId,
uploadingAppId,
handleToggleStatus,
handleUploadLogo,
};
};

View File

@@ -0,0 +1,21 @@
import { Metadata } from "next/types";
import { Text } from "@/components/atoms/Text/Text";
import { OAuthAppsSection } from "./components/OAuthAppsSection";
export const metadata: Metadata = { title: "OAuth Apps - AutoGPT Platform" };
const OAuthAppsPage = () => {
return (
<div className="container space-y-6 py-10">
<div className="flex flex-col gap-2">
<Text variant="h3">OAuth Applications</Text>
<Text variant="large">
Manage your OAuth applications that use the AutoGPT Platform API
</Text>
</div>
<OAuthAppsSection />
</div>
);
};
export default OAuthAppsPage;

View File

@@ -21,8 +21,16 @@ import { WarningOctagonIcon } from "@phosphor-icons/react/dist/ssr";
import { LoadingSignup } from "./components/LoadingSignup";
import { useSignupPage } from "./useSignupPage";
import { MobileWarningBanner } from "@/components/auth/MobileWarningBanner";
import { useSearchParams } from "next/navigation";
export default function SignupPage() {
const searchParams = useSearchParams();
const nextUrl = searchParams.get("next");
// Preserve next parameter when switching between login/signup
const loginHref = nextUrl
? `/login?next=${encodeURIComponent(nextUrl)}`
: "/login";
const {
form,
feedback,
@@ -186,7 +194,7 @@ export default function SignupPage() {
<AuthCard.BottomText
text="Already a member?"
link={{ text: "Log in", href: "/login" }}
link={{ text: "Log in", href: loginHref }}
/>
</AuthCard>
<MobileWarningBanner />

View File

@@ -3,7 +3,7 @@ import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { environment } from "@/services/environment";
import { LoginProvider, signupFormSchema } from "@/types/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import z from "zod";
@@ -14,17 +14,21 @@ export function useSignupPage() {
const [feedback, setFeedback] = useState<string | null>(null);
const { toast } = useToast();
const router = useRouter();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = useState(false);
const [isSigningUp, setIsSigningUp] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const [showNotAllowedModal, setShowNotAllowedModal] = useState(false);
const isCloudEnv = environment.isCloud();
// Get redirect destination from 'next' query parameter
const nextUrl = searchParams.get("next");
useEffect(() => {
if (isLoggedIn && !isSigningUp) {
router.push("/marketplace");
router.push(nextUrl || "/marketplace");
}
}, [isLoggedIn, isSigningUp]);
}, [isLoggedIn, isSigningUp, nextUrl, router]);
const form = useForm<z.infer<typeof signupFormSchema>>({
resolver: zodResolver(signupFormSchema),
@@ -41,10 +45,16 @@ export function useSignupPage() {
setIsSigningUp(true);
try {
// Include next URL in OAuth flow if present
const callbackUrl = nextUrl
? `/auth/callback?next=${encodeURIComponent(nextUrl)}`
: `/auth/callback`;
const fullCallbackUrl = `${window.location.origin}${callbackUrl}`;
const response = await fetch("/api/auth/provider", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider }),
body: JSON.stringify({ provider, redirectTo: fullCallbackUrl }),
});
if (!response.ok) {
@@ -118,8 +128,9 @@ export function useSignupPage() {
return;
}
const next = result.next || "/";
if (next) router.replace(next);
// Prefer the URL's next parameter, then result.next (for onboarding), then default
const redirectTo = nextUrl || result.next || "/";
router.replace(redirectTo);
} catch (error) {
setIsLoading(false);
setIsSigningUp(false);

View File

@@ -113,6 +113,19 @@ export const customMutator = async <
body: data,
});
// Check if response is a redirect (3xx) and redirect is allowed
const allowRedirect = requestOptions.redirect !== "error";
const isRedirect = response.status >= 300 && response.status < 400;
// For redirect responses, return early without trying to parse body
if (allowRedirect && isRedirect) {
return {
status: response.status,
data: null,
headers: response.headers,
} as T;
}
if (!response.ok) {
let responseData: any = null;
try {

View File

@@ -5370,6 +5370,369 @@
}
}
},
"/api/oauth/app/{client_id}": {
"get": {
"tags": ["oauth"],
"summary": "Get Oauth App Info",
"description": "Get public information about an OAuth application.\n\nThis endpoint is used by the consent screen to display application details\nto the user before they authorize access.\n\nReturns:\n- name: Application name\n- description: Application description (if provided)\n- scopes: List of scopes the application is allowed to request",
"operationId": "getOauthGetOauthAppInfo",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "client_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Client Id" }
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthApplicationPublicInfo"
}
}
}
},
"404": { "description": "Application not found or disabled" },
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
}
}
},
"/api/oauth/authorize": {
"post": {
"tags": ["oauth"],
"summary": "Authorize",
"description": "OAuth 2.0 Authorization Endpoint\n\nUser must be logged in (authenticated with Supabase JWT).\nThis endpoint creates an authorization code and returns a redirect URL.\n\nPKCE (Proof Key for Code Exchange) is REQUIRED for all authorization requests.\n\nThe frontend consent screen should call this endpoint after the user approves,\nthen redirect the user to the returned `redirect_url`.\n\nRequest Body:\n- client_id: The OAuth application's client ID\n- redirect_uri: Where to redirect after authorization (must match registered URI)\n- scopes: List of permissions (e.g., \"EXECUTE_GRAPH READ_GRAPH\")\n- state: Anti-CSRF token provided by client (will be returned in redirect)\n- response_type: Must be \"code\" (for authorization code flow)\n- code_challenge: PKCE code challenge (required)\n- code_challenge_method: \"S256\" (recommended) or \"plain\"\n\nReturns:\n- redirect_url: The URL to redirect the user to (includes authorization code)\n\nError cases return a redirect_url with error parameters, or raise HTTPException\nfor critical errors (like invalid redirect_uri).",
"operationId": "postOauthAuthorize",
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/AuthorizeRequest" }
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/AuthorizeResponse" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
},
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/oauth/token": {
"post": {
"tags": ["oauth"],
"summary": "Token",
"description": "OAuth 2.0 Token Endpoint\n\nExchanges authorization code or refresh token for access token.\n\nGrant Types:\n1. authorization_code: Exchange authorization code for tokens\n - Required: grant_type, code, redirect_uri, client_id, client_secret\n - Optional: code_verifier (required if PKCE was used)\n\n2. refresh_token: Exchange refresh token for new access token\n - Required: grant_type, refresh_token, client_id, client_secret\n\nReturns:\n- access_token: Bearer token for API access (1 hour TTL)\n- token_type: \"Bearer\"\n- expires_in: Seconds until access token expires\n- refresh_token: Token for refreshing access (30 days TTL)\n- scopes: List of scopes",
"operationId": "postOauthToken",
"requestBody": {
"content": {
"application/json": {
"schema": {
"anyOf": [
{ "$ref": "#/components/schemas/TokenRequestByCode" },
{ "$ref": "#/components/schemas/TokenRequestByRefreshToken" }
],
"title": "Request"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/TokenResponse" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/oauth/introspect": {
"post": {
"tags": ["oauth"],
"summary": "Introspect",
"description": "OAuth 2.0 Token Introspection Endpoint (RFC 7662)\n\nAllows clients to check if a token is valid and get its metadata.\n\nReturns:\n- active: Whether the token is currently active\n- scopes: List of authorized scopes (if active)\n- client_id: The client the token was issued to (if active)\n- user_id: The user the token represents (if active)\n- exp: Expiration timestamp (if active)\n- token_type: \"access_token\" or \"refresh_token\" (if active)",
"operationId": "postOauthIntrospect",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Body_postOauthIntrospect"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TokenIntrospectionResult"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/oauth/revoke": {
"post": {
"tags": ["oauth"],
"summary": "Revoke",
"description": "OAuth 2.0 Token Revocation Endpoint (RFC 7009)\n\nAllows clients to revoke an access or refresh token.\n\nNote: Revoking a refresh token does NOT revoke associated access tokens.\nRevoking an access token does NOT revoke the associated refresh token.",
"operationId": "postOauthRevoke",
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Body_postOauthRevoke" }
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": { "application/json": { "schema": {} } }
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/oauth/apps/mine": {
"get": {
"tags": ["oauth"],
"summary": "List My Oauth Apps",
"description": "List all OAuth applications owned by the current user.\n\nReturns a list of OAuth applications with their details including:\n- id, name, description, logo_url\n- client_id (public identifier)\n- redirect_uris, grant_types, scopes\n- is_active status\n- created_at, updated_at timestamps\n\nNote: client_secret is never returned for security reasons.",
"operationId": "getOauthListMyOauthApps",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/OAuthApplicationInfo"
},
"type": "array",
"title": "Response Getoauthlistmyoauthapps"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
},
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/oauth/apps/{app_id}/status": {
"patch": {
"tags": ["oauth"],
"summary": "Update App Status",
"description": "Enable or disable an OAuth application.\n\nOnly the application owner can update the status.\nWhen disabled, the application cannot be used for new authorizations\nand existing access tokens will fail validation.\n\nReturns the updated application info.",
"operationId": "patchOauthUpdateAppStatus",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "app_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "App Id" }
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Body_patchOauthUpdateAppStatus"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthApplicationInfo"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
}
}
},
"/api/oauth/apps/{app_id}/logo": {
"patch": {
"tags": ["oauth"],
"summary": "Update App Logo",
"description": "Update the logo URL for an OAuth application.\n\nOnly the application owner can update the logo.\nThe logo should be uploaded first using the media upload endpoint,\nthen this endpoint is called with the resulting URL.\n\nLogo requirements:\n- Must be square (1:1 aspect ratio)\n- Minimum 512x512 pixels\n- Maximum 2048x2048 pixels\n\nReturns the updated application info.",
"operationId": "patchOauthUpdateAppLogo",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "app_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "App Id" }
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/UpdateAppLogoRequest" }
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthApplicationInfo"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
}
}
},
"/api/oauth/apps/{app_id}/logo/upload": {
"post": {
"tags": ["oauth"],
"summary": "Upload App Logo",
"description": "Upload a logo image for an OAuth application.\n\nRequirements:\n- Image must be square (1:1 aspect ratio)\n- Minimum 512x512 pixels\n- Maximum 2048x2048 pixels\n- Allowed formats: JPEG, PNG, WebP\n- Maximum file size: 3MB\n\nThe image is uploaded to cloud storage and the app's logoUrl is updated.\nReturns the updated application info.",
"operationId": "postOauthUploadAppLogo",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "app_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "App Id" }
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_postOauthUploadAppLogo"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthApplicationInfo"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
}
}
},
"/health": {
"get": {
"tags": ["health"],
@@ -5418,29 +5781,30 @@
},
"APIKeyInfo": {
"properties": {
"id": { "type": "string", "title": "Id" },
"name": { "type": "string", "title": "Name" },
"head": {
"type": "string",
"title": "Head",
"description": "The first 8 characters of the key"
},
"tail": {
"type": "string",
"title": "Tail",
"description": "The last 8 characters of the key"
},
"status": { "$ref": "#/components/schemas/APIKeyStatus" },
"permissions": {
"user_id": { "type": "string", "title": "User Id" },
"scopes": {
"items": { "$ref": "#/components/schemas/APIKeyPermission" },
"type": "array",
"title": "Permissions"
"title": "Scopes"
},
"type": {
"type": "string",
"const": "api_key",
"title": "Type",
"default": "api_key"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
},
"expires_at": {
"anyOf": [
{ "type": "string", "format": "date-time" },
{ "type": "null" }
],
"title": "Expires At"
},
"last_used_at": {
"anyOf": [
{ "type": "string", "format": "date-time" },
@@ -5455,28 +5819,41 @@
],
"title": "Revoked At"
},
"id": { "type": "string", "title": "Id" },
"name": { "type": "string", "title": "Name" },
"head": {
"type": "string",
"title": "Head",
"description": "The first 8 characters of the key"
},
"tail": {
"type": "string",
"title": "Tail",
"description": "The last 8 characters of the key"
},
"status": { "$ref": "#/components/schemas/APIKeyStatus" },
"description": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Description"
},
"user_id": { "type": "string", "title": "User Id" }
}
},
"type": "object",
"required": [
"user_id",
"scopes",
"created_at",
"id",
"name",
"head",
"tail",
"status",
"permissions",
"created_at",
"user_id"
"status"
],
"title": "APIKeyInfo"
},
"APIKeyPermission": {
"type": "string",
"enum": [
"IDENTITY",
"EXECUTE_GRAPH",
"READ_GRAPH",
"EXECUTE_BLOCK",
@@ -5614,6 +5991,72 @@
"required": ["answer", "documents", "success"],
"title": "ApiResponse"
},
"AuthorizeRequest": {
"properties": {
"client_id": {
"type": "string",
"title": "Client Id",
"description": "Client identifier"
},
"redirect_uri": {
"type": "string",
"title": "Redirect Uri",
"description": "Redirect URI"
},
"scopes": {
"items": { "type": "string" },
"type": "array",
"title": "Scopes",
"description": "List of scopes"
},
"state": {
"type": "string",
"title": "State",
"description": "Anti-CSRF token from client"
},
"response_type": {
"type": "string",
"title": "Response Type",
"description": "Must be 'code' for authorization code flow",
"default": "code"
},
"code_challenge": {
"type": "string",
"title": "Code Challenge",
"description": "PKCE code challenge (required)"
},
"code_challenge_method": {
"type": "string",
"enum": ["S256", "plain"],
"title": "Code Challenge Method",
"description": "PKCE code challenge method (S256 recommended)",
"default": "S256"
}
},
"type": "object",
"required": [
"client_id",
"redirect_uri",
"scopes",
"state",
"code_challenge"
],
"title": "AuthorizeRequest",
"description": "OAuth 2.0 authorization request"
},
"AuthorizeResponse": {
"properties": {
"redirect_url": {
"type": "string",
"title": "Redirect Url",
"description": "URL to redirect the user to"
}
},
"type": "object",
"required": ["redirect_url"],
"title": "AuthorizeResponse",
"description": "OAuth 2.0 authorization response with redirect URL"
},
"AutoTopUpConfig": {
"properties": {
"amount": { "type": "integer", "title": "Amount" },
@@ -5863,6 +6306,86 @@
"required": ["blocks", "pagination"],
"title": "BlockResponse"
},
"Body_patchOauthUpdateAppStatus": {
"properties": {
"is_active": {
"type": "boolean",
"title": "Is Active",
"description": "Whether the app should be active"
}
},
"type": "object",
"required": ["is_active"],
"title": "Body_patchOauthUpdateAppStatus"
},
"Body_postOauthIntrospect": {
"properties": {
"token": {
"type": "string",
"title": "Token",
"description": "Token to introspect"
},
"token_type_hint": {
"anyOf": [
{ "type": "string", "enum": ["access_token", "refresh_token"] },
{ "type": "null" }
],
"title": "Token Type Hint",
"description": "Hint about token type ('access_token' or 'refresh_token')"
},
"client_id": {
"type": "string",
"title": "Client Id",
"description": "Client identifier"
},
"client_secret": {
"type": "string",
"title": "Client Secret",
"description": "Client secret"
}
},
"type": "object",
"required": ["token", "client_id", "client_secret"],
"title": "Body_postOauthIntrospect"
},
"Body_postOauthRevoke": {
"properties": {
"token": {
"type": "string",
"title": "Token",
"description": "Token to revoke"
},
"token_type_hint": {
"anyOf": [
{ "type": "string", "enum": ["access_token", "refresh_token"] },
{ "type": "null" }
],
"title": "Token Type Hint",
"description": "Hint about token type ('access_token' or 'refresh_token')"
},
"client_id": {
"type": "string",
"title": "Client Id",
"description": "Client identifier"
},
"client_secret": {
"type": "string",
"title": "Client Secret",
"description": "Client secret"
}
},
"type": "object",
"required": ["token", "client_id", "client_secret"],
"title": "Body_postOauthRevoke"
},
"Body_postOauthUploadAppLogo": {
"properties": {
"file": { "type": "string", "format": "binary", "title": "File" }
},
"type": "object",
"required": ["file"],
"title": "Body_postOauthUploadAppLogo"
},
"Body_postV1Exchange_oauth_code_for_tokens": {
"properties": {
"code": {
@@ -7855,6 +8378,85 @@
"required": ["provider", "access_token", "scopes"],
"title": "OAuth2Credentials"
},
"OAuthApplicationInfo": {
"properties": {
"id": { "type": "string", "title": "Id" },
"name": { "type": "string", "title": "Name" },
"description": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Description"
},
"logo_url": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Logo Url"
},
"client_id": { "type": "string", "title": "Client Id" },
"redirect_uris": {
"items": { "type": "string" },
"type": "array",
"title": "Redirect Uris"
},
"grant_types": {
"items": { "type": "string" },
"type": "array",
"title": "Grant Types"
},
"scopes": {
"items": { "$ref": "#/components/schemas/APIKeyPermission" },
"type": "array",
"title": "Scopes"
},
"owner_id": { "type": "string", "title": "Owner Id" },
"is_active": { "type": "boolean", "title": "Is Active" },
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
},
"updated_at": {
"type": "string",
"format": "date-time",
"title": "Updated At"
}
},
"type": "object",
"required": [
"id",
"name",
"client_id",
"redirect_uris",
"grant_types",
"scopes",
"owner_id",
"is_active",
"created_at",
"updated_at"
],
"title": "OAuthApplicationInfo",
"description": "OAuth application information (without client secret hash)"
},
"OAuthApplicationPublicInfo": {
"properties": {
"name": { "type": "string", "title": "Name" },
"description": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Description"
},
"logo_url": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Logo Url"
},
"scopes": {
"items": { "type": "string" },
"type": "array",
"title": "Scopes"
}
},
"type": "object",
"required": ["name", "scopes"],
"title": "OAuthApplicationPublicInfo",
"description": "Public information about an OAuth application (for consent screen)"
},
"OnboardingStep": {
"type": "string",
"enum": [
@@ -9892,6 +10494,134 @@
"required": ["timezone"],
"title": "TimezoneResponse"
},
"TokenIntrospectionResult": {
"properties": {
"active": { "type": "boolean", "title": "Active" },
"scopes": {
"anyOf": [
{ "items": { "type": "string" }, "type": "array" },
{ "type": "null" }
],
"title": "Scopes"
},
"client_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Client Id"
},
"user_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "User Id"
},
"exp": {
"anyOf": [{ "type": "integer" }, { "type": "null" }],
"title": "Exp"
},
"token_type": {
"anyOf": [
{ "type": "string", "enum": ["access_token", "refresh_token"] },
{ "type": "null" }
],
"title": "Token Type"
}
},
"type": "object",
"required": ["active"],
"title": "TokenIntrospectionResult",
"description": "Result of token introspection (RFC 7662)"
},
"TokenRequestByCode": {
"properties": {
"grant_type": {
"type": "string",
"const": "authorization_code",
"title": "Grant Type"
},
"code": {
"type": "string",
"title": "Code",
"description": "Authorization code"
},
"redirect_uri": {
"type": "string",
"title": "Redirect Uri",
"description": "Redirect URI (must match authorization request)"
},
"client_id": { "type": "string", "title": "Client Id" },
"client_secret": { "type": "string", "title": "Client Secret" },
"code_verifier": {
"type": "string",
"title": "Code Verifier",
"description": "PKCE code verifier"
}
},
"type": "object",
"required": [
"grant_type",
"code",
"redirect_uri",
"client_id",
"client_secret",
"code_verifier"
],
"title": "TokenRequestByCode"
},
"TokenRequestByRefreshToken": {
"properties": {
"grant_type": {
"type": "string",
"const": "refresh_token",
"title": "Grant Type"
},
"refresh_token": { "type": "string", "title": "Refresh Token" },
"client_id": { "type": "string", "title": "Client Id" },
"client_secret": { "type": "string", "title": "Client Secret" }
},
"type": "object",
"required": [
"grant_type",
"refresh_token",
"client_id",
"client_secret"
],
"title": "TokenRequestByRefreshToken"
},
"TokenResponse": {
"properties": {
"token_type": {
"type": "string",
"const": "Bearer",
"title": "Token Type",
"default": "Bearer"
},
"access_token": { "type": "string", "title": "Access Token" },
"access_token_expires_at": {
"type": "string",
"format": "date-time",
"title": "Access Token Expires At"
},
"refresh_token": { "type": "string", "title": "Refresh Token" },
"refresh_token_expires_at": {
"type": "string",
"format": "date-time",
"title": "Refresh Token Expires At"
},
"scopes": {
"items": { "type": "string" },
"type": "array",
"title": "Scopes"
}
},
"type": "object",
"required": [
"access_token",
"access_token_expires_at",
"refresh_token",
"refresh_token_expires_at",
"scopes"
],
"title": "TokenResponse",
"description": "OAuth 2.0 token response"
},
"TransactionHistory": {
"properties": {
"transactions": {
@@ -9938,6 +10668,18 @@
"required": ["name", "graph_id", "graph_version", "trigger_config"],
"title": "TriggeredPresetSetupRequest"
},
"UpdateAppLogoRequest": {
"properties": {
"logo_url": {
"type": "string",
"title": "Logo Url",
"description": "URL of the uploaded logo image"
}
},
"type": "object",
"required": ["logo_url"],
"title": "UpdateAppLogoRequest"
},
"UpdatePermissionsRequest": {
"properties": {
"permissions": {