diff --git a/autogpt_platform/autogpt_libs/autogpt_libs/api_key/keysmith.py b/autogpt_platform/autogpt_libs/autogpt_libs/api_key/keysmith.py index 394044a69d..aee7040288 100644 --- a/autogpt_platform/autogpt_libs/autogpt_libs/api_key/keysmith.py +++ b/autogpt_platform/autogpt_libs/autogpt_libs/api_key/keysmith.py @@ -57,6 +57,9 @@ class APIKeySmith: def hash_key(self, raw_key: str) -> tuple[str, str]: """Migrate a legacy hash to secure hash format.""" + if not raw_key.startswith(self.PREFIX): + raise ValueError("Key without 'agpt_' prefix would fail validation") + salt = self._generate_salt() hash = self._hash_key_with_salt(raw_key, salt) return hash, salt.hex() diff --git a/autogpt_platform/backend/backend/cli/__init__.py b/autogpt_platform/backend/backend/cli/__init__.py new file mode 100644 index 0000000000..d96b0c7d49 --- /dev/null +++ b/autogpt_platform/backend/backend/cli/__init__.py @@ -0,0 +1 @@ +"""CLI utilities for backend development & administration""" diff --git a/autogpt_platform/backend/backend/cli/generate_openapi_json.py b/autogpt_platform/backend/backend/cli/generate_openapi_json.py new file mode 100644 index 0000000000..313e603c44 --- /dev/null +++ b/autogpt_platform/backend/backend/cli/generate_openapi_json.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +""" +Script to generate OpenAPI JSON specification for the FastAPI app. + +This script imports the FastAPI app from backend.server.rest_api and outputs +the OpenAPI specification as JSON to stdout or a specified file. + +Usage: + `poetry run python generate_openapi_json.py` + `poetry run python generate_openapi_json.py --output openapi.json` + `poetry run python generate_openapi_json.py --indent 4 --output openapi.json` +""" + +import json +import os +from pathlib import Path + +import click + + +@click.command() +@click.option( + "--output", + type=click.Path(dir_okay=False, path_type=Path), + help="Output file path (default: stdout)", +) +@click.option( + "--pretty", + type=click.BOOL, + default=False, + help="Pretty-print JSON output (indented 2 spaces)", +) +def main(output: Path, pretty: bool): + """Generate and output the OpenAPI JSON specification.""" + openapi_schema = get_openapi_schema() + + json_output = json.dumps(openapi_schema, indent=2 if pretty else None) + + if output: + output.write_text(json_output) + click.echo(f"✅ OpenAPI specification written to {output}\n\nPreview:") + click.echo(f"\n{json_output[:500]} ...") + else: + print(json_output) + + +def get_openapi_schema(): + """Get the OpenAPI schema from the FastAPI app""" + from backend.server.rest_api import app + + return app.openapi() + + +if __name__ == "__main__": + os.environ["LOG_LEVEL"] = "ERROR" # disable stdout log output + + main() diff --git a/autogpt_platform/backend/backend/cli/oauth_tool.py b/autogpt_platform/backend/backend/cli/oauth_tool.py new file mode 100755 index 0000000000..57982d359b --- /dev/null +++ b/autogpt_platform/backend/backend/cli/oauth_tool.py @@ -0,0 +1,1177 @@ +#!/usr/bin/env python3 +""" +OAuth Application Credential Generator and Test Server + +Generates client IDs, client secrets, and SQL INSERT statements for OAuth applications. +Also provides a test server to test the OAuth flows end-to-end. + +Usage: + # Generate credentials interactively (recommended) + poetry run oauth-tool generate-app + + # Generate credentials with all options provided + poetry run oauth-tool generate-app \\ + --name "My App" \\ + --description "My application description" \\ + --redirect-uris "https://app.example.com/callback,http://localhost:3000/callback" \\ + --scopes "EXECUTE_GRAPH,READ_GRAPH" + + # Mix of options and interactive prompts + poetry run oauth-tool generate-app --name "My App" + + # Hash an existing plaintext secret (for secret rotation) + poetry run oauth-tool hash-secret "my-plaintext-secret" + + # Validate a plaintext secret against a hash and salt + poetry run oauth-tool validate-secret "my-plaintext-secret" "hash" "salt" + + # Run a test server to test OAuth flows + poetry run oauth-tool test-server --owner-id YOUR_USER_ID +""" + +import asyncio +import base64 +import hashlib +import secrets +import sys +import uuid +from datetime import datetime +from typing import Optional +from urllib.parse import urlparse + +import click +from autogpt_libs.api_key.keysmith import APIKeySmith +from prisma.enums import APIKeyPermission + +keysmith = APIKeySmith() + + +def generate_client_id() -> str: + """Generate a unique client ID""" + return f"agpt_client_{secrets.token_urlsafe(16)}" + + +def generate_client_secret() -> tuple[str, str, str]: + """ + Generate a client secret with its hash and salt. + Returns (plaintext_secret, hashed_secret, salt) + """ + # Generate a secure random secret (32 bytes = 256 bits of entropy) + plaintext = f"agpt_secret_{secrets.token_urlsafe(32)}" + + # Hash using Scrypt (same as API keys) + hashed, salt = keysmith.hash_key(plaintext) + + return plaintext, hashed, salt + + +def hash_secret(plaintext: str) -> tuple[str, str]: + """Hash a plaintext secret using Scrypt. Returns (hash, salt)""" + return keysmith.hash_key(plaintext) + + +def validate_secret(plaintext: str, hash_value: str, salt: str) -> bool: + """Validate a plaintext secret against a stored hash and salt""" + return keysmith.verify_key(plaintext, hash_value, salt) + + +def generate_app_credentials( + name: str, + redirect_uris: list[str], + scopes: list[str], + description: str | None = None, + grant_types: list[str] | None = None, +) -> dict: + """ + Generate complete credentials for an OAuth application. + + Returns dict with: + - id: UUID for the application + - name: Application name + - description: Application description + - client_id: Client identifier (plaintext) + - client_secret_plaintext: Client secret (SENSITIVE - show only once) + - client_secret_hash: Hashed client secret (for database) + - redirect_uris: List of allowed redirect URIs + - grant_types: List of allowed grant types + - scopes: List of allowed scopes + """ + if grant_types is None: + grant_types = ["authorization_code", "refresh_token"] + + # Validate scopes + try: + validated_scopes = [APIKeyPermission(s.strip()) for s in scopes if s.strip()] + except ValueError as e: + raise ValueError(f"Invalid scope: {e}") + + if not validated_scopes: + raise ValueError("At least one scope is required") + + # Generate credentials + app_id = str(uuid.uuid4()) + client_id = generate_client_id() + client_secret_plaintext, client_secret_hash, client_secret_salt = ( + generate_client_secret() + ) + + return { + "id": app_id, + "name": name, + "description": description, + "client_id": client_id, + "client_secret_plaintext": client_secret_plaintext, + "client_secret_hash": client_secret_hash, + "client_secret_salt": client_secret_salt, + "redirect_uris": redirect_uris, + "grant_types": grant_types, + "scopes": [s.value for s in validated_scopes], + } + + +def format_sql_insert(creds: dict) -> str: + """ + Format credentials as a SQL INSERT statement. + + The statement includes placeholders that must be replaced: + - YOUR_USER_ID_HERE: Replace with the owner's user ID + """ + now_iso = datetime.utcnow().isoformat() + + # Format arrays for PostgreSQL + redirect_uris_pg = ( + "{" + ",".join(f'"{uri}"' for uri in creds["redirect_uris"]) + "}" + ) + grant_types_pg = "{" + ",".join(f'"{gt}"' for gt in creds["grant_types"]) + "}" + scopes_pg = "{" + ",".join(creds["scopes"]) + "}" + + sql = f""" +-- ============================================================ +-- OAuth Application: {creds['name']} +-- Generated: {now_iso} UTC +-- ============================================================ + +INSERT INTO "OAuthApplication" ( + id, + "createdAt", + "updatedAt", + name, + description, + "clientId", + "clientSecret", + "clientSecretSalt", + "redirectUris", + "grantTypes", + scopes, + "ownerId", + "isActive" +) +VALUES ( + '{creds['id']}', + NOW(), + NOW(), + '{creds['name']}', + {f"'{creds['description']}'" if creds['description'] else 'NULL'}, + '{creds['client_id']}', + '{creds['client_secret_hash']}', + '{creds['client_secret_salt']}', + ARRAY{redirect_uris_pg}::TEXT[], + ARRAY{grant_types_pg}::TEXT[], + ARRAY{scopes_pg}::"APIKeyPermission"[], + 'YOUR_USER_ID_HERE', -- ⚠️ REPLACE with actual owner user ID + true +); + +-- ============================================================ +-- ⚠️ IMPORTANT: Save these credentials securely! +-- ============================================================ +-- +-- Client ID: {creds['client_id']} +-- Client Secret: {creds['client_secret_plaintext']} +-- +-- ⚠️ The client secret is shown ONLY ONCE! +-- ⚠️ Store it securely and share only with the application developer. +-- ⚠️ Never commit it to version control. +-- +-- The client secret has been hashed in the database using Scrypt. +-- The plaintext secret above is needed by the application to authenticate. +-- ============================================================ + +-- To verify the application was created: +-- SELECT "clientId", name, scopes, "redirectUris", "isActive" +-- FROM "OAuthApplication" +-- WHERE "clientId" = '{creds['client_id']}'; +""" + return sql + + +@click.group() +def cli(): + """OAuth Application Credential Generator + + Generates client IDs, client secrets, and SQL INSERT statements for OAuth applications. + Does NOT directly insert into the database - outputs SQL for manual execution. + """ + pass + + +AVAILABLE_SCOPES = [ + "EXECUTE_GRAPH", + "READ_GRAPH", + "EXECUTE_BLOCK", + "READ_BLOCK", + "READ_STORE", + "USE_TOOLS", + "MANAGE_INTEGRATIONS", + "READ_INTEGRATIONS", + "DELETE_INTEGRATIONS", +] + +DEFAULT_GRANT_TYPES = ["authorization_code", "refresh_token"] + + +def prompt_for_name() -> str: + """Prompt for application name""" + return click.prompt("Application name", type=str) + + +def prompt_for_description() -> str | None: + """Prompt for application description""" + description = click.prompt( + "Application description (optional, press Enter to skip)", + type=str, + default="", + show_default=False, + ) + return description if description else None + + +def prompt_for_redirect_uris() -> list[str]: + """Prompt for redirect URIs interactively""" + click.echo("\nRedirect URIs (enter one per line, empty line to finish):") + click.echo(" Example: https://app.example.com/callback") + uris = [] + while True: + uri = click.prompt(" URI", type=str, default="", show_default=False) + if not uri: + if not uris: + click.echo(" At least one redirect URI is required.") + continue + break + uris.append(uri.strip()) + return uris + + +def prompt_for_scopes() -> list[str]: + """Prompt for scopes interactively with a menu""" + click.echo("\nAvailable scopes:") + for i, scope in enumerate(AVAILABLE_SCOPES, 1): + click.echo(f" {i}. {scope}") + + click.echo( + "\nSelect scopes by number (comma-separated) or enter scope names directly:" + ) + click.echo(" Example: 1,2 or EXECUTE_GRAPH,READ_GRAPH") + + while True: + selection = click.prompt("Scopes", type=str) + scopes = [] + + for item in selection.split(","): + item = item.strip() + if not item: + continue + + # Check if it's a number + if item.isdigit(): + idx = int(item) - 1 + if 0 <= idx < len(AVAILABLE_SCOPES): + scopes.append(AVAILABLE_SCOPES[idx]) + else: + click.echo(f" Invalid number: {item}") + scopes = [] + break + # Check if it's a valid scope name + elif item.upper() in AVAILABLE_SCOPES: + scopes.append(item.upper()) + else: + click.echo(f" Invalid scope: {item}") + scopes = [] + break + + if scopes: + return scopes + click.echo(" Please enter valid scope numbers or names.") + + +def prompt_for_grant_types() -> list[str] | None: + """Prompt for grant types interactively""" + click.echo(f"\nGrant types (default: {', '.join(DEFAULT_GRANT_TYPES)})") + grant_types_input = click.prompt( + "Grant types (comma-separated, press Enter for default)", + type=str, + default="", + show_default=False, + ) + + if not grant_types_input: + return None # Use default + + return [gt.strip() for gt in grant_types_input.split(",") if gt.strip()] + + +@cli.command(name="generate-app") +@click.option( + "--name", + default=None, + help="Application name (e.g., 'My Cool App')", +) +@click.option( + "--description", + default=None, + help="Application description", +) +@click.option( + "--redirect-uris", + default=None, + help="Comma-separated list of redirect URIs (e.g., 'https://app.example.com/callback,http://localhost:3000/callback')", +) +@click.option( + "--scopes", + default=None, + help="Comma-separated list of scopes (e.g., 'EXECUTE_GRAPH,READ_GRAPH')", +) +@click.option( + "--grant-types", + default=None, + help="Comma-separated list of grant types (default: 'authorization_code,refresh_token')", +) +def generate_app( + name: str | None, + description: str | None, + redirect_uris: str | None, + scopes: str | None, + grant_types: str | None, +): + """Generate credentials for a new OAuth application + + All options are optional. If not provided, you will be prompted interactively. + """ + # Interactive prompts for missing required values + if name is None: + name = prompt_for_name() + + if description is None: + description = prompt_for_description() + + if redirect_uris is None: + redirect_uris_list = prompt_for_redirect_uris() + else: + redirect_uris_list = [uri.strip() for uri in redirect_uris.split(",")] + + if scopes is None: + scopes_list = prompt_for_scopes() + else: + scopes_list = [scope.strip() for scope in scopes.split(",")] + + if grant_types is None: + grant_types_list = prompt_for_grant_types() + else: + grant_types_list = [gt.strip() for gt in grant_types.split(",")] + + try: + creds = generate_app_credentials( + name=name, + description=description, + redirect_uris=redirect_uris_list, + scopes=scopes_list, + grant_types=grant_types_list, + ) + + sql = format_sql_insert(creds) + click.echo(sql) + + except ValueError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + +@cli.command(name="hash-secret") +@click.argument("secret") +def hash_secret_command(secret): + """Hash a plaintext secret using Scrypt""" + hashed, salt = hash_secret(secret) + click.echo(f"Hash: {hashed}") + click.echo(f"Salt: {salt}") + + +@cli.command(name="validate-secret") +@click.argument("secret") +@click.argument("hash") +@click.argument("salt") +def validate_secret_command(secret, hash, salt): + """Validate a plaintext secret against a hash and salt""" + is_valid = validate_secret(secret, hash, salt) + if is_valid: + click.echo("✓ Secret is valid!") + sys.exit(0) + else: + click.echo("✗ Secret is invalid!", err=True) + sys.exit(1) + + +# ============================================================================ +# Test Server Command +# ============================================================================ + +TEST_APP_NAME = "OAuth Test App (CLI)" +TEST_APP_DESCRIPTION = "Temporary test application created by oauth_admin CLI" +TEST_SERVER_PORT = 9876 + + +def generate_pkce() -> tuple[str, str]: + """Generate PKCE code_verifier and code_challenge (S256)""" + code_verifier = secrets.token_urlsafe(32) + code_challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()) + .decode() + .rstrip("=") + ) + return code_verifier, code_challenge + + +def create_test_html( + platform_url: str, + client_id: str, + client_secret: str, + redirect_uri: str, + backend_url: str, +) -> str: + """Generate HTML page for test OAuth client""" + return f""" + + + + + OAuth Test Client + + + +
+
+

🔐 OAuth Test Client

+

Test the "Sign in with AutoGPT" and Integration Setup flows

+ +
+ + {client_id} +
+ +
+ + +
+
+ + + +
+

📋 Request Log

+
Waiting for action...
+
+ +
+

⚙️ Configuration

+
+ + {platform_url} +
+
+ + {backend_url} +
+
+ + {redirect_uri} +
+
+
+ + + + +""" + + +async def create_test_app_in_db( + owner_id: str, + redirect_uri: str, +) -> dict: + """Create a temporary test OAuth application in the database""" + from prisma.models import OAuthApplication + + from backend.data import db + + # Connect to database + await db.connect() + + # Generate credentials + creds = generate_app_credentials( + name=TEST_APP_NAME, + description=TEST_APP_DESCRIPTION, + redirect_uris=[redirect_uri], + scopes=AVAILABLE_SCOPES, # All scopes for testing + ) + + # Insert into database + app = await OAuthApplication.prisma().create( + data={ + "id": creds["id"], + "name": creds["name"], + "description": creds["description"], + "clientId": creds["client_id"], + "clientSecret": creds["client_secret_hash"], + "clientSecretSalt": creds["client_secret_salt"], + "redirectUris": creds["redirect_uris"], + "grantTypes": creds["grant_types"], + "scopes": creds["scopes"], + "ownerId": owner_id, + "isActive": True, + } + ) + + click.echo(f"✓ Created test OAuth application: {app.clientId}") + + return { + "id": app.id, + "client_id": app.clientId, + "client_secret": creds["client_secret_plaintext"], + } + + +async def cleanup_test_app(app_id: str) -> None: + """Remove test application and all associated tokens from database""" + from prisma.models import ( + OAuthAccessToken, + OAuthApplication, + OAuthAuthorizationCode, + OAuthRefreshToken, + ) + + from backend.data import db + + if not db.is_connected(): + await db.connect() + + click.echo("\n🧹 Cleaning up test data...") + + # Delete authorization codes + deleted_codes = await OAuthAuthorizationCode.prisma().delete_many( + where={"applicationId": app_id} + ) + if deleted_codes: + click.echo(f" Deleted {deleted_codes} authorization code(s)") + + # Delete access tokens + deleted_access = await OAuthAccessToken.prisma().delete_many( + where={"applicationId": app_id} + ) + if deleted_access: + click.echo(f" Deleted {deleted_access} access token(s)") + + # Delete refresh tokens + deleted_refresh = await OAuthRefreshToken.prisma().delete_many( + where={"applicationId": app_id} + ) + if deleted_refresh: + click.echo(f" Deleted {deleted_refresh} refresh token(s)") + + # Delete the application itself + await OAuthApplication.prisma().delete(where={"id": app_id}) + click.echo(" Deleted test OAuth application") + + await db.disconnect() + click.echo("✓ Cleanup complete!") + + +def run_test_server( + port: int, + platform_url: str, + backend_url: str, + client_id: str, + client_secret: str, +) -> None: + """Run a simple HTTP server for testing OAuth flows""" + import json as json_module + import threading + from http.server import BaseHTTPRequestHandler, HTTPServer + from urllib.request import Request, urlopen + + redirect_uri = f"http://localhost:{port}/callback" + + html_content = create_test_html( + platform_url=platform_url, + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + backend_url=backend_url, + ) + + class TestHandler(BaseHTTPRequestHandler): + def do_GET(self): + from urllib.parse import parse_qs + + # Parse the path + parsed = urlparse(self.path) + + # Serve the test page for root and callback + if parsed.path in ["/", "/callback"]: + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(html_content.encode()) + + # Proxy API calls to backend (avoids CORS issues) + # Supports both /proxy/api/* and /proxy/external-api/* + elif parsed.path.startswith("/proxy/"): + try: + # Extract the API path and token from query params + api_path = parsed.path[len("/proxy") :] + query_params = parse_qs(parsed.query) + token = query_params.get("token", [None])[0] + + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + + req = Request( + f"{backend_url}{api_path}", + headers=headers, + method="GET", + ) + + with urlopen(req) as response: + response_body = response.read() + self.send_response(response.status) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(response_body) + + except Exception as e: + error_msg = str(e) + status_code = 500 + if hasattr(e, "code"): + status_code = e.code # type: ignore + if hasattr(e, "read"): + try: + error_body = e.read().decode() # type: ignore + error_data = json_module.loads(error_body) + error_msg = error_data.get("detail", error_msg) + except Exception: + pass + + self.send_response(status_code) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json_module.dumps({"detail": error_msg}).encode()) + + else: + self.send_response(404) + self.end_headers() + + def do_POST(self): + # Parse the path + parsed = urlparse(self.path) + + # Proxy token exchange to backend (avoids CORS issues) + if parsed.path == "/proxy/token": + try: + # Read request body + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) + + # Forward to backend + req = Request( + f"{backend_url}/api/oauth/token", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + + with urlopen(req) as response: + response_body = response.read() + self.send_response(response.status) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(response_body) + + except Exception as e: + error_msg = str(e) + # Try to extract error detail from urllib error + if hasattr(e, "read"): + try: + error_body = e.read().decode() # type: ignore + error_data = json_module.loads(error_body) + error_msg = error_data.get("detail", error_msg) + except Exception: + pass + + self.send_response(500) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json_module.dumps({"detail": error_msg}).encode()) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + # Suppress default logging + pass + + server = HTTPServer(("localhost", port), TestHandler) + click.echo(f"\n🚀 Test server running at http://localhost:{port}") + click.echo(" Open this URL in your browser to test the OAuth flows\n") + + # Run server in a daemon thread + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + + # Use a simple polling loop that can be interrupted + try: + while server_thread.is_alive(): + server_thread.join(timeout=1.0) + except KeyboardInterrupt: + pass + + click.echo("\n\n⏹️ Server stopped") + server.shutdown() + + +async def setup_and_cleanup_test_app( + owner_id: str, + redirect_uri: str, + port: int, + platform_url: str, + backend_url: str, +) -> None: + """ + Async context manager that handles test app lifecycle. + Creates the app, yields control to run the server, then cleans up. + """ + app_info: Optional[dict] = None + + try: + # Create test app in database + click.echo("\n📝 Creating temporary OAuth application...") + app_info = await create_test_app_in_db(owner_id, redirect_uri) + + click.echo(f"\n Client ID: {app_info['client_id']}") + click.echo(f" Client Secret: {app_info['client_secret'][:30]}...") + + # Run the test server (blocking, synchronous) + click.echo("\n" + "-" * 60) + click.echo(" Press Ctrl+C to stop the server and clean up") + click.echo("-" * 60) + + run_test_server( + port=port, + platform_url=platform_url, + backend_url=backend_url, + client_id=app_info["client_id"], + client_secret=app_info["client_secret"], + ) + + finally: + # Always clean up - we're still in the same event loop + if app_info: + try: + await cleanup_test_app(app_info["id"]) + except Exception as e: + click.echo(f"\n⚠️ Cleanup error: {e}", err=True) + click.echo( + f" You may need to manually delete app with ID: {app_info['id']}" + ) + + +@cli.command(name="test-server") +@click.option( + "--owner-id", + required=True, + help="User ID to own the temporary test OAuth application", +) +@click.option( + "--port", + default=TEST_SERVER_PORT, + help=f"Port to run the test server on (default: {TEST_SERVER_PORT})", +) +@click.option( + "--platform-url", + default="http://localhost:3000", + help="AutoGPT Platform frontend URL (default: http://localhost:3000)", +) +@click.option( + "--backend-url", + default="http://localhost:8006", + help="AutoGPT Platform backend URL (default: http://localhost:8006)", +) +def test_server_command( + owner_id: str, + port: int, + platform_url: str, + backend_url: str, +): + """Run a test server to test OAuth flows interactively + + This command: + 1. Creates a temporary OAuth application in the database + 2. Starts a minimal web server that acts as a third-party client + 3. Lets you test "Sign in with AutoGPT" and Integration Setup flows + 4. Cleans up all test data (app, tokens, codes) when you stop the server + + Example: + poetry run oauth-tool test-server --owner-id YOUR_USER_ID + + The test server will be available at http://localhost:9876 + """ + redirect_uri = f"http://localhost:{port}/callback" + + click.echo("=" * 60) + click.echo(" OAuth Test Server") + click.echo("=" * 60) + click.echo(f"\n Owner ID: {owner_id}") + click.echo(f" Platform URL: {platform_url}") + click.echo(f" Backend URL: {backend_url}") + click.echo(f" Test Server: http://localhost:{port}") + click.echo(f" Redirect URI: {redirect_uri}") + click.echo("\n" + "=" * 60) + + try: + # Run everything in a single event loop to keep Prisma client happy + asyncio.run( + setup_and_cleanup_test_app( + owner_id=owner_id, + redirect_uri=redirect_uri, + port=port, + platform_url=platform_url, + backend_url=backend_url, + ) + ) + except KeyboardInterrupt: + # Already handled inside, just exit cleanly + pass + except Exception as e: + click.echo(f"\n❌ Error: {e}", err=True) + sys.exit(1) + + +if __name__ == "__main__": + cli() diff --git a/autogpt_platform/backend/backend/data/api_key.py b/autogpt_platform/backend/backend/data/auth/api_key.py similarity index 95% rename from autogpt_platform/backend/backend/data/api_key.py rename to autogpt_platform/backend/backend/data/auth/api_key.py index 45194897de..2ecd5be9a5 100644 --- a/autogpt_platform/backend/backend/data/api_key.py +++ b/autogpt_platform/backend/backend/data/auth/api_key.py @@ -1,22 +1,24 @@ import logging import uuid from datetime import datetime, timezone -from typing import Optional +from typing import Literal, Optional from autogpt_libs.api_key.keysmith import APIKeySmith from prisma.enums import APIKeyPermission, APIKeyStatus from prisma.models import APIKey as PrismaAPIKey from prisma.types import APIKeyWhereUniqueInput -from pydantic import BaseModel, Field +from pydantic import Field from backend.data.includes import MAX_USER_API_KEYS_FETCH from backend.util.exceptions import NotAuthorizedError, NotFoundError +from .base import APIAuthorizationInfo + logger = logging.getLogger(__name__) keysmith = APIKeySmith() -class APIKeyInfo(BaseModel): +class APIKeyInfo(APIAuthorizationInfo): id: str name: str head: str = Field( @@ -26,12 +28,9 @@ class APIKeyInfo(BaseModel): description=f"The last {APIKeySmith.TAIL_LENGTH} characters of the key" ) status: APIKeyStatus - permissions: list[APIKeyPermission] - created_at: datetime - last_used_at: Optional[datetime] = None - revoked_at: Optional[datetime] = None description: Optional[str] = None - user_id: str + + type: Literal["api_key"] = "api_key" # type: ignore @staticmethod def from_db(api_key: PrismaAPIKey): @@ -41,7 +40,7 @@ class APIKeyInfo(BaseModel): head=api_key.head, tail=api_key.tail, status=APIKeyStatus(api_key.status), - permissions=[APIKeyPermission(p) for p in api_key.permissions], + scopes=[APIKeyPermission(p) for p in api_key.permissions], created_at=api_key.createdAt, last_used_at=api_key.lastUsedAt, revoked_at=api_key.revokedAt, @@ -211,7 +210,7 @@ async def suspend_api_key(key_id: str, user_id: str) -> APIKeyInfo: def has_permission(api_key: APIKeyInfo, required_permission: APIKeyPermission) -> bool: - return required_permission in api_key.permissions + return required_permission in api_key.scopes async def get_api_key_by_id(key_id: str, user_id: str) -> Optional[APIKeyInfo]: diff --git a/autogpt_platform/backend/backend/data/auth/base.py b/autogpt_platform/backend/backend/data/auth/base.py new file mode 100644 index 0000000000..e307b5f49f --- /dev/null +++ b/autogpt_platform/backend/backend/data/auth/base.py @@ -0,0 +1,15 @@ +from datetime import datetime +from typing import Literal, Optional + +from prisma.enums import APIKeyPermission +from pydantic import BaseModel + + +class APIAuthorizationInfo(BaseModel): + user_id: str + scopes: list[APIKeyPermission] + type: Literal["oauth", "api_key"] + created_at: datetime + expires_at: Optional[datetime] = None + last_used_at: Optional[datetime] = None + revoked_at: Optional[datetime] = None diff --git a/autogpt_platform/backend/backend/data/auth/oauth.py b/autogpt_platform/backend/backend/data/auth/oauth.py new file mode 100644 index 0000000000..e49586194c --- /dev/null +++ b/autogpt_platform/backend/backend/data/auth/oauth.py @@ -0,0 +1,872 @@ +""" +OAuth 2.0 Provider Data Layer + +Handles management of OAuth applications, authorization codes, +access tokens, and refresh tokens. + +Hashing strategy: +- Access tokens & Refresh tokens: SHA256 (deterministic, allows direct lookup by hash) +- Client secrets: Scrypt with salt (lookup by client_id, then verify with salt) +""" + +import hashlib +import logging +import secrets +import uuid +from datetime import datetime, timedelta, timezone +from typing import Literal, Optional + +from autogpt_libs.api_key.keysmith import APIKeySmith +from prisma.enums import APIKeyPermission as APIPermission +from prisma.models import OAuthAccessToken as PrismaOAuthAccessToken +from prisma.models import OAuthApplication as PrismaOAuthApplication +from prisma.models import OAuthAuthorizationCode as PrismaOAuthAuthorizationCode +from prisma.models import OAuthRefreshToken as PrismaOAuthRefreshToken +from prisma.types import OAuthApplicationUpdateInput +from pydantic import BaseModel, Field, SecretStr + +from .base import APIAuthorizationInfo + +logger = logging.getLogger(__name__) +keysmith = APIKeySmith() # Only used for client secret hashing (Scrypt) + + +def _generate_token() -> str: + """Generate a cryptographically secure random token.""" + return secrets.token_urlsafe(32) + + +def _hash_token(token: str) -> str: + """Hash a token using SHA256 (deterministic, for direct lookup).""" + return hashlib.sha256(token.encode()).hexdigest() + + +# Token TTLs +AUTHORIZATION_CODE_TTL = timedelta(minutes=10) +ACCESS_TOKEN_TTL = timedelta(hours=1) +REFRESH_TOKEN_TTL = timedelta(days=30) + +ACCESS_TOKEN_PREFIX = "agpt_xt_" +REFRESH_TOKEN_PREFIX = "agpt_rt_" + + +# ============================================================================ +# Exception Classes +# ============================================================================ + + +class OAuthError(Exception): + """Base OAuth error""" + + pass + + +class InvalidClientError(OAuthError): + """Invalid client_id or client_secret""" + + pass + + +class InvalidGrantError(OAuthError): + """Invalid or expired authorization code/refresh token""" + + def __init__(self, reason: str): + self.reason = reason + super().__init__(f"Invalid grant: {reason}") + + +class InvalidTokenError(OAuthError): + """Invalid, expired, or revoked token""" + + def __init__(self, reason: str): + self.reason = reason + super().__init__(f"Invalid token: {reason}") + + +# ============================================================================ +# Data Models +# ============================================================================ + + +class OAuthApplicationInfo(BaseModel): + """OAuth application information (without client secret hash)""" + + id: str + name: str + description: Optional[str] = None + logo_url: Optional[str] = None + client_id: str + redirect_uris: list[str] + grant_types: list[str] + scopes: list[APIPermission] + owner_id: str + is_active: bool + created_at: datetime + updated_at: datetime + + @staticmethod + def from_db(app: PrismaOAuthApplication): + return OAuthApplicationInfo( + id=app.id, + name=app.name, + description=app.description, + logo_url=app.logoUrl, + client_id=app.clientId, + redirect_uris=app.redirectUris, + grant_types=app.grantTypes, + scopes=[APIPermission(s) for s in app.scopes], + owner_id=app.ownerId, + is_active=app.isActive, + created_at=app.createdAt, + updated_at=app.updatedAt, + ) + + +class OAuthApplicationInfoWithSecret(OAuthApplicationInfo): + """OAuth application with client secret hash (for validation)""" + + client_secret_hash: str + client_secret_salt: str + + @staticmethod + def from_db(app: PrismaOAuthApplication): + return OAuthApplicationInfoWithSecret( + **OAuthApplicationInfo.from_db(app).model_dump(), + client_secret_hash=app.clientSecret, + client_secret_salt=app.clientSecretSalt, + ) + + def verify_secret(self, plaintext_secret: str) -> bool: + """Verify a plaintext client secret against the stored hash""" + # Use keysmith.verify_key() with stored salt + return keysmith.verify_key( + plaintext_secret, self.client_secret_hash, self.client_secret_salt + ) + + +class OAuthAuthorizationCodeInfo(BaseModel): + """Authorization code information""" + + id: str + code: str + created_at: datetime + expires_at: datetime + application_id: str + user_id: str + scopes: list[APIPermission] + redirect_uri: str + code_challenge: Optional[str] = None + code_challenge_method: Optional[str] = None + used_at: Optional[datetime] = None + + @property + def is_used(self) -> bool: + return self.used_at is not None + + @staticmethod + def from_db(code: PrismaOAuthAuthorizationCode): + return OAuthAuthorizationCodeInfo( + id=code.id, + code=code.code, + created_at=code.createdAt, + expires_at=code.expiresAt, + application_id=code.applicationId, + user_id=code.userId, + scopes=[APIPermission(s) for s in code.scopes], + redirect_uri=code.redirectUri, + code_challenge=code.codeChallenge, + code_challenge_method=code.codeChallengeMethod, + used_at=code.usedAt, + ) + + +class OAuthAccessTokenInfo(APIAuthorizationInfo): + """Access token information""" + + id: str + expires_at: datetime # type: ignore + application_id: str + + type: Literal["oauth"] = "oauth" # type: ignore + + @staticmethod + def from_db(token: PrismaOAuthAccessToken): + return OAuthAccessTokenInfo( + id=token.id, + user_id=token.userId, + scopes=[APIPermission(s) for s in token.scopes], + created_at=token.createdAt, + expires_at=token.expiresAt, + last_used_at=None, + revoked_at=token.revokedAt, + application_id=token.applicationId, + ) + + +class OAuthAccessToken(OAuthAccessTokenInfo): + """Access token with plaintext token included (sensitive)""" + + token: SecretStr = Field(description="Plaintext token (sensitive)") + + @staticmethod + def from_db(token: PrismaOAuthAccessToken, plaintext_token: str): # type: ignore + return OAuthAccessToken( + **OAuthAccessTokenInfo.from_db(token).model_dump(), + token=SecretStr(plaintext_token), + ) + + +class OAuthRefreshTokenInfo(BaseModel): + """Refresh token information""" + + id: str + user_id: str + scopes: list[APIPermission] + created_at: datetime + expires_at: datetime + application_id: str + revoked_at: Optional[datetime] = None + + @property + def is_revoked(self) -> bool: + return self.revoked_at is not None + + @staticmethod + def from_db(token: PrismaOAuthRefreshToken): + return OAuthRefreshTokenInfo( + id=token.id, + user_id=token.userId, + scopes=[APIPermission(s) for s in token.scopes], + created_at=token.createdAt, + expires_at=token.expiresAt, + application_id=token.applicationId, + revoked_at=token.revokedAt, + ) + + +class OAuthRefreshToken(OAuthRefreshTokenInfo): + """Refresh token with plaintext token included (sensitive)""" + + token: SecretStr = Field(description="Plaintext token (sensitive)") + + @staticmethod + def from_db(token: PrismaOAuthRefreshToken, plaintext_token: str): # type: ignore + return OAuthRefreshToken( + **OAuthRefreshTokenInfo.from_db(token).model_dump(), + token=SecretStr(plaintext_token), + ) + + +class TokenIntrospectionResult(BaseModel): + """Result of token introspection (RFC 7662)""" + + active: bool + scopes: Optional[list[str]] = None + client_id: Optional[str] = None + user_id: Optional[str] = None + exp: Optional[int] = None # Unix timestamp + token_type: Optional[Literal["access_token", "refresh_token"]] = None + + +# ============================================================================ +# OAuth Application Management +# ============================================================================ + + +async def get_oauth_application(client_id: str) -> Optional[OAuthApplicationInfo]: + """Get OAuth application by client ID (without secret)""" + app = await PrismaOAuthApplication.prisma().find_unique( + where={"clientId": client_id} + ) + if not app: + return None + return OAuthApplicationInfo.from_db(app) + + +async def get_oauth_application_with_secret( + client_id: str, +) -> Optional[OAuthApplicationInfoWithSecret]: + """Get OAuth application by client ID (with secret hash for validation)""" + app = await PrismaOAuthApplication.prisma().find_unique( + where={"clientId": client_id} + ) + if not app: + return None + return OAuthApplicationInfoWithSecret.from_db(app) + + +async def validate_client_credentials( + client_id: str, client_secret: str +) -> OAuthApplicationInfo: + """ + Validate client credentials and return application info. + + Raises: + InvalidClientError: If client_id or client_secret is invalid, or app is inactive + """ + app = await get_oauth_application_with_secret(client_id) + if not app: + raise InvalidClientError("Invalid client_id") + + if not app.is_active: + raise InvalidClientError("Application is not active") + + # Verify client secret + if not app.verify_secret(client_secret): + raise InvalidClientError("Invalid client_secret") + + # Return without secret hash + return OAuthApplicationInfo(**app.model_dump(exclude={"client_secret_hash"})) + + +def validate_redirect_uri(app: OAuthApplicationInfo, redirect_uri: str) -> bool: + """Validate that redirect URI is registered for the application""" + return redirect_uri in app.redirect_uris + + +def validate_scopes( + app: OAuthApplicationInfo, requested_scopes: list[APIPermission] +) -> bool: + """Validate that all requested scopes are allowed for the application""" + return all(scope in app.scopes for scope in requested_scopes) + + +# ============================================================================ +# Authorization Code Flow +# ============================================================================ + + +def _generate_authorization_code() -> str: + """Generate a cryptographically secure authorization code""" + # 32 bytes = 256 bits of entropy + return secrets.token_urlsafe(32) + + +async def create_authorization_code( + application_id: str, + user_id: str, + scopes: list[APIPermission], + redirect_uri: str, + code_challenge: Optional[str] = None, + code_challenge_method: Optional[Literal["S256", "plain"]] = None, +) -> OAuthAuthorizationCodeInfo: + """ + Create a new authorization code. + Expires in 10 minutes and can only be used once. + """ + code = _generate_authorization_code() + now = datetime.now(timezone.utc) + expires_at = now + AUTHORIZATION_CODE_TTL + + saved_code = await PrismaOAuthAuthorizationCode.prisma().create( + data={ + "id": str(uuid.uuid4()), + "code": code, + "expiresAt": expires_at, + "applicationId": application_id, + "userId": user_id, + "scopes": [s for s in scopes], + "redirectUri": redirect_uri, + "codeChallenge": code_challenge, + "codeChallengeMethod": code_challenge_method, + } + ) + + return OAuthAuthorizationCodeInfo.from_db(saved_code) + + +async def consume_authorization_code( + code: str, + application_id: str, + redirect_uri: str, + code_verifier: Optional[str] = None, +) -> tuple[str, list[APIPermission]]: + """ + Consume an authorization code and return (user_id, scopes). + + This marks the code as used and validates: + - Code exists and matches application + - Code is not expired + - Code has not been used + - Redirect URI matches + - PKCE code verifier matches (if code challenge was provided) + + Raises: + InvalidGrantError: If code is invalid, expired, used, or PKCE fails + """ + auth_code = await PrismaOAuthAuthorizationCode.prisma().find_unique( + where={"code": code} + ) + + if not auth_code: + raise InvalidGrantError("authorization code not found") + + # Validate application + if auth_code.applicationId != application_id: + raise InvalidGrantError( + "authorization code does not belong to this application" + ) + + # Check if already used + if auth_code.usedAt is not None: + raise InvalidGrantError( + f"authorization code already used at {auth_code.usedAt}" + ) + + # Check expiration + now = datetime.now(timezone.utc) + if auth_code.expiresAt < now: + raise InvalidGrantError("authorization code expired") + + # Validate redirect URI + if auth_code.redirectUri != redirect_uri: + raise InvalidGrantError("redirect_uri mismatch") + + # Validate PKCE if code challenge was provided + if auth_code.codeChallenge: + if not code_verifier: + raise InvalidGrantError("code_verifier required but not provided") + + if not _verify_pkce( + code_verifier, auth_code.codeChallenge, auth_code.codeChallengeMethod + ): + raise InvalidGrantError("PKCE verification failed") + + # Mark code as used + await PrismaOAuthAuthorizationCode.prisma().update( + where={"code": code}, + data={"usedAt": now}, + ) + + return auth_code.userId, [APIPermission(s) for s in auth_code.scopes] + + +def _verify_pkce( + code_verifier: str, code_challenge: str, code_challenge_method: Optional[str] +) -> bool: + """ + Verify PKCE code verifier against code challenge. + + Supports: + - S256: SHA256(code_verifier) == code_challenge + - plain: code_verifier == code_challenge + """ + if code_challenge_method == "S256": + # Hash the verifier with SHA256 and base64url encode + hashed = hashlib.sha256(code_verifier.encode("ascii")).digest() + computed_challenge = ( + secrets.token_urlsafe(len(hashed)).encode("ascii").decode("ascii") + ) + # For proper base64url encoding + import base64 + + computed_challenge = ( + base64.urlsafe_b64encode(hashed).decode("ascii").rstrip("=") + ) + return secrets.compare_digest(computed_challenge, code_challenge) + elif code_challenge_method == "plain" or code_challenge_method is None: + # Plain comparison + return secrets.compare_digest(code_verifier, code_challenge) + else: + logger.warning(f"Unsupported code challenge method: {code_challenge_method}") + return False + + +# ============================================================================ +# Access Token Management +# ============================================================================ + + +async def create_access_token( + application_id: str, user_id: str, scopes: list[APIPermission] +) -> OAuthAccessToken: + """ + Create a new access token. + Returns OAuthAccessToken (with plaintext token). + """ + plaintext_token = ACCESS_TOKEN_PREFIX + _generate_token() + token_hash = _hash_token(plaintext_token) + now = datetime.now(timezone.utc) + expires_at = now + ACCESS_TOKEN_TTL + + saved_token = await PrismaOAuthAccessToken.prisma().create( + data={ + "id": str(uuid.uuid4()), + "token": token_hash, # SHA256 hash for direct lookup + "expiresAt": expires_at, + "applicationId": application_id, + "userId": user_id, + "scopes": [s for s in scopes], + } + ) + + return OAuthAccessToken.from_db(saved_token, plaintext_token=plaintext_token) + + +async def validate_access_token( + token: str, +) -> tuple[OAuthAccessTokenInfo, OAuthApplicationInfo]: + """ + Validate an access token and return token info. + + Raises: + InvalidTokenError: If token is invalid, expired, or revoked + InvalidClientError: If the client application is not marked as active + """ + token_hash = _hash_token(token) + + # Direct lookup by hash + access_token = await PrismaOAuthAccessToken.prisma().find_unique( + where={"token": token_hash}, include={"Application": True} + ) + + if not access_token: + raise InvalidTokenError("access token not found") + + if not access_token.Application: # should be impossible + raise InvalidClientError("Client application not found") + + if not access_token.Application.isActive: + raise InvalidClientError("Client application is disabled") + + if access_token.revokedAt is not None: + raise InvalidTokenError("access token has been revoked") + + # Check expiration + now = datetime.now(timezone.utc) + if access_token.expiresAt < now: + raise InvalidTokenError("access token expired") + + return ( + OAuthAccessTokenInfo.from_db(access_token), + OAuthApplicationInfo.from_db(access_token.Application), + ) + + +async def revoke_access_token( + token: str, application_id: str +) -> OAuthAccessTokenInfo | None: + """ + Revoke an access token. + + Args: + token: The plaintext access token to revoke + application_id: The application ID making the revocation request. + Only tokens belonging to this application will be revoked. + + Returns: + OAuthAccessTokenInfo if token was found and revoked, None otherwise. + + Note: + Always performs exactly 2 DB queries regardless of outcome to prevent + timing side-channel attacks that could reveal token existence. + """ + try: + token_hash = _hash_token(token) + + # Use update_many to filter by both token and applicationId + updated_count = await PrismaOAuthAccessToken.prisma().update_many( + where={ + "token": token_hash, + "applicationId": application_id, + "revokedAt": None, + }, + data={"revokedAt": datetime.now(timezone.utc)}, + ) + + # Always perform second query to ensure constant time + result = await PrismaOAuthAccessToken.prisma().find_unique( + where={"token": token_hash} + ) + + # Only return result if we actually revoked something + if updated_count == 0: + return None + + return OAuthAccessTokenInfo.from_db(result) if result else None + except Exception as e: + logger.exception(f"Error revoking access token: {e}") + return None + + +# ============================================================================ +# Refresh Token Management +# ============================================================================ + + +async def create_refresh_token( + application_id: str, user_id: str, scopes: list[APIPermission] +) -> OAuthRefreshToken: + """ + Create a new refresh token. + Returns OAuthRefreshToken (with plaintext token). + """ + plaintext_token = REFRESH_TOKEN_PREFIX + _generate_token() + token_hash = _hash_token(plaintext_token) + now = datetime.now(timezone.utc) + expires_at = now + REFRESH_TOKEN_TTL + + saved_token = await PrismaOAuthRefreshToken.prisma().create( + data={ + "id": str(uuid.uuid4()), + "token": token_hash, # SHA256 hash for direct lookup + "expiresAt": expires_at, + "applicationId": application_id, + "userId": user_id, + "scopes": [s for s in scopes], + } + ) + + return OAuthRefreshToken.from_db(saved_token, plaintext_token=plaintext_token) + + +async def refresh_tokens( + refresh_token: str, application_id: str +) -> tuple[OAuthAccessToken, OAuthRefreshToken]: + """ + Use a refresh token to create new access and refresh tokens. + Returns (new_access_token, new_refresh_token) both with plaintext tokens included. + + Raises: + InvalidGrantError: If refresh token is invalid, expired, or revoked + """ + token_hash = _hash_token(refresh_token) + + # Direct lookup by hash + rt = await PrismaOAuthRefreshToken.prisma().find_unique(where={"token": token_hash}) + + if not rt: + raise InvalidGrantError("refresh token not found") + + # NOTE: no need to check Application.isActive, this is checked by the token endpoint + + if rt.revokedAt is not None: + raise InvalidGrantError("refresh token has been revoked") + + # Validate application + if rt.applicationId != application_id: + raise InvalidGrantError("refresh token does not belong to this application") + + # Check expiration + now = datetime.now(timezone.utc) + if rt.expiresAt < now: + raise InvalidGrantError("refresh token expired") + + # Revoke old refresh token + await PrismaOAuthRefreshToken.prisma().update( + where={"token": token_hash}, + data={"revokedAt": now}, + ) + + # Create new access and refresh tokens with same scopes + scopes = [APIPermission(s) for s in rt.scopes] + new_access_token = await create_access_token( + rt.applicationId, + rt.userId, + scopes, + ) + new_refresh_token = await create_refresh_token( + rt.applicationId, + rt.userId, + scopes, + ) + + return new_access_token, new_refresh_token + + +async def revoke_refresh_token( + token: str, application_id: str +) -> OAuthRefreshTokenInfo | None: + """ + Revoke a refresh token. + + Args: + token: The plaintext refresh token to revoke + application_id: The application ID making the revocation request. + Only tokens belonging to this application will be revoked. + + Returns: + OAuthRefreshTokenInfo if token was found and revoked, None otherwise. + + Note: + Always performs exactly 2 DB queries regardless of outcome to prevent + timing side-channel attacks that could reveal token existence. + """ + try: + token_hash = _hash_token(token) + + # Use update_many to filter by both token and applicationId + updated_count = await PrismaOAuthRefreshToken.prisma().update_many( + where={ + "token": token_hash, + "applicationId": application_id, + "revokedAt": None, + }, + data={"revokedAt": datetime.now(timezone.utc)}, + ) + + # Always perform second query to ensure constant time + result = await PrismaOAuthRefreshToken.prisma().find_unique( + where={"token": token_hash} + ) + + # Only return result if we actually revoked something + if updated_count == 0: + return None + + return OAuthRefreshTokenInfo.from_db(result) if result else None + except Exception as e: + logger.exception(f"Error revoking refresh token: {e}") + return None + + +# ============================================================================ +# Token Introspection +# ============================================================================ + + +async def introspect_token( + token: str, + token_type_hint: Optional[Literal["access_token", "refresh_token"]] = None, +) -> TokenIntrospectionResult: + """ + Introspect a token and return its metadata (RFC 7662). + + Returns TokenIntrospectionResult with active=True and metadata if valid, + or active=False if the token is invalid/expired/revoked. + """ + # Try as access token first (or if hint says "access_token") + if token_type_hint != "refresh_token": + try: + token_info, app = await validate_access_token(token) + return TokenIntrospectionResult( + active=True, + scopes=list(s.value for s in token_info.scopes), + client_id=app.client_id if app else None, + user_id=token_info.user_id, + exp=int(token_info.expires_at.timestamp()), + token_type="access_token", + ) + except InvalidTokenError: + pass # Try as refresh token + + # Try as refresh token + token_hash = _hash_token(token) + refresh_token = await PrismaOAuthRefreshToken.prisma().find_unique( + where={"token": token_hash} + ) + + if refresh_token and refresh_token.revokedAt is None: + # Check if valid (not expired) + now = datetime.now(timezone.utc) + if refresh_token.expiresAt > now: + app = await get_oauth_application_by_id(refresh_token.applicationId) + return TokenIntrospectionResult( + active=True, + scopes=list(s for s in refresh_token.scopes), + client_id=app.client_id if app else None, + user_id=refresh_token.userId, + exp=int(refresh_token.expiresAt.timestamp()), + token_type="refresh_token", + ) + + # Token not found or inactive + return TokenIntrospectionResult(active=False) + + +async def get_oauth_application_by_id(app_id: str) -> Optional[OAuthApplicationInfo]: + """Get OAuth application by ID""" + app = await PrismaOAuthApplication.prisma().find_unique(where={"id": app_id}) + if not app: + return None + return OAuthApplicationInfo.from_db(app) + + +async def list_user_oauth_applications(user_id: str) -> list[OAuthApplicationInfo]: + """Get all OAuth applications owned by a user""" + apps = await PrismaOAuthApplication.prisma().find_many( + where={"ownerId": user_id}, + order={"createdAt": "desc"}, + ) + return [OAuthApplicationInfo.from_db(app) for app in apps] + + +async def update_oauth_application( + app_id: str, + *, + owner_id: str, + is_active: Optional[bool] = None, + logo_url: Optional[str] = None, +) -> Optional[OAuthApplicationInfo]: + """ + Update OAuth application active status. + Only the owner can update their app's status. + + Returns the updated app info, or None if app not found or not owned by user. + """ + # First verify ownership + app = await PrismaOAuthApplication.prisma().find_first( + where={"id": app_id, "ownerId": owner_id} + ) + if not app: + return None + + patch: OAuthApplicationUpdateInput = {} + if is_active is not None: + patch["isActive"] = is_active + if logo_url: + patch["logoUrl"] = logo_url + if not patch: + return OAuthApplicationInfo.from_db(app) # return unchanged + + updated_app = await PrismaOAuthApplication.prisma().update( + where={"id": app_id}, + data=patch, + ) + return OAuthApplicationInfo.from_db(updated_app) if updated_app else None + + +# ============================================================================ +# Token Cleanup +# ============================================================================ + + +async def cleanup_expired_oauth_tokens() -> dict[str, int]: + """ + Delete expired OAuth tokens from the database. + + This removes: + - Expired authorization codes (10 min TTL) + - Expired access tokens (1 hour TTL) + - Expired refresh tokens (30 day TTL) + + Returns a dict with counts of deleted tokens by type. + """ + now = datetime.now(timezone.utc) + + # Delete expired authorization codes + codes_result = await PrismaOAuthAuthorizationCode.prisma().delete_many( + where={"expiresAt": {"lt": now}} + ) + + # Delete expired access tokens + access_result = await PrismaOAuthAccessToken.prisma().delete_many( + where={"expiresAt": {"lt": now}} + ) + + # Delete expired refresh tokens + refresh_result = await PrismaOAuthRefreshToken.prisma().delete_many( + where={"expiresAt": {"lt": now}} + ) + + deleted = { + "authorization_codes": codes_result, + "access_tokens": access_result, + "refresh_tokens": refresh_result, + } + + total = sum(deleted.values()) + if total > 0: + logger.info(f"Cleaned up {total} expired OAuth tokens: {deleted}") + + return deleted diff --git a/autogpt_platform/backend/backend/executor/scheduler.py b/autogpt_platform/backend/backend/executor/scheduler.py index 6a0bb593c6..06c50bf82e 100644 --- a/autogpt_platform/backend/backend/executor/scheduler.py +++ b/autogpt_platform/backend/backend/executor/scheduler.py @@ -23,6 +23,7 @@ from dotenv import load_dotenv from pydantic import BaseModel, Field, ValidationError from sqlalchemy import MetaData, create_engine +from backend.data.auth.oauth import cleanup_expired_oauth_tokens from backend.data.block import BlockInput from backend.data.execution import GraphExecutionWithNodes from backend.data.model import CredentialsMetaInput @@ -242,6 +243,12 @@ def cleanup_expired_files(): run_async(cleanup_expired_files_async()) +def cleanup_oauth_tokens(): + """Clean up expired OAuth tokens from the database.""" + # Wait for completion + run_async(cleanup_expired_oauth_tokens()) + + def execution_accuracy_alerts(): """Check execution accuracy and send alerts if drops are detected.""" return report_execution_accuracy_alerts() @@ -446,6 +453,17 @@ class Scheduler(AppService): jobstore=Jobstores.EXECUTION.value, ) + # OAuth Token Cleanup - configurable interval + self.scheduler.add_job( + cleanup_oauth_tokens, + id="cleanup_oauth_tokens", + trigger="interval", + replace_existing=True, + seconds=config.oauth_token_cleanup_interval_hours + * 3600, # Convert hours to seconds + jobstore=Jobstores.EXECUTION.value, + ) + # Execution Accuracy Monitoring - configurable interval self.scheduler.add_job( execution_accuracy_alerts, @@ -604,6 +622,11 @@ class Scheduler(AppService): """Manually trigger cleanup of expired cloud storage files.""" return cleanup_expired_files() + @expose + def execute_cleanup_oauth_tokens(self): + """Manually trigger cleanup of expired OAuth tokens.""" + return cleanup_oauth_tokens() + @expose def execute_report_execution_accuracy_alerts(self): """Manually trigger execution accuracy alert checking.""" diff --git a/autogpt_platform/backend/backend/server/external/middleware.py b/autogpt_platform/backend/backend/server/external/middleware.py index af84c92687..0c278e1715 100644 --- a/autogpt_platform/backend/backend/server/external/middleware.py +++ b/autogpt_platform/backend/backend/server/external/middleware.py @@ -1,36 +1,107 @@ -from fastapi import HTTPException, Security -from fastapi.security import APIKeyHeader +from fastapi import HTTPException, Security, status +from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer from prisma.enums import APIKeyPermission -from backend.data.api_key import APIKeyInfo, has_permission, validate_api_key +from backend.data.auth.api_key import APIKeyInfo, validate_api_key +from backend.data.auth.base import APIAuthorizationInfo +from backend.data.auth.oauth import ( + InvalidClientError, + InvalidTokenError, + OAuthAccessTokenInfo, + validate_access_token, +) api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) +bearer_auth = HTTPBearer(auto_error=False) async def require_api_key(api_key: str | None = Security(api_key_header)) -> APIKeyInfo: - """Base middleware for API key authentication""" + """Middleware for API key authentication only""" if api_key is None: - raise HTTPException(status_code=401, detail="Missing API key") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key" + ) api_key_obj = await validate_api_key(api_key) if not api_key_obj: - raise HTTPException(status_code=401, detail="Invalid API key") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key" + ) return api_key_obj +async def require_access_token( + bearer: HTTPAuthorizationCredentials | None = Security(bearer_auth), +) -> OAuthAccessTokenInfo: + """Middleware for OAuth access token authentication only""" + if bearer is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing Authorization header", + ) + + try: + token_info, _ = await validate_access_token(bearer.credentials) + except (InvalidClientError, InvalidTokenError) as e: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e)) + + return token_info + + +async def require_auth( + api_key: str | None = Security(api_key_header), + bearer: HTTPAuthorizationCredentials | None = Security(bearer_auth), +) -> APIAuthorizationInfo: + """ + Unified authentication middleware supporting both API keys and OAuth tokens. + + Supports two authentication methods, which are checked in order: + 1. X-API-Key header (existing API key authentication) + 2. Authorization: Bearer header (OAuth access token) + + Returns: + APIAuthorizationInfo: base class of both APIKeyInfo and OAuthAccessTokenInfo. + """ + # Try API key first + if api_key is not None: + api_key_info = await validate_api_key(api_key) + if api_key_info: + return api_key_info + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key" + ) + + # Try OAuth bearer token + if bearer is not None: + try: + token_info, _ = await validate_access_token(bearer.credentials) + return token_info + except (InvalidClientError, InvalidTokenError) as e: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e)) + + # No credentials provided + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing authentication. Provide API key or access token.", + ) + + def require_permission(permission: APIKeyPermission): - """Dependency function for checking specific permissions""" + """ + Dependency function for checking specific permissions + (works with API keys and OAuth tokens) + """ async def check_permission( - api_key: APIKeyInfo = Security(require_api_key), - ) -> APIKeyInfo: - if not has_permission(api_key, permission): + auth: APIAuthorizationInfo = Security(require_auth), + ) -> APIAuthorizationInfo: + if permission not in auth.scopes: raise HTTPException( - status_code=403, - detail=f"API key lacks the required permission '{permission}'", + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing required permission: {permission.value}", ) - return api_key + return auth return check_permission diff --git a/autogpt_platform/backend/backend/server/external/routes/integrations.py b/autogpt_platform/backend/backend/server/external/routes/integrations.py index d64ca5615f..f9a8875ada 100644 --- a/autogpt_platform/backend/backend/server/external/routes/integrations.py +++ b/autogpt_platform/backend/backend/server/external/routes/integrations.py @@ -16,7 +16,7 @@ from fastapi import APIRouter, Body, HTTPException, Path, Security, status from prisma.enums import APIKeyPermission from pydantic import BaseModel, Field, SecretStr -from backend.data.api_key import APIKeyInfo +from backend.data.auth.base import APIAuthorizationInfo from backend.data.model import ( APIKeyCredentials, Credentials, @@ -255,7 +255,7 @@ def _get_oauth_handler_for_external( @integrations_router.get("/providers", response_model=list[ProviderInfo]) async def list_providers( - api_key: APIKeyInfo = Security( + auth: APIAuthorizationInfo = Security( require_permission(APIKeyPermission.READ_INTEGRATIONS) ), ) -> list[ProviderInfo]: @@ -319,7 +319,7 @@ async def list_providers( async def initiate_oauth( provider: Annotated[str, Path(title="The OAuth provider")], request: OAuthInitiateRequest, - api_key: APIKeyInfo = Security( + auth: APIAuthorizationInfo = Security( require_permission(APIKeyPermission.MANAGE_INTEGRATIONS) ), ) -> OAuthInitiateResponse: @@ -337,7 +337,10 @@ async def initiate_oauth( if not validate_callback_url(request.callback_url): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Callback URL origin is not allowed. Allowed origins: {settings.config.external_oauth_callback_origins}", + detail=( + f"Callback URL origin is not allowed. " + f"Allowed origins: {settings.config.external_oauth_callback_origins}", + ), ) # Validate provider @@ -359,13 +362,15 @@ async def initiate_oauth( ) # Store state token with external flow metadata + # Note: initiated_by_api_key_id is only available for API key auth, not OAuth + api_key_id = getattr(auth, "id", None) if auth.type == "api_key" else None state_token, code_challenge = await creds_manager.store.store_state_token( - user_id=api_key.user_id, + user_id=auth.user_id, provider=provider if isinstance(provider_name, str) else provider_name.value, scopes=request.scopes, callback_url=request.callback_url, state_metadata=request.state_metadata, - initiated_by_api_key_id=api_key.id, + initiated_by_api_key_id=api_key_id, ) # Build login URL @@ -393,7 +398,7 @@ async def initiate_oauth( async def complete_oauth( provider: Annotated[str, Path(title="The OAuth provider")], request: OAuthCompleteRequest, - api_key: APIKeyInfo = Security( + auth: APIAuthorizationInfo = Security( require_permission(APIKeyPermission.MANAGE_INTEGRATIONS) ), ) -> OAuthCompleteResponse: @@ -406,7 +411,7 @@ async def complete_oauth( """ # Verify state token valid_state = await creds_manager.store.verify_state_token( - api_key.user_id, request.state_token, provider + auth.user_id, request.state_token, provider ) if not valid_state: @@ -453,7 +458,7 @@ async def complete_oauth( ) # Store credentials - await creds_manager.create(api_key.user_id, credentials) + await creds_manager.create(auth.user_id, credentials) logger.info(f"Successfully completed external OAuth for provider {provider}") @@ -470,7 +475,7 @@ async def complete_oauth( @integrations_router.get("/credentials", response_model=list[CredentialSummary]) async def list_credentials( - api_key: APIKeyInfo = Security( + auth: APIAuthorizationInfo = Security( require_permission(APIKeyPermission.READ_INTEGRATIONS) ), ) -> list[CredentialSummary]: @@ -479,7 +484,7 @@ async def list_credentials( Returns metadata about each credential without exposing sensitive tokens. """ - credentials = await creds_manager.store.get_all_creds(api_key.user_id) + credentials = await creds_manager.store.get_all_creds(auth.user_id) return [ CredentialSummary( id=cred.id, @@ -499,7 +504,7 @@ async def list_credentials( ) async def list_credentials_by_provider( provider: Annotated[str, Path(title="The provider to list credentials for")], - api_key: APIKeyInfo = Security( + auth: APIAuthorizationInfo = Security( require_permission(APIKeyPermission.READ_INTEGRATIONS) ), ) -> list[CredentialSummary]: @@ -507,7 +512,7 @@ async def list_credentials_by_provider( List credentials for a specific provider. """ credentials = await creds_manager.store.get_creds_by_provider( - api_key.user_id, provider + auth.user_id, provider ) return [ CredentialSummary( @@ -536,7 +541,7 @@ async def create_credential( CreateUserPasswordCredentialRequest, CreateHostScopedCredentialRequest, ] = Body(..., discriminator="type"), - api_key: APIKeyInfo = Security( + auth: APIAuthorizationInfo = Security( require_permission(APIKeyPermission.MANAGE_INTEGRATIONS) ), ) -> CreateCredentialResponse: @@ -591,7 +596,7 @@ async def create_credential( # Store credentials try: - await creds_manager.create(api_key.user_id, credentials) + await creds_manager.create(auth.user_id, credentials) except Exception as e: logger.error(f"Failed to store credentials: {e}") raise HTTPException( @@ -623,7 +628,7 @@ class DeleteCredentialResponse(BaseModel): async def delete_credential( provider: Annotated[str, Path(title="The provider")], cred_id: Annotated[str, Path(title="The credential ID to delete")], - api_key: APIKeyInfo = Security( + auth: APIAuthorizationInfo = Security( require_permission(APIKeyPermission.DELETE_INTEGRATIONS) ), ) -> DeleteCredentialResponse: @@ -634,7 +639,7 @@ async def delete_credential( use the main API's delete endpoint which handles webhook cleanup and token revocation. """ - creds = await creds_manager.store.get_creds_by_id(api_key.user_id, cred_id) + creds = await creds_manager.store.get_creds_by_id(auth.user_id, cred_id) if not creds: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Credentials not found" @@ -645,6 +650,6 @@ async def delete_credential( detail="Credentials do not match the specified provider", ) - await creds_manager.delete(api_key.user_id, cred_id) + await creds_manager.delete(auth.user_id, cred_id) return DeleteCredentialResponse(deleted=True, credentials_id=cred_id) diff --git a/autogpt_platform/backend/backend/server/external/routes/tools.py b/autogpt_platform/backend/backend/server/external/routes/tools.py index 3a821c5be8..8e3f4cbfdb 100644 --- a/autogpt_platform/backend/backend/server/external/routes/tools.py +++ b/autogpt_platform/backend/backend/server/external/routes/tools.py @@ -14,7 +14,7 @@ from fastapi import APIRouter, Security from prisma.enums import APIKeyPermission from pydantic import BaseModel, Field -from backend.data.api_key import APIKeyInfo +from backend.data.auth.base import APIAuthorizationInfo from backend.server.external.middleware import require_permission from backend.server.v2.chat.model import ChatSession from backend.server.v2.chat.tools import find_agent_tool, run_agent_tool @@ -24,9 +24,9 @@ logger = logging.getLogger(__name__) tools_router = APIRouter(prefix="/tools", tags=["tools"]) -# Note: We use Security() as a function parameter dependency (api_key: APIKeyInfo = Security(...)) +# Note: We use Security() as a function parameter dependency (auth: APIAuthorizationInfo = Security(...)) # rather than in the decorator's dependencies= list. This avoids duplicate permission checks -# while still enforcing auth AND giving us access to the api_key for extracting user_id. +# while still enforcing auth AND giving us access to auth for extracting user_id. # Request models @@ -80,7 +80,9 @@ def _create_ephemeral_session(user_id: str | None) -> ChatSession: ) async def find_agent( request: FindAgentRequest, - api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.USE_TOOLS)), + auth: APIAuthorizationInfo = Security( + require_permission(APIKeyPermission.USE_TOOLS) + ), ) -> dict[str, Any]: """ Search for agents in the marketplace based on capabilities and user needs. @@ -91,9 +93,9 @@ async def find_agent( Returns: List of matching agents or no results response """ - session = _create_ephemeral_session(api_key.user_id) + session = _create_ephemeral_session(auth.user_id) result = await find_agent_tool._execute( - user_id=api_key.user_id, + user_id=auth.user_id, session=session, query=request.query, ) @@ -105,7 +107,9 @@ async def find_agent( ) async def run_agent( request: RunAgentRequest, - api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.USE_TOOLS)), + auth: APIAuthorizationInfo = Security( + require_permission(APIKeyPermission.USE_TOOLS) + ), ) -> dict[str, Any]: """ Run or schedule an agent from the marketplace. @@ -129,9 +133,9 @@ async def run_agent( - execution_started: If agent was run or scheduled successfully - error: If something went wrong """ - session = _create_ephemeral_session(api_key.user_id) + session = _create_ephemeral_session(auth.user_id) result = await run_agent_tool._execute( - user_id=api_key.user_id, + user_id=auth.user_id, session=session, username_agent_slug=request.username_agent_slug, inputs=request.inputs, diff --git a/autogpt_platform/backend/backend/server/external/routes/v1.py b/autogpt_platform/backend/backend/server/external/routes/v1.py index 1b2840acf9..f83673465a 100644 --- a/autogpt_platform/backend/backend/server/external/routes/v1.py +++ b/autogpt_platform/backend/backend/server/external/routes/v1.py @@ -5,6 +5,7 @@ from typing import Annotated, Any, Literal, Optional, Sequence from fastapi import APIRouter, Body, HTTPException, Security from prisma.enums import AgentExecutionStatus, APIKeyPermission +from pydantic import BaseModel, Field from typing_extensions import TypedDict import backend.data.block @@ -12,7 +13,8 @@ import backend.server.v2.store.cache as store_cache import backend.server.v2.store.model as store_model from backend.data import execution as execution_db from backend.data import graph as graph_db -from backend.data.api_key import APIKeyInfo +from backend.data import user as user_db +from backend.data.auth.base import APIAuthorizationInfo from backend.data.block import BlockInput, CompletedBlockOutput from backend.executor.utils import add_graph_execution from backend.server.external.middleware import require_permission @@ -24,27 +26,33 @@ logger = logging.getLogger(__name__) v1_router = APIRouter() -class NodeOutput(TypedDict): - key: str - value: Any +class UserInfoResponse(BaseModel): + id: str + name: Optional[str] + email: str + timezone: str = Field( + description="The user's last known timezone (e.g. 'Europe/Amsterdam'), " + "or 'not-set' if not set" + ) -class ExecutionNode(TypedDict): - node_id: str - input: Any - output: dict[str, Any] +@v1_router.get( + path="/me", + tags=["user", "meta"], +) +async def get_user_info( + auth: APIAuthorizationInfo = Security( + require_permission(APIKeyPermission.IDENTITY) + ), +) -> UserInfoResponse: + user = await user_db.get_user_by_id(auth.user_id) - -class ExecutionNodeOutput(TypedDict): - node_id: str - outputs: list[NodeOutput] - - -class GraphExecutionResult(TypedDict): - execution_id: str - status: str - nodes: list[ExecutionNode] - output: Optional[list[dict[str, str]]] + return UserInfoResponse( + id=user.id, + name=user.name, + email=user.email, + timezone=user.timezone, + ) @v1_router.get( @@ -65,7 +73,9 @@ async def get_graph_blocks() -> Sequence[dict[Any, Any]]: async def execute_graph_block( block_id: str, data: BlockInput, - api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.EXECUTE_BLOCK)), + auth: APIAuthorizationInfo = Security( + require_permission(APIKeyPermission.EXECUTE_BLOCK) + ), ) -> CompletedBlockOutput: obj = backend.data.block.get_block(block_id) if not obj: @@ -85,12 +95,14 @@ async def execute_graph( graph_id: str, graph_version: int, node_input: Annotated[dict[str, Any], Body(..., embed=True, default_factory=dict)], - api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.EXECUTE_GRAPH)), + auth: APIAuthorizationInfo = Security( + require_permission(APIKeyPermission.EXECUTE_GRAPH) + ), ) -> dict[str, Any]: try: graph_exec = await add_graph_execution( graph_id=graph_id, - user_id=api_key.user_id, + user_id=auth.user_id, inputs=node_input, graph_version=graph_version, ) @@ -100,6 +112,19 @@ async def execute_graph( raise HTTPException(status_code=400, detail=msg) +class ExecutionNode(TypedDict): + node_id: str + input: Any + output: dict[str, Any] + + +class GraphExecutionResult(TypedDict): + execution_id: str + status: str + nodes: list[ExecutionNode] + output: Optional[list[dict[str, str]]] + + @v1_router.get( path="/graphs/{graph_id}/executions/{graph_exec_id}/results", tags=["graphs"], @@ -107,10 +132,12 @@ async def execute_graph( async def get_graph_execution_results( graph_id: str, graph_exec_id: str, - api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.READ_GRAPH)), + auth: APIAuthorizationInfo = Security( + require_permission(APIKeyPermission.READ_GRAPH) + ), ) -> GraphExecutionResult: graph_exec = await execution_db.get_graph_execution( - user_id=api_key.user_id, + user_id=auth.user_id, execution_id=graph_exec_id, include_node_executions=True, ) @@ -122,7 +149,7 @@ async def get_graph_execution_results( if not await graph_db.get_graph( graph_id=graph_exec.graph_id, version=graph_exec.graph_version, - user_id=api_key.user_id, + user_id=auth.user_id, ): raise HTTPException(status_code=404, detail=f"Graph #{graph_id} not found.") diff --git a/autogpt_platform/backend/backend/server/model.py b/autogpt_platform/backend/backend/server/model.py index 1d7b79cd7c..5e13e20450 100644 --- a/autogpt_platform/backend/backend/server/model.py +++ b/autogpt_platform/backend/backend/server/model.py @@ -4,7 +4,7 @@ from typing import Any, Literal, Optional import pydantic from prisma.enums import OnboardingStep -from backend.data.api_key import APIKeyInfo, APIKeyPermission +from backend.data.auth.api_key import APIKeyInfo, APIKeyPermission from backend.data.graph import Graph from backend.util.timezone_name import TimeZoneName diff --git a/autogpt_platform/backend/backend/server/rest_api.py b/autogpt_platform/backend/backend/server/rest_api.py index 556903571c..5db2b18c27 100644 --- a/autogpt_platform/backend/backend/server/rest_api.py +++ b/autogpt_platform/backend/backend/server/rest_api.py @@ -21,6 +21,7 @@ import backend.data.db import backend.data.graph import backend.data.user import backend.integrations.webhooks.utils +import backend.server.routers.oauth import backend.server.routers.postmark.postmark import backend.server.routers.v1 import backend.server.v2.admin.credit_admin_routes @@ -297,6 +298,11 @@ app.include_router( tags=["v2", "chat"], prefix="/api/chat", ) +app.include_router( + backend.server.routers.oauth.router, + tags=["oauth"], + prefix="/api/oauth", +) app.mount("/external-api", external_app) diff --git a/autogpt_platform/backend/backend/server/routers/oauth.py b/autogpt_platform/backend/backend/server/routers/oauth.py new file mode 100644 index 0000000000..55f591427a --- /dev/null +++ b/autogpt_platform/backend/backend/server/routers/oauth.py @@ -0,0 +1,833 @@ +""" +OAuth 2.0 Provider Endpoints + +Implements OAuth 2.0 Authorization Code flow with PKCE support. + +Flow: +1. User clicks "Login with AutoGPT" in 3rd party app +2. App redirects user to /oauth/authorize with client_id, redirect_uri, scope, state +3. User sees consent screen (if not already logged in, redirects to login first) +4. User approves → backend creates authorization code +5. User redirected back to app with code +6. App exchanges code for access/refresh tokens at /oauth/token +7. App uses access token to call external API endpoints +""" + +import io +import logging +import os +import uuid +from datetime import datetime +from typing import Literal, Optional +from urllib.parse import urlencode + +from autogpt_libs.auth import get_user_id +from fastapi import APIRouter, Body, HTTPException, Security, UploadFile, status +from gcloud.aio import storage as async_storage +from PIL import Image +from prisma.enums import APIKeyPermission +from pydantic import BaseModel, Field + +from backend.data.auth.oauth import ( + InvalidClientError, + InvalidGrantError, + OAuthApplicationInfo, + TokenIntrospectionResult, + consume_authorization_code, + create_access_token, + create_authorization_code, + create_refresh_token, + get_oauth_application, + get_oauth_application_by_id, + introspect_token, + list_user_oauth_applications, + refresh_tokens, + revoke_access_token, + revoke_refresh_token, + update_oauth_application, + validate_client_credentials, + validate_redirect_uri, + validate_scopes, +) +from backend.util.settings import Settings +from backend.util.virus_scanner import scan_content_safe + +settings = Settings() +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ============================================================================ +# Request/Response Models +# ============================================================================ + + +class TokenResponse(BaseModel): + """OAuth 2.0 token response""" + + token_type: Literal["Bearer"] = "Bearer" + access_token: str + access_token_expires_at: datetime + refresh_token: str + refresh_token_expires_at: datetime + scopes: list[str] + + +class ErrorResponse(BaseModel): + """OAuth 2.0 error response""" + + error: str + error_description: Optional[str] = None + + +class OAuthApplicationPublicInfo(BaseModel): + """Public information about an OAuth application (for consent screen)""" + + name: str + description: Optional[str] = None + logo_url: Optional[str] = None + scopes: list[str] + + +# ============================================================================ +# Application Info Endpoint +# ============================================================================ + + +@router.get( + "/app/{client_id}", + responses={ + 404: {"description": "Application not found or disabled"}, + }, +) +async def get_oauth_app_info( + client_id: str, user_id: str = Security(get_user_id) +) -> OAuthApplicationPublicInfo: + """ + Get public information about an OAuth application. + + This endpoint is used by the consent screen to display application details + to the user before they authorize access. + + Returns: + - name: Application name + - description: Application description (if provided) + - scopes: List of scopes the application is allowed to request + """ + app = await get_oauth_application(client_id) + if not app or not app.is_active: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Application not found", + ) + + return OAuthApplicationPublicInfo( + name=app.name, + description=app.description, + logo_url=app.logo_url, + scopes=[s.value for s in app.scopes], + ) + + +# ============================================================================ +# Authorization Endpoint +# ============================================================================ + + +class AuthorizeRequest(BaseModel): + """OAuth 2.0 authorization request""" + + client_id: str = Field(description="Client identifier") + redirect_uri: str = Field(description="Redirect URI") + scopes: list[str] = Field(description="List of scopes") + state: str = Field(description="Anti-CSRF token from client") + response_type: str = Field( + default="code", description="Must be 'code' for authorization code flow" + ) + code_challenge: str = Field(description="PKCE code challenge (required)") + code_challenge_method: Literal["S256", "plain"] = Field( + default="S256", description="PKCE code challenge method (S256 recommended)" + ) + + +class AuthorizeResponse(BaseModel): + """OAuth 2.0 authorization response with redirect URL""" + + redirect_url: str = Field(description="URL to redirect the user to") + + +@router.post("/authorize") +async def authorize( + request: AuthorizeRequest = Body(), + user_id: str = Security(get_user_id), +) -> AuthorizeResponse: + """ + OAuth 2.0 Authorization Endpoint + + User must be logged in (authenticated with Supabase JWT). + This endpoint creates an authorization code and returns a redirect URL. + + PKCE (Proof Key for Code Exchange) is REQUIRED for all authorization requests. + + The frontend consent screen should call this endpoint after the user approves, + then redirect the user to the returned `redirect_url`. + + Request Body: + - client_id: The OAuth application's client ID + - redirect_uri: Where to redirect after authorization (must match registered URI) + - scopes: List of permissions (e.g., "EXECUTE_GRAPH READ_GRAPH") + - state: Anti-CSRF token provided by client (will be returned in redirect) + - response_type: Must be "code" (for authorization code flow) + - code_challenge: PKCE code challenge (required) + - code_challenge_method: "S256" (recommended) or "plain" + + Returns: + - redirect_url: The URL to redirect the user to (includes authorization code) + + Error cases return a redirect_url with error parameters, or raise HTTPException + for critical errors (like invalid redirect_uri). + """ + try: + # Validate response_type + if request.response_type != "code": + return _error_redirect_url( + request.redirect_uri, + request.state, + "unsupported_response_type", + "Only 'code' response type is supported", + ) + + # Get application + app = await get_oauth_application(request.client_id) + if not app: + return _error_redirect_url( + request.redirect_uri, + request.state, + "invalid_client", + "Unknown client_id", + ) + + if not app.is_active: + return _error_redirect_url( + request.redirect_uri, + request.state, + "invalid_client", + "Application is not active", + ) + + # Validate redirect URI + if not validate_redirect_uri(app, request.redirect_uri): + # For invalid redirect_uri, we can't redirect safely + # Must return error instead + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + "Invalid redirect_uri. " + f"Must be one of: {', '.join(app.redirect_uris)}" + ), + ) + + # Parse and validate scopes + try: + requested_scopes = [APIKeyPermission(s.strip()) for s in request.scopes] + except ValueError as e: + return _error_redirect_url( + request.redirect_uri, + request.state, + "invalid_scope", + f"Invalid scope: {e}", + ) + + if not requested_scopes: + return _error_redirect_url( + request.redirect_uri, + request.state, + "invalid_scope", + "At least one scope is required", + ) + + if not validate_scopes(app, requested_scopes): + return _error_redirect_url( + request.redirect_uri, + request.state, + "invalid_scope", + "Application is not authorized for all requested scopes. " + f"Allowed: {', '.join(s.value for s in app.scopes)}", + ) + + # Create authorization code + auth_code = await create_authorization_code( + application_id=app.id, + user_id=user_id, + scopes=requested_scopes, + redirect_uri=request.redirect_uri, + code_challenge=request.code_challenge, + code_challenge_method=request.code_challenge_method, + ) + + # Build redirect URL with authorization code + params = { + "code": auth_code.code, + "state": request.state, + } + redirect_url = f"{request.redirect_uri}?{urlencode(params)}" + + logger.info( + f"Authorization code issued for user #{user_id} " + f"and app {app.name} (#{app.id})" + ) + + return AuthorizeResponse(redirect_url=redirect_url) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error in authorization endpoint: {e}", exc_info=True) + return _error_redirect_url( + request.redirect_uri, + request.state, + "server_error", + "An unexpected error occurred", + ) + + +def _error_redirect_url( + redirect_uri: str, + state: str, + error: str, + error_description: Optional[str] = None, +) -> AuthorizeResponse: + """Helper to build redirect URL with OAuth error parameters""" + params = { + "error": error, + "state": state, + } + if error_description: + params["error_description"] = error_description + + redirect_url = f"{redirect_uri}?{urlencode(params)}" + return AuthorizeResponse(redirect_url=redirect_url) + + +# ============================================================================ +# Token Endpoint +# ============================================================================ + + +class TokenRequestByCode(BaseModel): + grant_type: Literal["authorization_code"] + code: str = Field(description="Authorization code") + redirect_uri: str = Field( + description="Redirect URI (must match authorization request)" + ) + client_id: str + client_secret: str + code_verifier: str = Field(description="PKCE code verifier") + + +class TokenRequestByRefreshToken(BaseModel): + grant_type: Literal["refresh_token"] + refresh_token: str + client_id: str + client_secret: str + + +@router.post("/token") +async def token( + request: TokenRequestByCode | TokenRequestByRefreshToken = Body(), +) -> TokenResponse: + """ + OAuth 2.0 Token Endpoint + + Exchanges authorization code or refresh token for access token. + + Grant Types: + 1. authorization_code: Exchange authorization code for tokens + - Required: grant_type, code, redirect_uri, client_id, client_secret + - Optional: code_verifier (required if PKCE was used) + + 2. refresh_token: Exchange refresh token for new access token + - Required: grant_type, refresh_token, client_id, client_secret + + Returns: + - access_token: Bearer token for API access (1 hour TTL) + - token_type: "Bearer" + - expires_in: Seconds until access token expires + - refresh_token: Token for refreshing access (30 days TTL) + - scopes: List of scopes + """ + # Validate client credentials + try: + app = await validate_client_credentials( + request.client_id, request.client_secret + ) + except InvalidClientError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e), + ) + + # Handle authorization_code grant + if request.grant_type == "authorization_code": + # Consume authorization code + try: + user_id, scopes = await consume_authorization_code( + code=request.code, + application_id=app.id, + redirect_uri=request.redirect_uri, + code_verifier=request.code_verifier, + ) + except InvalidGrantError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + # Create access and refresh tokens + access_token = await create_access_token(app.id, user_id, scopes) + refresh_token = await create_refresh_token(app.id, user_id, scopes) + + logger.info( + f"Access token issued for user #{user_id} and app {app.name} (#{app.id})" + "via authorization code" + ) + + if not access_token.token or not refresh_token.token: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to generate tokens", + ) + + return TokenResponse( + token_type="Bearer", + access_token=access_token.token.get_secret_value(), + access_token_expires_at=access_token.expires_at, + refresh_token=refresh_token.token.get_secret_value(), + refresh_token_expires_at=refresh_token.expires_at, + scopes=list(s.value for s in scopes), + ) + + # Handle refresh_token grant + elif request.grant_type == "refresh_token": + # Refresh access token + try: + new_access_token, new_refresh_token = await refresh_tokens( + request.refresh_token, app.id + ) + except InvalidGrantError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + logger.info( + f"Tokens refreshed for user #{new_access_token.user_id} " + f"by app {app.name} (#{app.id})" + ) + + if not new_access_token.token or not new_refresh_token.token: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to generate tokens", + ) + + return TokenResponse( + token_type="Bearer", + access_token=new_access_token.token.get_secret_value(), + access_token_expires_at=new_access_token.expires_at, + refresh_token=new_refresh_token.token.get_secret_value(), + refresh_token_expires_at=new_refresh_token.expires_at, + scopes=list(s.value for s in new_access_token.scopes), + ) + + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported grant_type: {request.grant_type}. " + "Must be 'authorization_code' or 'refresh_token'", + ) + + +# ============================================================================ +# Token Introspection Endpoint +# ============================================================================ + + +@router.post("/introspect") +async def introspect( + token: str = Body(description="Token to introspect"), + token_type_hint: Optional[Literal["access_token", "refresh_token"]] = Body( + None, description="Hint about token type ('access_token' or 'refresh_token')" + ), + client_id: str = Body(description="Client identifier"), + client_secret: str = Body(description="Client secret"), +) -> TokenIntrospectionResult: + """ + OAuth 2.0 Token Introspection Endpoint (RFC 7662) + + Allows clients to check if a token is valid and get its metadata. + + Returns: + - active: Whether the token is currently active + - scopes: List of authorized scopes (if active) + - client_id: The client the token was issued to (if active) + - user_id: The user the token represents (if active) + - exp: Expiration timestamp (if active) + - token_type: "access_token" or "refresh_token" (if active) + """ + # Validate client credentials + try: + await validate_client_credentials(client_id, client_secret) + except InvalidClientError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e), + ) + + # Introspect the token + return await introspect_token(token, token_type_hint) + + +# ============================================================================ +# Token Revocation Endpoint +# ============================================================================ + + +@router.post("/revoke") +async def revoke( + token: str = Body(description="Token to revoke"), + token_type_hint: Optional[Literal["access_token", "refresh_token"]] = Body( + None, description="Hint about token type ('access_token' or 'refresh_token')" + ), + client_id: str = Body(description="Client identifier"), + client_secret: str = Body(description="Client secret"), +): + """ + OAuth 2.0 Token Revocation Endpoint (RFC 7009) + + Allows clients to revoke an access or refresh token. + + Note: Revoking a refresh token does NOT revoke associated access tokens. + Revoking an access token does NOT revoke the associated refresh token. + """ + # Validate client credentials + try: + app = await validate_client_credentials(client_id, client_secret) + except InvalidClientError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e), + ) + + # Try to revoke as access token first + # Note: We pass app.id to ensure the token belongs to the authenticated app + if token_type_hint != "refresh_token": + revoked = await revoke_access_token(token, app.id) + if revoked: + logger.info( + f"Access token revoked for app {app.name} (#{app.id}); " + f"user #{revoked.user_id}" + ) + return {"status": "ok"} + + # Try to revoke as refresh token + revoked = await revoke_refresh_token(token, app.id) + if revoked: + logger.info( + f"Refresh token revoked for app {app.name} (#{app.id}); " + f"user #{revoked.user_id}" + ) + return {"status": "ok"} + + # Per RFC 7009, revocation endpoint returns 200 even if token not found + # or if token belongs to a different application. + # This prevents token scanning attacks. + logger.warning(f"Unsuccessful token revocation attempt by app {app.name} #{app.id}") + return {"status": "ok"} + + +# ============================================================================ +# Application Management Endpoints (for app owners) +# ============================================================================ + + +@router.get("/apps/mine") +async def list_my_oauth_apps( + user_id: str = Security(get_user_id), +) -> list[OAuthApplicationInfo]: + """ + List all OAuth applications owned by the current user. + + Returns a list of OAuth applications with their details including: + - id, name, description, logo_url + - client_id (public identifier) + - redirect_uris, grant_types, scopes + - is_active status + - created_at, updated_at timestamps + + Note: client_secret is never returned for security reasons. + """ + return await list_user_oauth_applications(user_id) + + +@router.patch("/apps/{app_id}/status") +async def update_app_status( + app_id: str, + user_id: str = Security(get_user_id), + is_active: bool = Body(description="Whether the app should be active", embed=True), +) -> OAuthApplicationInfo: + """ + Enable or disable an OAuth application. + + Only the application owner can update the status. + When disabled, the application cannot be used for new authorizations + and existing access tokens will fail validation. + + Returns the updated application info. + """ + updated_app = await update_oauth_application( + app_id=app_id, + owner_id=user_id, + is_active=is_active, + ) + + if not updated_app: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Application not found or you don't have permission to update it", + ) + + action = "enabled" if is_active else "disabled" + logger.info(f"OAuth app {updated_app.name} (#{app_id}) {action} by user #{user_id}") + + return updated_app + + +class UpdateAppLogoRequest(BaseModel): + logo_url: str = Field(description="URL of the uploaded logo image") + + +@router.patch("/apps/{app_id}/logo") +async def update_app_logo( + app_id: str, + request: UpdateAppLogoRequest = Body(), + user_id: str = Security(get_user_id), +) -> OAuthApplicationInfo: + """ + Update the logo URL for an OAuth application. + + Only the application owner can update the logo. + The logo should be uploaded first using the media upload endpoint, + then this endpoint is called with the resulting URL. + + Logo requirements: + - Must be square (1:1 aspect ratio) + - Minimum 512x512 pixels + - Maximum 2048x2048 pixels + + Returns the updated application info. + """ + if ( + not (app := await get_oauth_application_by_id(app_id)) + or app.owner_id != user_id + ): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="OAuth App not found", + ) + + # Delete the current app logo file (if any and it's in our cloud storage) + await _delete_app_current_logo_file(app) + + updated_app = await update_oauth_application( + app_id=app_id, + owner_id=user_id, + logo_url=request.logo_url, + ) + + if not updated_app: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Application not found or you don't have permission to update it", + ) + + logger.info( + f"OAuth app {updated_app.name} (#{app_id}) logo updated by user #{user_id}" + ) + + return updated_app + + +# Logo upload constraints +LOGO_MIN_SIZE = 512 +LOGO_MAX_SIZE = 2048 +LOGO_ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"} +LOGO_MAX_FILE_SIZE = 3 * 1024 * 1024 # 3MB + + +@router.post("/apps/{app_id}/logo/upload") +async def upload_app_logo( + app_id: str, + file: UploadFile, + user_id: str = Security(get_user_id), +) -> OAuthApplicationInfo: + """ + Upload a logo image for an OAuth application. + + Requirements: + - Image must be square (1:1 aspect ratio) + - Minimum 512x512 pixels + - Maximum 2048x2048 pixels + - Allowed formats: JPEG, PNG, WebP + - Maximum file size: 3MB + + The image is uploaded to cloud storage and the app's logoUrl is updated. + Returns the updated application info. + """ + # Verify ownership to reduce vulnerability to DoS(torage) or DoM(oney) attacks + if ( + not (app := await get_oauth_application_by_id(app_id)) + or app.owner_id != user_id + ): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="OAuth App not found", + ) + + # Check GCS configuration + if not settings.config.media_gcs_bucket_name: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Media storage is not configured", + ) + + # Validate content type + content_type = file.content_type + if content_type not in LOGO_ALLOWED_TYPES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid file type. Allowed: JPEG, PNG, WebP. Got: {content_type}", + ) + + # Read file content + try: + file_bytes = await file.read() + except Exception as e: + logger.error(f"Error reading logo file: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Failed to read uploaded file", + ) + + # Check file size + if len(file_bytes) > LOGO_MAX_FILE_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + "File too large. " + f"Maximum size is {LOGO_MAX_FILE_SIZE // 1024 // 1024}MB" + ), + ) + + # Validate image dimensions + try: + image = Image.open(io.BytesIO(file_bytes)) + width, height = image.size + + if width != height: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Logo must be square. Got {width}x{height}", + ) + + if width < LOGO_MIN_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Logo too small. Minimum {LOGO_MIN_SIZE}x{LOGO_MIN_SIZE}. " + f"Got {width}x{height}", + ) + + if width > LOGO_MAX_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Logo too large. Maximum {LOGO_MAX_SIZE}x{LOGO_MAX_SIZE}. " + f"Got {width}x{height}", + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error validating logo image: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid image file", + ) + + # Scan for viruses + filename = file.filename or "logo" + await scan_content_safe(file_bytes, filename=filename) + + # Generate unique filename + file_ext = os.path.splitext(filename)[1].lower() or ".png" + unique_filename = f"{uuid.uuid4()}{file_ext}" + storage_path = f"oauth-apps/{app_id}/logo/{unique_filename}" + + # Upload to GCS + try: + async with async_storage.Storage() as async_client: + bucket_name = settings.config.media_gcs_bucket_name + + await async_client.upload( + bucket_name, storage_path, file_bytes, content_type=content_type + ) + + logo_url = f"https://storage.googleapis.com/{bucket_name}/{storage_path}" + except Exception as e: + logger.error(f"Error uploading logo to GCS: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to upload logo", + ) + + # Delete the current app logo file (if any and it's in our cloud storage) + await _delete_app_current_logo_file(app) + + # Update the app with the new logo URL + updated_app = await update_oauth_application( + app_id=app_id, + owner_id=user_id, + logo_url=logo_url, + ) + + if not updated_app: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Application not found or you don't have permission to update it", + ) + + logger.info( + f"OAuth app {updated_app.name} (#{app_id}) logo uploaded by user #{user_id}" + ) + + return updated_app + + +async def _delete_app_current_logo_file(app: OAuthApplicationInfo): + """ + Delete the current logo file for the given app, if there is one in our cloud storage + """ + bucket_name = settings.config.media_gcs_bucket_name + storage_base_url = f"https://storage.googleapis.com/{bucket_name}/" + + if app.logo_url and app.logo_url.startswith(storage_base_url): + # Parse blob path from URL: https://storage.googleapis.com/{bucket}/{path} + old_path = app.logo_url.replace(storage_base_url, "") + try: + async with async_storage.Storage() as async_client: + await async_client.delete(bucket_name, old_path) + logger.info(f"Deleted old logo for OAuth app #{app.id}: {old_path}") + except Exception as e: + # Log but don't fail - the new logo was uploaded successfully + logger.warning( + f"Failed to delete old logo for OAuth app #{app.id}: {e}", exc_info=e + ) diff --git a/autogpt_platform/backend/backend/server/routers/oauth_test.py b/autogpt_platform/backend/backend/server/routers/oauth_test.py new file mode 100644 index 0000000000..8ec6911152 --- /dev/null +++ b/autogpt_platform/backend/backend/server/routers/oauth_test.py @@ -0,0 +1,1784 @@ +""" +End-to-end integration tests for OAuth 2.0 Provider Endpoints. + +These tests hit the actual API endpoints and database, testing the complete +OAuth flow from endpoint to database. + +Tests cover: +1. Authorization endpoint - creating authorization codes +2. Token endpoint - exchanging codes for tokens and refreshing +3. Token introspection endpoint - checking token validity +4. Token revocation endpoint - revoking tokens +5. Complete OAuth flow end-to-end +""" + +import base64 +import hashlib +import secrets +import uuid +from typing import AsyncGenerator + +import httpx +import pytest +from autogpt_libs.api_key.keysmith import APIKeySmith +from prisma.enums import APIKeyPermission +from prisma.models import OAuthAccessToken as PrismaOAuthAccessToken +from prisma.models import OAuthApplication as PrismaOAuthApplication +from prisma.models import OAuthAuthorizationCode as PrismaOAuthAuthorizationCode +from prisma.models import OAuthRefreshToken as PrismaOAuthRefreshToken +from prisma.models import User as PrismaUser + +from backend.server.rest_api import app + +keysmith = APIKeySmith() + + +# ============================================================================ +# Test Fixtures +# ============================================================================ + + +@pytest.fixture +def test_user_id() -> str: + """Test user ID for OAuth tests.""" + return str(uuid.uuid4()) + + +@pytest.fixture +async def test_user(server, test_user_id: str): + """Create a test user in the database.""" + await PrismaUser.prisma().create( + data={ + "id": test_user_id, + "email": f"oauth-test-{test_user_id}@example.com", + "name": "OAuth Test User", + } + ) + + yield test_user_id + + # Cleanup - delete in correct order due to foreign key constraints + await PrismaOAuthAccessToken.prisma().delete_many(where={"userId": test_user_id}) + await PrismaOAuthRefreshToken.prisma().delete_many(where={"userId": test_user_id}) + await PrismaOAuthAuthorizationCode.prisma().delete_many( + where={"userId": test_user_id} + ) + await PrismaOAuthApplication.prisma().delete_many(where={"ownerId": test_user_id}) + await PrismaUser.prisma().delete(where={"id": test_user_id}) + + +@pytest.fixture +async def test_oauth_app(test_user: str): + """Create a test OAuth application in the database.""" + app_id = str(uuid.uuid4()) + client_id = f"test_client_{secrets.token_urlsafe(8)}" + # Secret must start with "agpt_" prefix for keysmith verification to work + client_secret_plaintext = f"agpt_secret_{secrets.token_urlsafe(16)}" + client_secret_hash, client_secret_salt = keysmith.hash_key(client_secret_plaintext) + + await PrismaOAuthApplication.prisma().create( + data={ + "id": app_id, + "name": "Test OAuth App", + "description": "Test application for integration tests", + "clientId": client_id, + "clientSecret": client_secret_hash, + "clientSecretSalt": client_secret_salt, + "redirectUris": [ + "https://example.com/callback", + "http://localhost:3000/callback", + ], + "grantTypes": ["authorization_code", "refresh_token"], + "scopes": [APIKeyPermission.EXECUTE_GRAPH, APIKeyPermission.READ_GRAPH], + "ownerId": test_user, + "isActive": True, + } + ) + + yield { + "id": app_id, + "client_id": client_id, + "client_secret": client_secret_plaintext, + "redirect_uri": "https://example.com/callback", + } + + # Cleanup is handled by test_user fixture (cascade delete) + + +def generate_pkce() -> tuple[str, str]: + """Generate PKCE code verifier and challenge.""" + verifier = secrets.token_urlsafe(32) + challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()) + .decode("ascii") + .rstrip("=") + ) + return verifier, challenge + + +@pytest.fixture +def pkce_credentials() -> tuple[str, str]: + """Generate PKCE code verifier and challenge as a fixture.""" + return generate_pkce() + + +@pytest.fixture +async def client(server, test_user: str) -> AsyncGenerator[httpx.AsyncClient, None]: + """ + Create an async HTTP client that talks directly to the FastAPI app. + + Uses ASGI transport so we don't need an actual HTTP server running. + Also overrides get_user_id dependency to return our test user. + + Depends on `server` to ensure the DB is connected and `test_user` to ensure + the user exists in the database before running tests. + """ + from autogpt_libs.auth import get_user_id + + # Override get_user_id dependency to return our test user + def override_get_user_id(): + return test_user + + # Store original override if any + original_override = app.dependency_overrides.get(get_user_id) + + # Set our override + app.dependency_overrides[get_user_id] = override_get_user_id + + try: + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + ) as http_client: + yield http_client + finally: + # Restore original override + if original_override is not None: + app.dependency_overrides[get_user_id] = original_override + else: + app.dependency_overrides.pop(get_user_id, None) + + +# ============================================================================ +# Authorization Endpoint Integration Tests +# ============================================================================ + + +@pytest.mark.asyncio(loop_scope="session") +async def test_authorize_creates_code_in_database( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, + pkce_credentials: tuple[str, str], +): + """Test that authorization endpoint creates a code in the database.""" + verifier, challenge = pkce_credentials + + response = await client.post( + "/api/oauth/authorize", + json={ + "client_id": test_oauth_app["client_id"], + "redirect_uri": test_oauth_app["redirect_uri"], + "scopes": ["EXECUTE_GRAPH", "READ_GRAPH"], + "state": "test_state_123", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + + assert response.status_code == 200 + redirect_url = response.json()["redirect_url"] + + # Parse the redirect URL to get the authorization code + from urllib.parse import parse_qs, urlparse + + parsed = urlparse(redirect_url) + query_params = parse_qs(parsed.query) + + assert "code" in query_params, f"Expected 'code' in query params: {query_params}" + auth_code = query_params["code"][0] + assert query_params["state"][0] == "test_state_123" + + # Verify code exists in database + db_code = await PrismaOAuthAuthorizationCode.prisma().find_unique( + where={"code": auth_code} + ) + + assert db_code is not None + assert db_code.userId == test_user + assert db_code.applicationId == test_oauth_app["id"] + assert db_code.redirectUri == test_oauth_app["redirect_uri"] + assert APIKeyPermission.EXECUTE_GRAPH in db_code.scopes + assert APIKeyPermission.READ_GRAPH in db_code.scopes + assert db_code.usedAt is None # Not yet consumed + assert db_code.codeChallenge == challenge + assert db_code.codeChallengeMethod == "S256" + + +@pytest.mark.asyncio(loop_scope="session") +async def test_authorize_with_pkce_stores_challenge( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, + pkce_credentials: tuple[str, str], +): + """Test that PKCE code challenge is stored correctly.""" + verifier, challenge = pkce_credentials + + response = await client.post( + "/api/oauth/authorize", + json={ + "client_id": test_oauth_app["client_id"], + "redirect_uri": test_oauth_app["redirect_uri"], + "scopes": ["EXECUTE_GRAPH"], + "state": "pkce_test_state", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + + assert response.status_code == 200 + + from urllib.parse import parse_qs, urlparse + + auth_code = parse_qs(urlparse(response.json()["redirect_url"]).query)["code"][0] + + # Verify PKCE challenge is stored + db_code = await PrismaOAuthAuthorizationCode.prisma().find_unique( + where={"code": auth_code} + ) + + assert db_code is not None + assert db_code.codeChallenge == challenge + assert db_code.codeChallengeMethod == "S256" + + +@pytest.mark.asyncio(loop_scope="session") +async def test_authorize_invalid_client_returns_error( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, +): + """Test that invalid client_id returns error in redirect.""" + _, challenge = generate_pkce() + + response = await client.post( + "/api/oauth/authorize", + json={ + "client_id": "nonexistent_client_id", + "redirect_uri": test_oauth_app["redirect_uri"], + "scopes": ["EXECUTE_GRAPH"], + "state": "error_test", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + + assert response.status_code == 200 + from urllib.parse import parse_qs, urlparse + + query_params = parse_qs(urlparse(response.json()["redirect_url"]).query) + assert query_params["error"][0] == "invalid_client" + + +@pytest.fixture +async def inactive_oauth_app(test_user: str): + """Create an inactive test OAuth application in the database.""" + app_id = str(uuid.uuid4()) + client_id = f"inactive_client_{secrets.token_urlsafe(8)}" + client_secret_plaintext = f"agpt_secret_{secrets.token_urlsafe(16)}" + client_secret_hash, client_secret_salt = keysmith.hash_key(client_secret_plaintext) + + await PrismaOAuthApplication.prisma().create( + data={ + "id": app_id, + "name": "Inactive OAuth App", + "description": "Inactive test application", + "clientId": client_id, + "clientSecret": client_secret_hash, + "clientSecretSalt": client_secret_salt, + "redirectUris": ["https://example.com/callback"], + "grantTypes": ["authorization_code", "refresh_token"], + "scopes": [APIKeyPermission.EXECUTE_GRAPH], + "ownerId": test_user, + "isActive": False, # Inactive! + } + ) + + yield { + "id": app_id, + "client_id": client_id, + "client_secret": client_secret_plaintext, + "redirect_uri": "https://example.com/callback", + } + + +@pytest.mark.asyncio(loop_scope="session") +async def test_authorize_inactive_app( + client: httpx.AsyncClient, + test_user: str, + inactive_oauth_app: dict, +): + """Test that authorization with inactive app returns error.""" + _, challenge = generate_pkce() + + response = await client.post( + "/api/oauth/authorize", + json={ + "client_id": inactive_oauth_app["client_id"], + "redirect_uri": inactive_oauth_app["redirect_uri"], + "scopes": ["EXECUTE_GRAPH"], + "state": "inactive_test", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + + assert response.status_code == 200 + from urllib.parse import parse_qs, urlparse + + query_params = parse_qs(urlparse(response.json()["redirect_url"]).query) + assert query_params["error"][0] == "invalid_client" + + +@pytest.mark.asyncio(loop_scope="session") +async def test_authorize_invalid_redirect_uri( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, +): + """Test authorization with unregistered redirect_uri returns HTTP error.""" + _, challenge = generate_pkce() + + response = await client.post( + "/api/oauth/authorize", + json={ + "client_id": test_oauth_app["client_id"], + "redirect_uri": "https://malicious.com/callback", + "scopes": ["EXECUTE_GRAPH"], + "state": "invalid_redirect_test", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + + # Invalid redirect_uri should return HTTP 400, not a redirect + assert response.status_code == 400 + assert "redirect_uri" in response.json()["detail"].lower() + + +@pytest.mark.asyncio(loop_scope="session") +async def test_authorize_invalid_scope( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, +): + """Test authorization with invalid scope value.""" + _, challenge = generate_pkce() + + response = await client.post( + "/api/oauth/authorize", + json={ + "client_id": test_oauth_app["client_id"], + "redirect_uri": test_oauth_app["redirect_uri"], + "scopes": ["INVALID_SCOPE_NAME"], + "state": "invalid_scope_test", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + + assert response.status_code == 200 + from urllib.parse import parse_qs, urlparse + + query_params = parse_qs(urlparse(response.json()["redirect_url"]).query) + assert query_params["error"][0] == "invalid_scope" + + +@pytest.mark.asyncio(loop_scope="session") +async def test_authorize_unauthorized_scope( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, +): + """Test authorization requesting scope not authorized for app.""" + _, challenge = generate_pkce() + + # The test_oauth_app only has EXECUTE_GRAPH and READ_GRAPH scopes + # DELETE_GRAPH is not in the app's allowed scopes + response = await client.post( + "/api/oauth/authorize", + json={ + "client_id": test_oauth_app["client_id"], + "redirect_uri": test_oauth_app["redirect_uri"], + "scopes": ["DELETE_GRAPH"], # Not authorized for this app + "state": "unauthorized_scope_test", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + + assert response.status_code == 200 + from urllib.parse import parse_qs, urlparse + + query_params = parse_qs(urlparse(response.json()["redirect_url"]).query) + assert query_params["error"][0] == "invalid_scope" + + +@pytest.mark.asyncio(loop_scope="session") +async def test_authorize_unsupported_response_type( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, +): + """Test authorization with unsupported response_type.""" + _, challenge = generate_pkce() + + response = await client.post( + "/api/oauth/authorize", + json={ + "client_id": test_oauth_app["client_id"], + "redirect_uri": test_oauth_app["redirect_uri"], + "scopes": ["EXECUTE_GRAPH"], + "state": "unsupported_response_test", + "response_type": "token", # Implicit flow not supported + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + + assert response.status_code == 200 + from urllib.parse import parse_qs, urlparse + + query_params = parse_qs(urlparse(response.json()["redirect_url"]).query) + assert query_params["error"][0] == "unsupported_response_type" + + +# ============================================================================ +# Token Endpoint Integration Tests - Authorization Code Grant +# ============================================================================ + + +@pytest.mark.asyncio(loop_scope="session") +async def test_token_exchange_creates_tokens_in_database( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, +): + """Test that token exchange creates access and refresh tokens in database.""" + from urllib.parse import parse_qs, urlparse + + verifier, challenge = generate_pkce() + + # First get an authorization code + auth_response = await client.post( + "/api/oauth/authorize", + json={ + "client_id": test_oauth_app["client_id"], + "redirect_uri": test_oauth_app["redirect_uri"], + "scopes": ["EXECUTE_GRAPH", "READ_GRAPH"], + "state": "token_test_state", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + + auth_code = parse_qs(urlparse(auth_response.json()["redirect_url"]).query)["code"][ + 0 + ] + + # Exchange code for tokens + token_response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": test_oauth_app["redirect_uri"], + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + "code_verifier": verifier, + }, + ) + + assert token_response.status_code == 200 + tokens = token_response.json() + + assert "access_token" in tokens + assert "refresh_token" in tokens + assert tokens["token_type"] == "Bearer" + assert "EXECUTE_GRAPH" in tokens["scopes"] + assert "READ_GRAPH" in tokens["scopes"] + + # Verify access token exists in database (hashed) + access_token_hash = hashlib.sha256(tokens["access_token"].encode()).hexdigest() + db_access_token = await PrismaOAuthAccessToken.prisma().find_unique( + where={"token": access_token_hash} + ) + + assert db_access_token is not None + assert db_access_token.userId == test_user + assert db_access_token.applicationId == test_oauth_app["id"] + assert db_access_token.revokedAt is None + + # Verify refresh token exists in database (hashed) + refresh_token_hash = hashlib.sha256(tokens["refresh_token"].encode()).hexdigest() + db_refresh_token = await PrismaOAuthRefreshToken.prisma().find_unique( + where={"token": refresh_token_hash} + ) + + assert db_refresh_token is not None + assert db_refresh_token.userId == test_user + assert db_refresh_token.applicationId == test_oauth_app["id"] + assert db_refresh_token.revokedAt is None + + # Verify authorization code is marked as used + db_code = await PrismaOAuthAuthorizationCode.prisma().find_unique( + where={"code": auth_code} + ) + assert db_code is not None + assert db_code.usedAt is not None + + +@pytest.mark.asyncio(loop_scope="session") +async def test_authorization_code_cannot_be_reused( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, +): + """Test that authorization code can only be used once.""" + from urllib.parse import parse_qs, urlparse + + verifier, challenge = generate_pkce() + + # Get authorization code + auth_response = await client.post( + "/api/oauth/authorize", + json={ + "client_id": test_oauth_app["client_id"], + "redirect_uri": test_oauth_app["redirect_uri"], + "scopes": ["EXECUTE_GRAPH"], + "state": "reuse_test", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + + auth_code = parse_qs(urlparse(auth_response.json()["redirect_url"]).query)["code"][ + 0 + ] + + # First exchange - should succeed + first_response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": test_oauth_app["redirect_uri"], + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + "code_verifier": verifier, + }, + ) + assert first_response.status_code == 200 + + # Second exchange - should fail + second_response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": test_oauth_app["redirect_uri"], + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + "code_verifier": verifier, + }, + ) + assert second_response.status_code == 400 + assert "already used" in second_response.json()["detail"] + + +@pytest.mark.asyncio(loop_scope="session") +async def test_token_exchange_with_invalid_client_secret( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, +): + """Test that token exchange fails with invalid client secret.""" + from urllib.parse import parse_qs, urlparse + + verifier, challenge = generate_pkce() + + # Get authorization code + auth_response = await client.post( + "/api/oauth/authorize", + json={ + "client_id": test_oauth_app["client_id"], + "redirect_uri": test_oauth_app["redirect_uri"], + "scopes": ["EXECUTE_GRAPH"], + "state": "bad_secret_test", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + + auth_code = parse_qs(urlparse(auth_response.json()["redirect_url"]).query)["code"][ + 0 + ] + + # Try to exchange with wrong secret + response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": test_oauth_app["redirect_uri"], + "client_id": test_oauth_app["client_id"], + "client_secret": "wrong_secret", + "code_verifier": verifier, + }, + ) + + assert response.status_code == 401 + + +@pytest.mark.asyncio(loop_scope="session") +async def test_token_authorization_code_invalid_code( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, +): + """Test token exchange with invalid/nonexistent authorization code.""" + response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "authorization_code", + "code": "nonexistent_invalid_code_xyz", + "redirect_uri": test_oauth_app["redirect_uri"], + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + "code_verifier": "", + }, + ) + + assert response.status_code == 400 + assert "not found" in response.json()["detail"].lower() + + +@pytest.mark.asyncio(loop_scope="session") +async def test_token_authorization_code_expired( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, +): + """Test token exchange with expired authorization code.""" + from datetime import datetime, timedelta, timezone + + # Create an expired authorization code directly in the database + expired_code = f"expired_code_{secrets.token_urlsafe(16)}" + now = datetime.now(timezone.utc) + + await PrismaOAuthAuthorizationCode.prisma().create( + data={ + "code": expired_code, + "applicationId": test_oauth_app["id"], + "userId": test_user, + "scopes": [APIKeyPermission.EXECUTE_GRAPH], + "redirectUri": test_oauth_app["redirect_uri"], + "expiresAt": now - timedelta(hours=1), # Already expired + } + ) + + response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "authorization_code", + "code": expired_code, + "redirect_uri": test_oauth_app["redirect_uri"], + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + "code_verifier": "", + }, + ) + + assert response.status_code == 400 + assert "expired" in response.json()["detail"].lower() + + +@pytest.mark.asyncio(loop_scope="session") +async def test_token_authorization_code_redirect_uri_mismatch( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, +): + """Test token exchange with mismatched redirect_uri.""" + from urllib.parse import parse_qs, urlparse + + verifier, challenge = generate_pkce() + + # Get authorization code with one redirect_uri + auth_response = await client.post( + "/api/oauth/authorize", + json={ + "client_id": test_oauth_app["client_id"], + "redirect_uri": test_oauth_app["redirect_uri"], + "scopes": ["EXECUTE_GRAPH"], + "state": "redirect_mismatch_test", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + + auth_code = parse_qs(urlparse(auth_response.json()["redirect_url"]).query)["code"][ + 0 + ] + + # Try to exchange with different redirect_uri + # Note: localhost:3000 is in the app's registered redirect_uris + response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "authorization_code", + "code": auth_code, + # Different redirect_uri from authorization request + "redirect_uri": "http://localhost:3000/callback", + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + "code_verifier": verifier, + }, + ) + + assert response.status_code == 400 + assert "redirect_uri" in response.json()["detail"].lower() + + +@pytest.mark.asyncio(loop_scope="session") +async def test_token_authorization_code_pkce_failure( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, + pkce_credentials: tuple[str, str], +): + """Test token exchange with PKCE verification failure (wrong verifier).""" + from urllib.parse import parse_qs, urlparse + + verifier, challenge = pkce_credentials + + # Get authorization code with PKCE challenge + auth_response = await client.post( + "/api/oauth/authorize", + json={ + "client_id": test_oauth_app["client_id"], + "redirect_uri": test_oauth_app["redirect_uri"], + "scopes": ["EXECUTE_GRAPH"], + "state": "pkce_failure_test", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + + auth_code = parse_qs(urlparse(auth_response.json()["redirect_url"]).query)["code"][ + 0 + ] + + # Try to exchange with wrong verifier + response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": test_oauth_app["redirect_uri"], + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + "code_verifier": "wrong_verifier_that_does_not_match", + }, + ) + + assert response.status_code == 400 + assert "pkce" in response.json()["detail"].lower() + + +# ============================================================================ +# Token Endpoint Integration Tests - Refresh Token Grant +# ============================================================================ + + +@pytest.mark.asyncio(loop_scope="session") +async def test_refresh_token_creates_new_tokens( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, +): + """Test that refresh token grant creates new access and refresh tokens.""" + from urllib.parse import parse_qs, urlparse + + verifier, challenge = generate_pkce() + + # Get initial tokens + auth_response = await client.post( + "/api/oauth/authorize", + json={ + "client_id": test_oauth_app["client_id"], + "redirect_uri": test_oauth_app["redirect_uri"], + "scopes": ["EXECUTE_GRAPH"], + "state": "refresh_test", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + + auth_code = parse_qs(urlparse(auth_response.json()["redirect_url"]).query)["code"][ + 0 + ] + + initial_response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": test_oauth_app["redirect_uri"], + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + "code_verifier": verifier, + }, + ) + initial_tokens = initial_response.json() + + # Use refresh token to get new tokens + refresh_response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "refresh_token", + "refresh_token": initial_tokens["refresh_token"], + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + }, + ) + + assert refresh_response.status_code == 200 + new_tokens = refresh_response.json() + + # Tokens should be different + assert new_tokens["access_token"] != initial_tokens["access_token"] + assert new_tokens["refresh_token"] != initial_tokens["refresh_token"] + + # Old refresh token should be revoked in database + old_refresh_hash = hashlib.sha256( + initial_tokens["refresh_token"].encode() + ).hexdigest() + old_db_token = await PrismaOAuthRefreshToken.prisma().find_unique( + where={"token": old_refresh_hash} + ) + assert old_db_token is not None + assert old_db_token.revokedAt is not None + + # New tokens should exist and be valid + new_access_hash = hashlib.sha256(new_tokens["access_token"].encode()).hexdigest() + new_db_access = await PrismaOAuthAccessToken.prisma().find_unique( + where={"token": new_access_hash} + ) + assert new_db_access is not None + assert new_db_access.revokedAt is None + + +@pytest.mark.asyncio(loop_scope="session") +async def test_token_refresh_invalid_token( + client: httpx.AsyncClient, + test_oauth_app: dict, +): + """Test token refresh with invalid/nonexistent refresh token.""" + response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "refresh_token", + "refresh_token": "completely_invalid_refresh_token_xyz", + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + }, + ) + + assert response.status_code == 400 + assert "not found" in response.json()["detail"].lower() + + +@pytest.mark.asyncio(loop_scope="session") +async def test_token_refresh_expired( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, +): + """Test token refresh with expired refresh token.""" + from datetime import datetime, timedelta, timezone + + # Create an expired refresh token directly in the database + expired_token_value = f"expired_refresh_{secrets.token_urlsafe(16)}" + expired_token_hash = hashlib.sha256(expired_token_value.encode()).hexdigest() + now = datetime.now(timezone.utc) + + await PrismaOAuthRefreshToken.prisma().create( + data={ + "token": expired_token_hash, + "applicationId": test_oauth_app["id"], + "userId": test_user, + "scopes": [APIKeyPermission.EXECUTE_GRAPH], + "expiresAt": now - timedelta(days=1), # Already expired + } + ) + + response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "refresh_token", + "refresh_token": expired_token_value, + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + }, + ) + + assert response.status_code == 400 + assert "expired" in response.json()["detail"].lower() + + +@pytest.mark.asyncio(loop_scope="session") +async def test_token_refresh_revoked( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, +): + """Test token refresh with revoked refresh token.""" + from datetime import datetime, timedelta, timezone + + # Create a revoked refresh token directly in the database + revoked_token_value = f"revoked_refresh_{secrets.token_urlsafe(16)}" + revoked_token_hash = hashlib.sha256(revoked_token_value.encode()).hexdigest() + now = datetime.now(timezone.utc) + + await PrismaOAuthRefreshToken.prisma().create( + data={ + "token": revoked_token_hash, + "applicationId": test_oauth_app["id"], + "userId": test_user, + "scopes": [APIKeyPermission.EXECUTE_GRAPH], + "expiresAt": now + timedelta(days=30), # Not expired + "revokedAt": now - timedelta(hours=1), # But revoked + } + ) + + response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "refresh_token", + "refresh_token": revoked_token_value, + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + }, + ) + + assert response.status_code == 400 + assert "revoked" in response.json()["detail"].lower() + + +@pytest.fixture +async def other_oauth_app(test_user: str): + """Create a second OAuth application for cross-app tests.""" + app_id = str(uuid.uuid4()) + client_id = f"other_client_{secrets.token_urlsafe(8)}" + client_secret_plaintext = f"agpt_other_{secrets.token_urlsafe(16)}" + client_secret_hash, client_secret_salt = keysmith.hash_key(client_secret_plaintext) + + await PrismaOAuthApplication.prisma().create( + data={ + "id": app_id, + "name": "Other OAuth App", + "description": "Second test application", + "clientId": client_id, + "clientSecret": client_secret_hash, + "clientSecretSalt": client_secret_salt, + "redirectUris": ["https://other.example.com/callback"], + "grantTypes": ["authorization_code", "refresh_token"], + "scopes": [APIKeyPermission.EXECUTE_GRAPH], + "ownerId": test_user, + "isActive": True, + } + ) + + yield { + "id": app_id, + "client_id": client_id, + "client_secret": client_secret_plaintext, + "redirect_uri": "https://other.example.com/callback", + } + + +@pytest.mark.asyncio(loop_scope="session") +async def test_token_refresh_wrong_application( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, + other_oauth_app: dict, +): + """Test token refresh with token from different application.""" + from datetime import datetime, timedelta, timezone + + # Create a refresh token for `test_oauth_app` + token_value = f"app1_refresh_{secrets.token_urlsafe(16)}" + token_hash = hashlib.sha256(token_value.encode()).hexdigest() + now = datetime.now(timezone.utc) + + await PrismaOAuthRefreshToken.prisma().create( + data={ + "token": token_hash, + "applicationId": test_oauth_app["id"], # Belongs to test_oauth_app + "userId": test_user, + "scopes": [APIKeyPermission.EXECUTE_GRAPH], + "expiresAt": now + timedelta(days=30), + } + ) + + # Try to use it with `other_oauth_app` + response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "refresh_token", + "refresh_token": token_value, + "client_id": other_oauth_app["client_id"], + "client_secret": other_oauth_app["client_secret"], + }, + ) + + assert response.status_code == 400 + assert "does not belong" in response.json()["detail"].lower() + + +# ============================================================================ +# Token Introspection Integration Tests +# ============================================================================ + + +@pytest.mark.asyncio(loop_scope="session") +async def test_introspect_valid_access_token( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, +): + """Test introspection returns correct info for valid access token.""" + from urllib.parse import parse_qs, urlparse + + verifier, challenge = generate_pkce() + + # Get tokens + auth_response = await client.post( + "/api/oauth/authorize", + json={ + "client_id": test_oauth_app["client_id"], + "redirect_uri": test_oauth_app["redirect_uri"], + "scopes": ["EXECUTE_GRAPH", "READ_GRAPH"], + "state": "introspect_test", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + + auth_code = parse_qs(urlparse(auth_response.json()["redirect_url"]).query)["code"][ + 0 + ] + + token_response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": test_oauth_app["redirect_uri"], + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + "code_verifier": verifier, + }, + ) + tokens = token_response.json() + + # Introspect the access token + introspect_response = await client.post( + "/api/oauth/introspect", + json={ + "token": tokens["access_token"], + "token_type_hint": "access_token", + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + }, + ) + + assert introspect_response.status_code == 200 + data = introspect_response.json() + + assert data["active"] is True + assert data["token_type"] == "access_token" + assert data["user_id"] == test_user + assert data["client_id"] == test_oauth_app["client_id"] + assert "EXECUTE_GRAPH" in data["scopes"] + assert "READ_GRAPH" in data["scopes"] + + +@pytest.mark.asyncio(loop_scope="session") +async def test_introspect_invalid_token_returns_inactive( + client: httpx.AsyncClient, + test_oauth_app: dict, +): + """Test introspection returns inactive for non-existent token.""" + introspect_response = await client.post( + "/api/oauth/introspect", + json={ + "token": "completely_invalid_token_that_does_not_exist", + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + }, + ) + + assert introspect_response.status_code == 200 + assert introspect_response.json()["active"] is False + + +@pytest.mark.asyncio(loop_scope="session") +async def test_introspect_active_refresh_token( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, +): + """Test introspection returns correct info for valid refresh token.""" + from urllib.parse import parse_qs, urlparse + + verifier, challenge = generate_pkce() + + # Get tokens via the full flow + auth_response = await client.post( + "/api/oauth/authorize", + json={ + "client_id": test_oauth_app["client_id"], + "redirect_uri": test_oauth_app["redirect_uri"], + "scopes": ["EXECUTE_GRAPH", "READ_GRAPH"], + "state": "introspect_refresh_test", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + + auth_code = parse_qs(urlparse(auth_response.json()["redirect_url"]).query)["code"][ + 0 + ] + + token_response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": test_oauth_app["redirect_uri"], + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + "code_verifier": verifier, + }, + ) + tokens = token_response.json() + + # Introspect the refresh token + introspect_response = await client.post( + "/api/oauth/introspect", + json={ + "token": tokens["refresh_token"], + "token_type_hint": "refresh_token", + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + }, + ) + + assert introspect_response.status_code == 200 + data = introspect_response.json() + + assert data["active"] is True + assert data["token_type"] == "refresh_token" + assert data["user_id"] == test_user + assert data["client_id"] == test_oauth_app["client_id"] + + +@pytest.mark.asyncio(loop_scope="session") +async def test_introspect_invalid_client( + client: httpx.AsyncClient, + test_oauth_app: dict, +): + """Test introspection with invalid client credentials.""" + introspect_response = await client.post( + "/api/oauth/introspect", + json={ + "token": "some_token", + "client_id": test_oauth_app["client_id"], + "client_secret": "wrong_secret_value", + }, + ) + + assert introspect_response.status_code == 401 + + +@pytest.mark.asyncio(loop_scope="session") +async def test_validate_access_token_fails_when_app_disabled( + test_user: str, +): + """ + Test that validate_access_token raises InvalidClientError when the app is disabled. + + This tests the security feature where disabling an OAuth application + immediately invalidates all its access tokens. + """ + from datetime import datetime, timedelta, timezone + + from backend.data.auth.oauth import InvalidClientError, validate_access_token + + # Create an OAuth app + app_id = str(uuid.uuid4()) + client_id = f"disable_test_{secrets.token_urlsafe(8)}" + client_secret_plaintext = f"agpt_disable_{secrets.token_urlsafe(16)}" + client_secret_hash, client_secret_salt = keysmith.hash_key(client_secret_plaintext) + + await PrismaOAuthApplication.prisma().create( + data={ + "id": app_id, + "name": "App To Be Disabled", + "description": "Test app for disabled validation", + "clientId": client_id, + "clientSecret": client_secret_hash, + "clientSecretSalt": client_secret_salt, + "redirectUris": ["https://example.com/callback"], + "grantTypes": ["authorization_code"], + "scopes": [APIKeyPermission.EXECUTE_GRAPH], + "ownerId": test_user, + "isActive": True, + } + ) + + # Create an access token directly in the database + token_plaintext = f"test_token_{secrets.token_urlsafe(32)}" + token_hash = hashlib.sha256(token_plaintext.encode()).hexdigest() + now = datetime.now(timezone.utc) + + await PrismaOAuthAccessToken.prisma().create( + data={ + "token": token_hash, + "applicationId": app_id, + "userId": test_user, + "scopes": [APIKeyPermission.EXECUTE_GRAPH], + "expiresAt": now + timedelta(hours=1), + } + ) + + # Token should be valid while app is active + token_info, _ = await validate_access_token(token_plaintext) + assert token_info.user_id == test_user + + # Disable the app + await PrismaOAuthApplication.prisma().update( + where={"id": app_id}, + data={"isActive": False}, + ) + + # Token should now fail validation with InvalidClientError + with pytest.raises(InvalidClientError, match="disabled"): + await validate_access_token(token_plaintext) + + # Cleanup + await PrismaOAuthApplication.prisma().delete(where={"id": app_id}) + + +# ============================================================================ +# Token Revocation Integration Tests +# ============================================================================ + + +@pytest.mark.asyncio(loop_scope="session") +async def test_revoke_access_token_updates_database( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, +): + """Test that revoking access token updates database.""" + from urllib.parse import parse_qs, urlparse + + verifier, challenge = generate_pkce() + + # Get tokens + auth_response = await client.post( + "/api/oauth/authorize", + json={ + "client_id": test_oauth_app["client_id"], + "redirect_uri": test_oauth_app["redirect_uri"], + "scopes": ["EXECUTE_GRAPH"], + "state": "revoke_access_test", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + + auth_code = parse_qs(urlparse(auth_response.json()["redirect_url"]).query)["code"][ + 0 + ] + + token_response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": test_oauth_app["redirect_uri"], + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + "code_verifier": verifier, + }, + ) + tokens = token_response.json() + + # Verify token is not revoked in database + access_hash = hashlib.sha256(tokens["access_token"].encode()).hexdigest() + db_token_before = await PrismaOAuthAccessToken.prisma().find_unique( + where={"token": access_hash} + ) + assert db_token_before is not None + assert db_token_before.revokedAt is None + + # Revoke the token + revoke_response = await client.post( + "/api/oauth/revoke", + json={ + "token": tokens["access_token"], + "token_type_hint": "access_token", + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + }, + ) + + assert revoke_response.status_code == 200 + assert revoke_response.json()["status"] == "ok" + + # Verify token is now revoked in database + db_token_after = await PrismaOAuthAccessToken.prisma().find_unique( + where={"token": access_hash} + ) + assert db_token_after is not None + assert db_token_after.revokedAt is not None + + +@pytest.mark.asyncio(loop_scope="session") +async def test_revoke_unknown_token_returns_ok( + client: httpx.AsyncClient, + test_oauth_app: dict, +): + """Test that revoking unknown token returns 200 (per RFC 7009).""" + revoke_response = await client.post( + "/api/oauth/revoke", + json={ + "token": "unknown_token_that_does_not_exist_anywhere", + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + }, + ) + + # Per RFC 7009, should return 200 even for unknown tokens + assert revoke_response.status_code == 200 + assert revoke_response.json()["status"] == "ok" + + +@pytest.mark.asyncio(loop_scope="session") +async def test_revoke_refresh_token_updates_database( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, +): + """Test that revoking refresh token updates database.""" + from urllib.parse import parse_qs, urlparse + + verifier, challenge = generate_pkce() + + # Get tokens + auth_response = await client.post( + "/api/oauth/authorize", + json={ + "client_id": test_oauth_app["client_id"], + "redirect_uri": test_oauth_app["redirect_uri"], + "scopes": ["EXECUTE_GRAPH"], + "state": "revoke_refresh_test", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + + auth_code = parse_qs(urlparse(auth_response.json()["redirect_url"]).query)["code"][ + 0 + ] + + token_response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": test_oauth_app["redirect_uri"], + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + "code_verifier": verifier, + }, + ) + tokens = token_response.json() + + # Verify refresh token is not revoked in database + refresh_hash = hashlib.sha256(tokens["refresh_token"].encode()).hexdigest() + db_token_before = await PrismaOAuthRefreshToken.prisma().find_unique( + where={"token": refresh_hash} + ) + assert db_token_before is not None + assert db_token_before.revokedAt is None + + # Revoke the refresh token + revoke_response = await client.post( + "/api/oauth/revoke", + json={ + "token": tokens["refresh_token"], + "token_type_hint": "refresh_token", + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + }, + ) + + assert revoke_response.status_code == 200 + assert revoke_response.json()["status"] == "ok" + + # Verify refresh token is now revoked in database + db_token_after = await PrismaOAuthRefreshToken.prisma().find_unique( + where={"token": refresh_hash} + ) + assert db_token_after is not None + assert db_token_after.revokedAt is not None + + +@pytest.mark.asyncio(loop_scope="session") +async def test_revoke_invalid_client( + client: httpx.AsyncClient, + test_oauth_app: dict, +): + """Test revocation with invalid client credentials.""" + revoke_response = await client.post( + "/api/oauth/revoke", + json={ + "token": "some_token", + "client_id": test_oauth_app["client_id"], + "client_secret": "wrong_secret_value", + }, + ) + + assert revoke_response.status_code == 401 + + +@pytest.mark.asyncio(loop_scope="session") +async def test_revoke_token_from_different_app_fails_silently( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, +): + """ + Test that an app cannot revoke tokens belonging to a different app. + + Per RFC 7009, the endpoint still returns 200 OK (to prevent token scanning), + but the token should remain valid in the database. + """ + from urllib.parse import parse_qs, urlparse + + verifier, challenge = generate_pkce() + + # Get tokens for app 1 + auth_response = await client.post( + "/api/oauth/authorize", + json={ + "client_id": test_oauth_app["client_id"], + "redirect_uri": test_oauth_app["redirect_uri"], + "scopes": ["EXECUTE_GRAPH"], + "state": "cross_app_revoke_test", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + + auth_code = parse_qs(urlparse(auth_response.json()["redirect_url"]).query)["code"][ + 0 + ] + + token_response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": test_oauth_app["redirect_uri"], + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + "code_verifier": verifier, + }, + ) + tokens = token_response.json() + + # Create a second OAuth app + app2_id = str(uuid.uuid4()) + app2_client_id = f"test_client_app2_{secrets.token_urlsafe(8)}" + app2_client_secret_plaintext = f"agpt_secret_app2_{secrets.token_urlsafe(16)}" + app2_client_secret_hash, app2_client_secret_salt = keysmith.hash_key( + app2_client_secret_plaintext + ) + + await PrismaOAuthApplication.prisma().create( + data={ + "id": app2_id, + "name": "Second Test OAuth App", + "description": "Second test application for cross-app revocation test", + "clientId": app2_client_id, + "clientSecret": app2_client_secret_hash, + "clientSecretSalt": app2_client_secret_salt, + "redirectUris": ["https://other-app.com/callback"], + "grantTypes": ["authorization_code", "refresh_token"], + "scopes": [APIKeyPermission.EXECUTE_GRAPH, APIKeyPermission.READ_GRAPH], + "ownerId": test_user, + "isActive": True, + } + ) + + # App 2 tries to revoke App 1's access token + revoke_response = await client.post( + "/api/oauth/revoke", + json={ + "token": tokens["access_token"], + "token_type_hint": "access_token", + "client_id": app2_client_id, + "client_secret": app2_client_secret_plaintext, + }, + ) + + # Per RFC 7009, returns 200 OK even if token not found/not owned + assert revoke_response.status_code == 200 + assert revoke_response.json()["status"] == "ok" + + # But the token should NOT be revoked in the database + access_hash = hashlib.sha256(tokens["access_token"].encode()).hexdigest() + db_token = await PrismaOAuthAccessToken.prisma().find_unique( + where={"token": access_hash} + ) + assert db_token is not None + assert db_token.revokedAt is None, "Token should NOT be revoked by different app" + + # Now app 1 revokes its own token - should work + revoke_response2 = await client.post( + "/api/oauth/revoke", + json={ + "token": tokens["access_token"], + "token_type_hint": "access_token", + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + }, + ) + + assert revoke_response2.status_code == 200 + + # Token should now be revoked + db_token_after = await PrismaOAuthAccessToken.prisma().find_unique( + where={"token": access_hash} + ) + assert db_token_after is not None + assert db_token_after.revokedAt is not None, "Token should be revoked by own app" + + # Cleanup second app + await PrismaOAuthApplication.prisma().delete(where={"id": app2_id}) + + +# ============================================================================ +# Complete End-to-End OAuth Flow Test +# ============================================================================ + + +@pytest.mark.asyncio(loop_scope="session") +async def test_complete_oauth_flow_end_to_end( + client: httpx.AsyncClient, + test_user: str, + test_oauth_app: dict, + pkce_credentials: tuple[str, str], +): + """ + Test the complete OAuth 2.0 flow from authorization to token refresh. + + This is a comprehensive integration test that verifies the entire + OAuth flow works correctly with real API calls and database operations. + """ + from urllib.parse import parse_qs, urlparse + + verifier, challenge = pkce_credentials + + # Step 1: Authorization request with PKCE + auth_response = await client.post( + "/api/oauth/authorize", + json={ + "client_id": test_oauth_app["client_id"], + "redirect_uri": test_oauth_app["redirect_uri"], + "scopes": ["EXECUTE_GRAPH", "READ_GRAPH"], + "state": "e2e_test_state", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + + assert auth_response.status_code == 200 + + redirect_url = auth_response.json()["redirect_url"] + query = parse_qs(urlparse(redirect_url).query) + + assert query["state"][0] == "e2e_test_state" + auth_code = query["code"][0] + + # Verify authorization code in database + db_code = await PrismaOAuthAuthorizationCode.prisma().find_unique( + where={"code": auth_code} + ) + assert db_code is not None + assert db_code.codeChallenge == challenge + + # Step 2: Exchange code for tokens + token_response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": test_oauth_app["redirect_uri"], + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + "code_verifier": verifier, + }, + ) + + assert token_response.status_code == 200 + tokens = token_response.json() + assert "access_token" in tokens + assert "refresh_token" in tokens + + # Verify code is marked as used + db_code_used = await PrismaOAuthAuthorizationCode.prisma().find_unique_or_raise( + where={"code": auth_code} + ) + assert db_code_used.usedAt is not None + + # Step 3: Introspect access token + introspect_response = await client.post( + "/api/oauth/introspect", + json={ + "token": tokens["access_token"], + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + }, + ) + + assert introspect_response.status_code == 200 + introspect_data = introspect_response.json() + assert introspect_data["active"] is True + assert introspect_data["user_id"] == test_user + + # Step 4: Refresh tokens + refresh_response = await client.post( + "/api/oauth/token", + json={ + "grant_type": "refresh_token", + "refresh_token": tokens["refresh_token"], + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + }, + ) + + assert refresh_response.status_code == 200 + new_tokens = refresh_response.json() + assert new_tokens["access_token"] != tokens["access_token"] + assert new_tokens["refresh_token"] != tokens["refresh_token"] + + # Verify old refresh token is revoked + old_refresh_hash = hashlib.sha256(tokens["refresh_token"].encode()).hexdigest() + old_db_refresh = await PrismaOAuthRefreshToken.prisma().find_unique_or_raise( + where={"token": old_refresh_hash} + ) + assert old_db_refresh.revokedAt is not None + + # Step 5: Verify new access token works + new_introspect = await client.post( + "/api/oauth/introspect", + json={ + "token": new_tokens["access_token"], + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + }, + ) + + assert new_introspect.status_code == 200 + assert new_introspect.json()["active"] is True + + # Step 6: Revoke new access token + revoke_response = await client.post( + "/api/oauth/revoke", + json={ + "token": new_tokens["access_token"], + "token_type_hint": "access_token", + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + }, + ) + + assert revoke_response.status_code == 200 + + # Step 7: Verify revoked token is inactive + final_introspect = await client.post( + "/api/oauth/introspect", + json={ + "token": new_tokens["access_token"], + "client_id": test_oauth_app["client_id"], + "client_secret": test_oauth_app["client_secret"], + }, + ) + + assert final_introspect.status_code == 200 + assert final_introspect.json()["active"] is False + + # Verify in database + new_access_hash = hashlib.sha256(new_tokens["access_token"].encode()).hexdigest() + db_revoked = await PrismaOAuthAccessToken.prisma().find_unique_or_raise( + where={"token": new_access_hash} + ) + assert db_revoked.revokedAt is not None diff --git a/autogpt_platform/backend/backend/server/routers/v1.py b/autogpt_platform/backend/backend/server/routers/v1.py index d74d4ecdf7..e5e74690f8 100644 --- a/autogpt_platform/backend/backend/server/routers/v1.py +++ b/autogpt_platform/backend/backend/server/routers/v1.py @@ -31,9 +31,9 @@ from typing_extensions import Optional, TypedDict import backend.server.integrations.router import backend.server.routers.analytics import backend.server.v2.library.db as library_db -from backend.data import api_key as api_key_db from backend.data import execution as execution_db from backend.data import graph as graph_db +from backend.data.auth import api_key as api_key_db from backend.data.block import BlockInput, CompletedBlockOutput, get_block, get_blocks from backend.data.credit import ( AutoTopUpConfig, diff --git a/autogpt_platform/backend/backend/util/settings.py b/autogpt_platform/backend/backend/util/settings.py index 4eb45dc972..0f17b1215c 100644 --- a/autogpt_platform/backend/backend/util/settings.py +++ b/autogpt_platform/backend/backend/util/settings.py @@ -362,6 +362,13 @@ class Config(UpdateTrackingModel["Config"], BaseSettings): description="Hours between cloud storage cleanup runs (1-24 hours)", ) + oauth_token_cleanup_interval_hours: int = Field( + default=6, + ge=1, + le=24, + description="Hours between OAuth token cleanup runs (1-24 hours)", + ) + upload_file_size_limit_mb: int = Field( default=256, ge=1, diff --git a/autogpt_platform/backend/migrations/20251212165920_add_oauth_provider_support/migration.sql b/autogpt_platform/backend/migrations/20251212165920_add_oauth_provider_support/migration.sql new file mode 100644 index 0000000000..9c8672c4c3 --- /dev/null +++ b/autogpt_platform/backend/migrations/20251212165920_add_oauth_provider_support/migration.sql @@ -0,0 +1,129 @@ +-- CreateTable +CREATE TABLE "OAuthApplication" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "clientId" TEXT NOT NULL, + "clientSecret" TEXT NOT NULL, + "clientSecretSalt" TEXT NOT NULL, + "redirectUris" TEXT[], + "grantTypes" TEXT[] DEFAULT ARRAY['authorization_code', 'refresh_token']::TEXT[], + "scopes" "APIKeyPermission"[], + "ownerId" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "OAuthApplication_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OAuthAuthorizationCode" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "applicationId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "scopes" "APIKeyPermission"[], + "redirectUri" TEXT NOT NULL, + "codeChallenge" TEXT, + "codeChallengeMethod" TEXT, + "usedAt" TIMESTAMP(3), + + CONSTRAINT "OAuthAuthorizationCode_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OAuthAccessToken" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "applicationId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "scopes" "APIKeyPermission"[], + "revokedAt" TIMESTAMP(3), + + CONSTRAINT "OAuthAccessToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OAuthRefreshToken" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "applicationId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "scopes" "APIKeyPermission"[], + "revokedAt" TIMESTAMP(3), + + CONSTRAINT "OAuthRefreshToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "OAuthApplication_clientId_key" ON "OAuthApplication"("clientId"); + +-- CreateIndex +CREATE INDEX "OAuthApplication_clientId_idx" ON "OAuthApplication"("clientId"); + +-- CreateIndex +CREATE INDEX "OAuthApplication_ownerId_idx" ON "OAuthApplication"("ownerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "OAuthAuthorizationCode_code_key" ON "OAuthAuthorizationCode"("code"); + +-- CreateIndex +CREATE INDEX "OAuthAuthorizationCode_code_idx" ON "OAuthAuthorizationCode"("code"); + +-- CreateIndex +CREATE INDEX "OAuthAuthorizationCode_applicationId_userId_idx" ON "OAuthAuthorizationCode"("applicationId", "userId"); + +-- CreateIndex +CREATE INDEX "OAuthAuthorizationCode_expiresAt_idx" ON "OAuthAuthorizationCode"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "OAuthAccessToken_token_key" ON "OAuthAccessToken"("token"); + +-- CreateIndex +CREATE INDEX "OAuthAccessToken_token_idx" ON "OAuthAccessToken"("token"); + +-- CreateIndex +CREATE INDEX "OAuthAccessToken_userId_applicationId_idx" ON "OAuthAccessToken"("userId", "applicationId"); + +-- CreateIndex +CREATE INDEX "OAuthAccessToken_expiresAt_idx" ON "OAuthAccessToken"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "OAuthRefreshToken_token_key" ON "OAuthRefreshToken"("token"); + +-- CreateIndex +CREATE INDEX "OAuthRefreshToken_token_idx" ON "OAuthRefreshToken"("token"); + +-- CreateIndex +CREATE INDEX "OAuthRefreshToken_userId_applicationId_idx" ON "OAuthRefreshToken"("userId", "applicationId"); + +-- CreateIndex +CREATE INDEX "OAuthRefreshToken_expiresAt_idx" ON "OAuthRefreshToken"("expiresAt"); + +-- AddForeignKey +ALTER TABLE "OAuthApplication" ADD CONSTRAINT "OAuthApplication_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthAuthorizationCode" ADD CONSTRAINT "OAuthAuthorizationCode_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "OAuthApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthAuthorizationCode" ADD CONSTRAINT "OAuthAuthorizationCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthAccessToken" ADD CONSTRAINT "OAuthAccessToken_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "OAuthApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthAccessToken" ADD CONSTRAINT "OAuthAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "OAuthApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/autogpt_platform/backend/migrations/20251218231330_add_oauth_app_logo/migration.sql b/autogpt_platform/backend/migrations/20251218231330_add_oauth_app_logo/migration.sql new file mode 100644 index 0000000000..c9c8c76df1 --- /dev/null +++ b/autogpt_platform/backend/migrations/20251218231330_add_oauth_app_logo/migration.sql @@ -0,0 +1,5 @@ +-- AlterEnum +ALTER TYPE "APIKeyPermission" ADD VALUE 'IDENTITY'; + +-- AlterTable +ALTER TABLE "OAuthApplication" ADD COLUMN "logoUrl" TEXT; diff --git a/autogpt_platform/backend/pyproject.toml b/autogpt_platform/backend/pyproject.toml index a87ae8e71d..fb06b65162 100644 --- a/autogpt_platform/backend/pyproject.toml +++ b/autogpt_platform/backend/pyproject.toml @@ -115,6 +115,8 @@ format = "linter:format" lint = "linter:lint" test = "run_tests:test" load-store-agents = "test.load_store_agents:run" +export-api-schema = "backend.cli.generate_openapi_json:main" +oauth-tool = "backend.cli.oauth_tool:cli" [tool.isort] profile = "black" diff --git a/autogpt_platform/backend/schema.prisma b/autogpt_platform/backend/schema.prisma index 121ccab5fc..d81cd4d1b1 100644 --- a/autogpt_platform/backend/schema.prisma +++ b/autogpt_platform/backend/schema.prisma @@ -61,6 +61,12 @@ model User { IntegrationWebhooks IntegrationWebhook[] NotificationBatches UserNotificationBatch[] PendingHumanReviews PendingHumanReview[] + + // OAuth Provider relations + OAuthApplications OAuthApplication[] + OAuthAuthorizationCodes OAuthAuthorizationCode[] + OAuthAccessTokens OAuthAccessToken[] + OAuthRefreshTokens OAuthRefreshToken[] } enum OnboardingStep { @@ -924,6 +930,7 @@ enum SubmissionStatus { } enum APIKeyPermission { + IDENTITY // Info about the authenticated user EXECUTE_GRAPH // Can execute agent graphs READ_GRAPH // Can get graph versions and details EXECUTE_BLOCK // Can execute individual blocks @@ -975,3 +982,113 @@ enum APIKeyStatus { REVOKED SUSPENDED } + +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// +////////////// OAUTH PROVIDER TABLES ////////////////// +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// + +// OAuth2 applications that can access AutoGPT on behalf of users +model OAuthApplication { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Application metadata + name String + description String? + logoUrl String? // URL to app logo stored in GCS + clientId String @unique + clientSecret String // Hashed with Scrypt (same as API keys) + clientSecretSalt String // Salt for Scrypt hashing + + // OAuth configuration + redirectUris String[] // Allowed callback URLs + grantTypes String[] @default(["authorization_code", "refresh_token"]) + scopes APIKeyPermission[] // Which permissions the app can request + + // Application management + ownerId String + Owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + isActive Boolean @default(true) + + // Relations + AuthorizationCodes OAuthAuthorizationCode[] + AccessTokens OAuthAccessToken[] + RefreshTokens OAuthRefreshToken[] + + @@index([clientId]) + @@index([ownerId]) +} + +// Temporary authorization codes (10 min TTL) +model OAuthAuthorizationCode { + id String @id @default(uuid()) + code String @unique + createdAt DateTime @default(now()) + expiresAt DateTime // Now + 10 minutes + + applicationId String + Application OAuthApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) + + userId String + User User @relation(fields: [userId], references: [id], onDelete: Cascade) + + scopes APIKeyPermission[] + redirectUri String // Must match one from application + + // PKCE (Proof Key for Code Exchange) support + codeChallenge String? + codeChallengeMethod String? // "S256" or "plain" + + usedAt DateTime? // Set when code is consumed + + @@index([code]) + @@index([applicationId, userId]) + @@index([expiresAt]) // For cleanup +} + +// Access tokens (1 hour TTL) +model OAuthAccessToken { + id String @id @default(uuid()) + token String @unique // SHA256 hash of plaintext token + createdAt DateTime @default(now()) + expiresAt DateTime // Now + 1 hour + + applicationId String + Application OAuthApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) + + userId String + User User @relation(fields: [userId], references: [id], onDelete: Cascade) + + scopes APIKeyPermission[] + + revokedAt DateTime? // Set when token is revoked + + @@index([token]) // For token lookup + @@index([userId, applicationId]) + @@index([expiresAt]) // For cleanup +} + +// Refresh tokens (30 days TTL) +model OAuthRefreshToken { + id String @id @default(uuid()) + token String @unique // SHA256 hash of plaintext token + createdAt DateTime @default(now()) + expiresAt DateTime // Now + 30 days + + applicationId String + Application OAuthApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade) + + userId String + User User @relation(fields: [userId], references: [id], onDelete: Cascade) + + scopes APIKeyPermission[] + + revokedAt DateTime? // Set when token is revoked + + @@index([token]) // For token lookup + @@index([userId, applicationId]) + @@index([expiresAt]) // For cleanup +} diff --git a/autogpt_platform/backend/test/e2e_test_data.py b/autogpt_platform/backend/test/e2e_test_data.py index 013c8c11a7..943c506f5c 100644 --- a/autogpt_platform/backend/test/e2e_test_data.py +++ b/autogpt_platform/backend/test/e2e_test_data.py @@ -23,13 +23,13 @@ from typing import Any, Dict, List from faker import Faker -from backend.data.api_key import create_api_key +from backend.data.auth.api_key import create_api_key from backend.data.credit import get_user_credit_model from backend.data.db import prisma from backend.data.graph import Graph, Link, Node, create_graph +from backend.data.user import get_or_create_user # Import API functions from the backend -from backend.data.user import get_or_create_user from backend.server.v2.library.db import create_library_agent, create_preset from backend.server.v2.library.model import LibraryAgentPresetCreatable from backend.server.v2.store.db import create_store_submission, review_store_submission @@ -464,7 +464,7 @@ class TestDataCreator: api_keys = [] for user in self.users: - from backend.data.api_key import APIKeyPermission + from backend.data.auth.api_key import APIKeyPermission try: # Use the API function to create API key diff --git a/autogpt_platform/frontend/src/app/(platform)/auth/authorize/page.tsx b/autogpt_platform/frontend/src/app/(platform)/auth/authorize/page.tsx new file mode 100644 index 0000000000..8093b75965 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/auth/authorize/page.tsx @@ -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(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 ( +
+ + + +
+ ); + } + + // Show loading state + if (isLoading) { + return ( +
+ +
+ + + Loading application information... + +
+
+
+ ); + } + + // Show error if app not found + if (error || !appInfo) { + return ( +
+ + + {redirectURI && ( + + )} + +
+ ); + } + + // Validate that requested scopes are allowed by the app + const invalidScopes = requestedScopes.filter( + (s) => !appInfo.scopes.includes(s), + ); + + if (invalidScopes.length > 0) { + return ( +
+ + + + +
+ ); + } + + return ( +
+ +
+ {/* App info */} +
+ {/* App logo */} +
+ {appInfo.logo_url ? ( + // eslint-disable-next-line @next/next/no-img-element + {`${appInfo.name} + ) : ( + + )} +
+ + {appInfo.name} + + {appInfo.description && ( + + {appInfo.description} + + )} +
+ + {/* Permissions */} +
+ + This application is requesting permission to: + +
    + {requestedScopes.map((scopeKey) => ( +
  • + + + {SCOPE_DESCRIPTIONS[scopeKey as APIKeyPermission] || + scopeKey} + +
  • + ))} +
+
+ + {/* Error message */} + {authorizeError && ( + + )} + + {/* Action buttons */} +
+ + +
+ + {/* Warning */} + + By authorizing, you allow this application to access your AutoGPT + account with the permissions listed above. + +
+
+
+ ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts b/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts index bff2fd0b68..13f8d988fe 100644 --- a/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts +++ b/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts @@ -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) { diff --git a/autogpt_platform/frontend/src/app/(platform)/auth/integrations/setup-wizard/page.tsx b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/setup-wizard/page.tsx new file mode 100644 index 0000000000..5163c46d5b --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/setup-wizard/page.tsx @@ -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 }, + }); + + // Parse providers from base64-encoded JSON + const providerConfigs = useMemo(() => { + if (!providersParam) return []; + return parseProvidersParam(providersParam); + }, [providersParam]); + + // Track selected credentials for each provider + const [selectedCredentials, setSelectedCredentials] = useState< + Record + >({}); + + // 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 ( +
+ + + +
+ ); + } + + if (providerConfigs.length === 0) { + return ( +
+ + + + +
+ ); + } + + return ( +
+ +
+ + {appInfo ? ( + <> + {appInfo.name} is requesting you to connect the + following integrations to your AutoGPT account. + + ) : ( + "Please connect the following integrations to continue." + )} + + + {/* Provider credentials list */} +
+ {providerConfigs.map((config) => { + const schema = createSchemaFromProviderConfig(config); + const isSelected = !!selectedCredentials[config.provider]; + + return ( +
+
+
+ {`${config.provider} +
+ + {toDisplayName(config.provider)} + +
+ {isSelected ? ( + + ) : ( + + )} + {isSelected && ( + + Connected + + )} +
+ + + handleCredentialSelect(config.provider, credMeta) + } + showTitle={false} + className="mb-0" + /> +
+ ); + })} +
+ + {/* Action buttons */} +
+ + +
+ + {/* Link to integrations settings */} + + You can view and manage all your integrations in your{" "} + + integration settings + + . + +
+
+
+ ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs.tsx index e63105c751..07350fb610 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs.tsx @@ -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; +import { + CredentialsInputState, + useCredentialsInput, +} from "./useCredentialsInput"; function isLoaded( - data: UseCredentialsInputsReturn, -): data is Extract { + data: CredentialsInputState, +): data is Extract { 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 (
-
- {displayName} credentials - {schema.description && ( - - )} -
+ {showTitle && ( +
+ {displayName} credentials + {schema.description && ( + + )} +
+ )} {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} diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/useCredentialsInputs.ts b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/useCredentialsInput.ts similarity index 76% rename from autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/useCredentialsInputs.ts rename to autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/useCredentialsInput.ts index 460980c10b..6f5ca48126 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/useCredentialsInputs.ts +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/useCredentialsInput.ts @@ -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; + +type Params = { schema: BlockIOCredentialsSubSchema; - selectedCredentials?: CredentialsMetaInput; - onSelectCredentials: (newValue?: CredentialsMetaInput) => void; + selectedCredential?: CredentialsMetaInput; + onSelectCredential: (newValue?: CredentialsMetaInput) => void; siblingInputs?: Record; 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, }; diff --git a/autogpt_platform/frontend/src/app/(platform)/login/page.tsx b/autogpt_platform/frontend/src/app/(platform)/login/page.tsx index 3f06e7f429..b670be5127 100644 --- a/autogpt_platform/frontend/src/app/(platform)/login/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/login/page.tsx @@ -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() { diff --git a/autogpt_platform/frontend/src/app/(platform)/login/useLoginPage.ts b/autogpt_platform/frontend/src/app/(platform)/login/useLoginPage.ts index a1e8b5a92c..656e1febc2 100644 --- a/autogpt_platform/frontend/src/app/(platform)/login/useLoginPage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/login/useLoginPage.ts @@ -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(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>({ 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"); diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api_keys/components/APIKeySection/APIKeySection.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api-keys/components/APIKeySection/APIKeySection.tsx similarity index 100% rename from autogpt_platform/frontend/src/app/(platform)/profile/(user)/api_keys/components/APIKeySection/APIKeySection.tsx rename to autogpt_platform/frontend/src/app/(platform)/profile/(user)/api-keys/components/APIKeySection/APIKeySection.tsx diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api_keys/components/APIKeySection/useAPISection.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api-keys/components/APIKeySection/useAPISection.ts similarity index 100% rename from autogpt_platform/frontend/src/app/(platform)/profile/(user)/api_keys/components/APIKeySection/useAPISection.tsx rename to autogpt_platform/frontend/src/app/(platform)/profile/(user)/api-keys/components/APIKeySection/useAPISection.ts diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api_keys/components/APIKeysModals/APIKeysModals.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api-keys/components/APIKeysModals/APIKeysModals.tsx similarity index 100% rename from autogpt_platform/frontend/src/app/(platform)/profile/(user)/api_keys/components/APIKeysModals/APIKeysModals.tsx rename to autogpt_platform/frontend/src/app/(platform)/profile/(user)/api-keys/components/APIKeysModals/APIKeysModals.tsx diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api_keys/components/APIKeysModals/useAPIkeysModals.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api-keys/components/APIKeysModals/useAPIkeysModals.ts similarity index 100% rename from autogpt_platform/frontend/src/app/(platform)/profile/(user)/api_keys/components/APIKeysModals/useAPIkeysModals.tsx rename to autogpt_platform/frontend/src/app/(platform)/profile/(user)/api-keys/components/APIKeysModals/useAPIkeysModals.ts diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api_keys/page.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api-keys/page.tsx similarity index 94% rename from autogpt_platform/frontend/src/app/(platform)/profile/(user)/api_keys/page.tsx rename to autogpt_platform/frontend/src/app/(platform)/profile/(user)/api-keys/page.tsx index ca66f0fb85..aedc3cc60c 100644 --- a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api_keys/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api-keys/page.tsx @@ -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, diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/layout.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/layout.tsx index 800028a49f..ca0e846557 100644 --- a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/layout.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/layout.tsx @@ -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: , + }, { text: "Creator Dashboard", href: "/profile/dashboard", - icon: , + icon: , }, - ...(isPaymentEnabled + ...(isPaymentEnabled || true ? [ { text: "Billing", href: "/profile/credits", - icon: , + icon: , }, ] : []), { text: "Integrations", href: "/profile/integrations", - icon: , - }, - { - text: "API Keys", - href: "/profile/api_keys", - icon: , - }, - { - text: "Profile", - href: "/profile", - icon: , + icon: , }, { text: "Settings", href: "/profile/settings", - icon: , + icon: , + }, + { + text: "API Keys", + href: "/profile/api-keys", + icon: , + }, + { + text: "OAuth Apps", + href: "/profile/oauth-apps", + icon: , }, ], }, diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/oauth-apps/components/OAuthAppsSection.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/oauth-apps/components/OAuthAppsSection.tsx new file mode 100644 index 0000000000..a864199348 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/oauth-apps/components/OAuthAppsSection.tsx @@ -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, + ) => { + 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 ( +
+ +
+ ); + } + + if (oauthApps.length === 0) { + return ( +
+

You don't have any OAuth applications.

+

+ OAuth applications can currently not be registered + via the API. Contact the system administrator to request an OAuth app + registration. +

+
+ ); + } + + return ( +
+ {oauthApps.map((app) => ( +
+ {/* Header: Logo, Name, Status */} +
+
+ {app.logo_url ? ( + // eslint-disable-next-line @next/next/no-img-element + {`${app.name} + ) : ( + + )} +
+
+
+

{app.name}

+ + {app.is_active ? "Active" : "Disabled"} + +
+ {app.description && ( +

+ {app.description} +

+ )} +
+
+ + {/* Client ID */} +
+ + Client ID + + + {app.client_id} + +
+ + {/* Footer: Created date and Actions */} +
+ + Created {new Date(app.created_at).toLocaleDateString()} + +
+ + { + fileInputRefs.current[app.id] = el; + }} + onChange={(e) => handleFileChange(app.id, e)} + accept="image/jpeg,image/png,image/webp" + className="hidden" + /> + +
+
+
+ ))} +
+ ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/oauth-apps/components/useOAuthApps.ts b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/oauth-apps/components/useOAuthApps.ts new file mode 100644 index 0000000000..5b5afc5783 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/oauth-apps/components/useOAuthApps.ts @@ -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(null); + const [uploadingAppId, setUploadingAppId] = useState(null); + + const { data: oauthAppsResponse, isLoading } = useGetOauthListMyOauthApps({ + query: { select: okData }, + }); + + 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, + }; +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/oauth-apps/page.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/oauth-apps/page.tsx new file mode 100644 index 0000000000..4251bb954e --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/oauth-apps/page.tsx @@ -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 ( +
+
+ OAuth Applications + + Manage your OAuth applications that use the AutoGPT Platform API + +
+ +
+ ); +}; + +export default OAuthAppsPage; diff --git a/autogpt_platform/frontend/src/app/(platform)/signup/page.tsx b/autogpt_platform/frontend/src/app/(platform)/signup/page.tsx index 53c47eeba7..b565699426 100644 --- a/autogpt_platform/frontend/src/app/(platform)/signup/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/signup/page.tsx @@ -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() { diff --git a/autogpt_platform/frontend/src/app/(platform)/signup/useSignupPage.ts b/autogpt_platform/frontend/src/app/(platform)/signup/useSignupPage.ts index 23ee8fb57c..e6d7c68aef 100644 --- a/autogpt_platform/frontend/src/app/(platform)/signup/useSignupPage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/signup/useSignupPage.ts @@ -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(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>({ 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); diff --git a/autogpt_platform/frontend/src/app/api/mutators/custom-mutator.ts b/autogpt_platform/frontend/src/app/api/mutators/custom-mutator.ts index 0a31eb6942..315b68ab87 100644 --- a/autogpt_platform/frontend/src/app/api/mutators/custom-mutator.ts +++ b/autogpt_platform/frontend/src/app/api/mutators/custom-mutator.ts @@ -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 { diff --git a/autogpt_platform/frontend/src/app/api/openapi.json b/autogpt_platform/frontend/src/app/api/openapi.json index f8c5563476..3556e2f5c7 100644 --- a/autogpt_platform/frontend/src/app/api/openapi.json +++ b/autogpt_platform/frontend/src/app/api/openapi.json @@ -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": { diff --git a/autogpt_platform/frontend/src/components/molecules/ErrorCard/ErrorCard.tsx b/autogpt_platform/frontend/src/components/molecules/ErrorCard/ErrorCard.tsx index 4269ae5415..843330b085 100644 --- a/autogpt_platform/frontend/src/components/molecules/ErrorCard/ErrorCard.tsx +++ b/autogpt_platform/frontend/src/components/molecules/ErrorCard/ErrorCard.tsx @@ -7,6 +7,7 @@ import { ActionButtons } from "./components/ActionButtons"; export interface ErrorCardProps { isSuccess?: boolean; + isOurProblem?: boolean; responseError?: { detail?: Array<{ msg: string }> | string; message?: string; @@ -17,15 +18,18 @@ export interface ErrorCardProps { message?: string; }; context?: string; + hint?: string; onRetry?: () => void; className?: string; } export function ErrorCard({ isSuccess = false, + isOurProblem = true, responseError, httpError, context = "data", + hint, onRetry, className = "", }: ErrorCardProps) { @@ -50,13 +54,19 @@ export function ErrorCard({
- - + {isOurProblem && ( + + )}
); diff --git a/autogpt_platform/frontend/src/components/molecules/ErrorCard/components/ErrorMessage.tsx b/autogpt_platform/frontend/src/components/molecules/ErrorCard/components/ErrorMessage.tsx index f232e6ff3f..bfb3726de1 100644 --- a/autogpt_platform/frontend/src/components/molecules/ErrorCard/components/ErrorMessage.tsx +++ b/autogpt_platform/frontend/src/components/molecules/ErrorCard/components/ErrorMessage.tsx @@ -4,9 +4,10 @@ import { Text } from "@/components/atoms/Text/Text"; interface Props { errorMessage: string; context: string; + hint?: string; } -export function ErrorMessage({ errorMessage, context }: Props) { +export function ErrorMessage({ errorMessage, context, hint }: Props) { return (
@@ -17,6 +18,13 @@ export function ErrorMessage({ errorMessage, context }: Props) { {errorMessage}
+ {hint && ( +
+ + {hint} + +
+ )}
); } diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts index 0d8be1df5d..2f27ef126d 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts @@ -912,7 +912,7 @@ export interface APIKey { prefix: string; postfix: string; status: APIKeyStatus; - permissions: APIKeyPermission[]; + scopes: APIKeyPermission[]; created_at: string; last_used_at?: string; revoked_at?: string; diff --git a/autogpt_platform/frontend/src/lib/supabase/helpers.ts b/autogpt_platform/frontend/src/lib/supabase/helpers.ts index 7b2d36a0fd..f41c8c2f0f 100644 --- a/autogpt_platform/frontend/src/lib/supabase/helpers.ts +++ b/autogpt_platform/frontend/src/lib/supabase/helpers.ts @@ -4,6 +4,8 @@ import { type CookieOptions } from "@supabase/ssr"; import { SupabaseClient } from "@supabase/supabase-js"; export const PROTECTED_PAGES = [ + "/auth/authorize", + "/auth/integrations", "/monitor", "/build", "/onboarding", @@ -59,14 +61,15 @@ export function hasWebSocketDisconnectIntent(): boolean { // Redirect utilities export function getRedirectPath( - pathname: string, + path: string, // including query strings userRole?: string, ): string | null { - if (shouldRedirectOnLogout(pathname)) { - return "/login"; + if (shouldRedirectOnLogout(path)) { + // Preserve the original path as a 'next' parameter so user can return after login + return `/login?next=${encodeURIComponent(path)}`; } - if (isAdminPage(pathname) && userRole !== "admin") { + if (isAdminPage(path) && userRole !== "admin") { return "/marketplace"; } diff --git a/autogpt_platform/frontend/src/lib/supabase/hooks/helpers.ts b/autogpt_platform/frontend/src/lib/supabase/hooks/helpers.ts index cce4f7a769..95b9e8bbca 100644 --- a/autogpt_platform/frontend/src/lib/supabase/hooks/helpers.ts +++ b/autogpt_platform/frontend/src/lib/supabase/hooks/helpers.ts @@ -77,7 +77,7 @@ export async function fetchUser(): Promise { } interface ValidateSessionParams { - pathname: string; + path: string; currentUser: User | null; } @@ -92,7 +92,7 @@ export async function validateSession( params: ValidateSessionParams, ): Promise { try { - const result = await validateSessionAction(params.pathname); + const result = await validateSessionAction(params.path); if (!result.isValid) { return { @@ -118,7 +118,7 @@ export async function validateSession( }; } catch (error) { console.error("Session validation error:", error); - const redirectPath = getRedirectPath(params.pathname); + const redirectPath = getRedirectPath(params.path); return { isValid: false, redirectPath, @@ -146,7 +146,7 @@ interface StorageEventHandlerParams { event: StorageEvent; api: BackendAPI | null; router: AppRouterInstance | null; - pathname: string; + path: string; } interface StorageEventHandlerResult { @@ -167,7 +167,7 @@ export function handleStorageEvent( params.api.disconnectWebSocket(); } - const redirectPath = getRedirectPath(params.pathname); + const redirectPath = getRedirectPath(params.path); return { shouldLogout: true, diff --git a/autogpt_platform/frontend/src/lib/supabase/hooks/useSupabase.ts b/autogpt_platform/frontend/src/lib/supabase/hooks/useSupabase.ts index 41fdee25a2..5f362397f6 100644 --- a/autogpt_platform/frontend/src/lib/supabase/hooks/useSupabase.ts +++ b/autogpt_platform/frontend/src/lib/supabase/hooks/useSupabase.ts @@ -1,8 +1,8 @@ "use client"; import { useBackendAPI } from "@/lib/autogpt-server-api/context"; -import { usePathname, useRouter } from "next/navigation"; -import { useEffect } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useMemo } from "react"; import { useShallow } from "zustand/react/shallow"; import type { ServerLogoutOptions } from "../actions"; import { useSupabaseStore } from "./useSupabaseStore"; @@ -10,8 +10,15 @@ import { useSupabaseStore } from "./useSupabaseStore"; export function useSupabase() { const router = useRouter(); const pathname = usePathname(); + const searchParams = useSearchParams(); const api = useBackendAPI(); + // Combine pathname and search params to get full path for redirect preservation + const fullPath = useMemo(() => { + const search = searchParams.toString(); + return search ? `${pathname}?${search}` : pathname; + }, [pathname, searchParams]); + const { user, supabase, @@ -36,9 +43,9 @@ export function useSupabase() { void initialize({ api, router, - pathname, + path: fullPath, }); - }, [api, initialize, pathname, router]); + }, [api, initialize, fullPath, router]); function handleLogout(options: ServerLogoutOptions = {}) { return logOut({ @@ -49,7 +56,7 @@ export function useSupabase() { function handleValidateSession() { return validateSession({ - pathname, + path: fullPath, router, }); } diff --git a/autogpt_platform/frontend/src/lib/supabase/hooks/useSupabaseStore.ts b/autogpt_platform/frontend/src/lib/supabase/hooks/useSupabaseStore.ts index dcc6029668..5207397ee4 100644 --- a/autogpt_platform/frontend/src/lib/supabase/hooks/useSupabaseStore.ts +++ b/autogpt_platform/frontend/src/lib/supabase/hooks/useSupabaseStore.ts @@ -21,7 +21,7 @@ import { interface InitializeParams { api: BackendAPI; router: AppRouterInstance; - pathname: string; + path: string; } interface LogOutParams { @@ -32,7 +32,7 @@ interface LogOutParams { interface ValidateParams { force?: boolean; - pathname?: string; + path?: string; router?: AppRouterInstance; } @@ -47,7 +47,7 @@ interface SupabaseStoreState { listenersCleanup: (() => void) | null; routerRef: AppRouterInstance | null; apiRef: BackendAPI | null; - currentPathname: string; + currentPath: string; initialize: (params: InitializeParams) => Promise; logOut: (params?: LogOutParams) => Promise; validateSession: (params?: ValidateParams) => Promise; @@ -60,7 +60,7 @@ export const useSupabaseStore = create((set, get) => { set({ routerRef: params.router, apiRef: params.api, - currentPathname: params.pathname, + currentPath: params.path, }); const supabaseClient = ensureSupabaseClient(); @@ -83,7 +83,7 @@ export const useSupabaseStore = create((set, get) => { // This handles race conditions after login where cookies might not be immediately available if (!result.user) { const validationResult = await validateSessionHelper({ - pathname: params.pathname, + path: params.path, currentUser: null, }); @@ -160,7 +160,7 @@ export const useSupabaseStore = create((set, get) => { params?: ValidateParams, ): Promise { const router = params?.router ?? get().routerRef; - const pathname = params?.pathname ?? get().currentPathname; + const pathname = params?.path ?? get().currentPath; if (!router || !pathname) return true; if (!params?.force && get().isValidating) return true; @@ -175,7 +175,7 @@ export const useSupabaseStore = create((set, get) => { try { const result = await validateSessionHelper({ - pathname, + path: pathname, currentUser: get().user, }); @@ -224,7 +224,7 @@ export const useSupabaseStore = create((set, get) => { event, api: get().apiRef, router: get().routerRef, - pathname: get().currentPathname, + path: get().currentPath, }); if (!result.shouldLogout) return; @@ -283,7 +283,7 @@ export const useSupabaseStore = create((set, get) => { listenersCleanup: null, routerRef: null, apiRef: null, - currentPathname: "", + currentPath: "", initialize, logOut, validateSession: validateSessionInternal, diff --git a/autogpt_platform/frontend/src/lib/supabase/middleware.ts b/autogpt_platform/frontend/src/lib/supabase/middleware.ts index 5e04efde67..5e4bd01e83 100644 --- a/autogpt_platform/frontend/src/lib/supabase/middleware.ts +++ b/autogpt_platform/frontend/src/lib/supabase/middleware.ts @@ -57,7 +57,9 @@ export async function updateSession(request: NextRequest) { const attemptingAdminPage = isAdminPage(pathname); if (attemptingProtectedPage || attemptingAdminPage) { + const currentDest = url.pathname + url.search; url.pathname = "/login"; + url.search = `?next=${encodeURIComponent(currentDest)}`; return NextResponse.redirect(url); } } diff --git a/autogpt_platform/frontend/src/middleware.ts b/autogpt_platform/frontend/src/middleware.ts index 65edec41d7..af1c823295 100644 --- a/autogpt_platform/frontend/src/middleware.ts +++ b/autogpt_platform/frontend/src/middleware.ts @@ -9,11 +9,15 @@ export const config = { matcher: [ /* * Match all request paths except for the ones starting with: - * - _next/static (static files) - * - _next/image (image optimization files) - * - favicon.ico (favicon file) + * - /_next/static (static files) + * - /_next/image (image optimization files) + * - /favicon.ico (favicon file) + * - /auth/callback (OAuth callback - needs to work without auth) * Feel free to modify this pattern to include more paths. + * + * Note: /auth/authorize and /auth/integrations/* ARE protected and need + * middleware to run for authentication checks. */ - "/((?!_next/static|_next/image|favicon.ico|auth|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", + "/((?!_next/static|_next/image|favicon.ico|auth/callback|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], }; diff --git a/autogpt_platform/frontend/src/tests/api-keys.spec.ts b/autogpt_platform/frontend/src/tests/api-keys.spec.ts index a42ae8384e..e2a5575aed 100644 --- a/autogpt_platform/frontend/src/tests/api-keys.spec.ts +++ b/autogpt_platform/frontend/src/tests/api-keys.spec.ts @@ -19,8 +19,8 @@ test.describe("API Keys Page", () => { const page = await context.newPage(); try { - await page.goto("/profile/api_keys"); - await hasUrl(page, "/login"); + await page.goto("/profile/api-keys"); + await hasUrl(page, "/login?next=%2Fprofile%2Fapi-keys"); } finally { await page.close(); await context.close(); @@ -29,7 +29,7 @@ test.describe("API Keys Page", () => { test("should create a new API key successfully", async ({ page }) => { const { getButton, getField } = getSelectors(page); - await page.goto("/profile/api_keys"); + await page.goto("/profile/api-keys"); await getButton("Create Key").click(); await getField("Name").fill("Test Key"); @@ -45,7 +45,7 @@ test.describe("API Keys Page", () => { test("should revoke an existing API key", async ({ page }) => { const { getRole, getId } = getSelectors(page); - await page.goto("/profile/api_keys"); + await page.goto("/profile/api-keys"); const apiKeyRow = getId("api-key-row").first(); const apiKeyContent = await apiKeyRow diff --git a/autogpt_platform/frontend/src/tests/profile-form.spec.ts b/autogpt_platform/frontend/src/tests/profile-form.spec.ts index 527c5cca92..1fc1008e9c 100644 --- a/autogpt_platform/frontend/src/tests/profile-form.spec.ts +++ b/autogpt_platform/frontend/src/tests/profile-form.spec.ts @@ -24,7 +24,7 @@ test.describe("Profile Form", () => { try { await page.goto("/profile"); - await hasUrl(page, "/login"); + await hasUrl(page, "/login?next=%2Fprofile"); } finally { await page.close(); await context.close(); diff --git a/autogpt_platform/frontend/src/tests/signin.spec.ts b/autogpt_platform/frontend/src/tests/signin.spec.ts index 6e53855a8e..0f36006c4d 100644 --- a/autogpt_platform/frontend/src/tests/signin.spec.ts +++ b/autogpt_platform/frontend/src/tests/signin.spec.ts @@ -152,10 +152,10 @@ test("multi-tab logout with WebSocket cleanup", async ({ context }) => { // Check if Tab 2 has been redirected to login or refresh the page to trigger redirect try { await page2.reload(); - await hasUrl(page2, "/login"); + await hasUrl(page2, "/login?next=%2Fbuild"); } catch { // If reload fails, the page might already be redirecting - await hasUrl(page2, "/login"); + await hasUrl(page2, "/login?next=%2Fbuild"); } // Verify the profile menu is no longer visible (user is logged out) diff --git a/docs/content/platform/integrating/api-guide.md b/docs/content/platform/integrating/api-guide.md new file mode 100644 index 0000000000..19d210af91 --- /dev/null +++ b/docs/content/platform/integrating/api-guide.md @@ -0,0 +1,85 @@ +# AutoGPT Platform External API Guide + +The AutoGPT Platform provides an External API that allows you to programmatically interact with agents, blocks, the store, and more. + +## API Documentation + +Full API documentation with interactive examples is available at: + +**[https://backend.agpt.co/external-api/docs](https://backend.agpt.co/external-api/docs)** + +This Swagger UI documentation includes all available endpoints, request/response schemas, and allows you to try out API calls directly. + +## Authentication Methods + +The External API supports two authentication methods: + +### 1. API Keys + +API keys are the simplest way to authenticate. Generate an API key from your AutoGPT Platform account settings and include it in your requests: + +```http +GET /external-api/v1/blocks +X-API-Key: your_api_key_here +``` + +API keys are ideal for: +- Server-to-server integrations +- Personal scripts and automation +- Backend services + +### 2. OAuth 2.0 (Single Sign-On) + +For applications that need to act on behalf of users, use OAuth 2.0. This allows users to authorize your application to access their AutoGPT resources. + +OAuth is ideal for: +- Third-party applications +- "Sign in with AutoGPT" (SSO, Single Sign-On) functionality +- Applications that need user-specific permissions + +See the [SSO Integration Guide](sso-guide.md) for complete OAuth implementation details. + +## Available Scopes + +When using OAuth, request only the scopes your application needs: + +| Scope | Description | +|-------|-------------| +| `IDENTITY` | Read user ID, e-mail, and timezone | +| `EXECUTE_GRAPH` | Run agents | +| `READ_GRAPH` | Read agent run results | +| `EXECUTE_BLOCK` | Run individual blocks | +| `READ_BLOCK` | Read block definitions | +| `READ_STORE` | Access the agent store | +| `USE_TOOLS` | Use platform tools | +| `MANAGE_INTEGRATIONS` | Create and update user integrations | +| `READ_INTEGRATIONS` | Read user integration status | +| `DELETE_INTEGRATIONS` | Remove user integrations | + +## Quick Start + +### Using an API Key + +```bash +# List available blocks +curl -H "X-API-Key: YOUR_API_KEY" \ + https://backend.agpt.co/external-api/v1/blocks +``` + +### Using OAuth + +1. Register an OAuth application (contact platform administrator) +2. Implement the OAuth flow as described in the [SSO Guide](sso-guide.md) +3. Use the obtained access token: + +```bash +curl -H "Authorization: Bearer agpt_xt_..." \ + https://backend.agpt.co/external-api/v1/blocks +``` + +## Support + +For issues or questions about API integration: + +- Open an issue on [GitHub](https://github.com/Significant-Gravitas/AutoGPT) +- Check the [Swagger documentation](https://backend.agpt.co/external-api/docs) diff --git a/docs/content/platform/integrating/oauth-guide.md b/docs/content/platform/integrating/oauth-guide.md new file mode 100644 index 0000000000..d88ef385e1 --- /dev/null +++ b/docs/content/platform/integrating/oauth-guide.md @@ -0,0 +1,440 @@ +# AutoGPT Platform OAuth Integration Guide + +This guide explains how to integrate your application with AutoGPT Platform using OAuth 2.0. OAuth can be used for API access, Single Sign-On (SSO), or both. + +For general API information and endpoint documentation, see the [API Guide](api-guide.md) and the [Swagger documentation](https://backend.agpt.co/external-api/docs). + +## Overview + +AutoGPT Platform's OAuth implementation supports multiple use cases: + +### OAuth for API Access + +Use OAuth when your application needs to call AutoGPT APIs on behalf of users. This is the most common use case for third-party integrations. + +**When to use:** + +- Your app needs to run agents, access the store, or manage integrations for users +- You want user-specific permissions rather than a single API key +- Users should be able to revoke access to your app + +### SSO: "Sign in with AutoGPT" + +Use SSO when you want users to sign in to your app through their AutoGPT account. Request the `IDENTITY` scope to get user information. + +**When to use:** + +- You want to use AutoGPT as an identity provider +- Users already have AutoGPT accounts and you want seamless login +- You need to identify users without managing passwords + +**Note:** SSO and API access can be combined. Request `IDENTITY` along with other scopes to both authenticate users and access APIs on their behalf. + +### Integration Setup Wizard + +A separate flow that guides users through connecting third-party services (GitHub, Google, etc.) to their AutoGPT account. See [Integration Setup Wizard](#integration-setup-wizard) below. + +## Prerequisites + +Before integrating, you need an OAuth application registered with AutoGPT Platform. Contact the platform administrator to obtain: + +- **Client ID** - Public identifier for your application +- **Client Secret** - Secret key for authenticating your application (keep this secure!) +- **Registered Redirect URIs** - URLs where users will be redirected after authorization + +## OAuth Flow + +The OAuth flow is technically the same whether you're using it for API access, SSO, or both. The main difference is which scopes you request. + +### Step 1: Redirect User to Authorization + +Redirect the user to the AutoGPT authorization page with the required parameters: + +```url +https://platform.agpt.co/auth/authorize? + client_id={YOUR_CLIENT_ID}& + redirect_uri=https://yourapp.com/callback& + scope=EXECUTE_GRAPH READ_GRAPH& + state={RANDOM_STATE_TOKEN}& + code_challenge={PKCE_CHALLENGE}& + code_challenge_method=S256& + response_type=code +``` + +#### Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `client_id` | Yes | Your OAuth application's client ID | +| `redirect_uri` | Yes | URL to redirect after authorization (must match registered URI) | +| `scope` | Yes | Space-separated list of permissions (see [Available Scopes](api-guide.md#available-scopes)) | +| `state` | Yes | Random string to prevent CSRF attacks (store and verify on callback) | +| `code_challenge` | Yes | PKCE code challenge (see [PKCE](#pkce-implementation)) | +| `code_challenge_method` | Yes | Must be `S256` | +| `response_type` | Yes | Must be `code` | + +### Step 2: Handle the Callback + +After the user approves (or denies) access, they'll be redirected to your `redirect_uri`: + +**Success:** + +```url +https://yourapp.com/callback?code=AUTHORIZATION_CODE&state=RANDOM_STATE_TOKEN +``` + +**Error:** + +```url +https://yourapp.com/callback?error=access_denied&error_description=User%20denied%20access&state=RANDOM_STATE_TOKEN +``` + +Always verify the `state` parameter matches what you sent in Step 1. + +### Step 3: Exchange Code for Tokens + +Exchange the authorization code for access and refresh tokens: + +```http +POST /api/oauth/token +Content-Type: application/json + +{ + "grant_type": "authorization_code", + "code": "{AUTHORIZATION_CODE}", + "redirect_uri": "https://yourapp.com/callback", + "client_id": "{YOUR_CLIENT_ID}", + "client_secret": "{YOUR_CLIENT_SECRET}", + "code_verifier": "{PKCE_VERIFIER}" +} +``` + +**Response:** + +```json +{ + "token_type": "Bearer", + "access_token": "agpt_xt_...", + "access_token_expires_at": "2025-01-15T12:00:00Z", + "refresh_token": "agpt_rt_...", + "refresh_token_expires_at": "2025-02-14T12:00:00Z", + "scopes": ["EXECUTE_GRAPH", "READ_GRAPH"] +} +``` + +### Step 4: Use the Access Token + +Include the access token in API requests: + +```http +GET /external-api/v1/blocks +Authorization: Bearer agpt_xt_... +``` + +**For SSO:** If you requested the `IDENTITY` scope, fetch user info to identify the user: + +```http +GET /external-api/v1/me +Authorization: Bearer agpt_xt_... +``` + +**Response:** + +```json +{ + "id": "user-uuid", + "name": "John Doe", + "email": "john@example.com", + "timezone": "Europe/Amsterdam" +} +``` + +See the [Swagger documentation](https://backend.agpt.co/external-api/docs) for all available endpoints. + +### Step 5: Refresh Tokens + +Access tokens expire after 1 hour. Use the refresh token to get new tokens: + +```http +POST /api/oauth/token +Content-Type: application/json + +{ + "grant_type": "refresh_token", + "refresh_token": "agpt_rt_...", + "client_id": "{YOUR_CLIENT_ID}", + "client_secret": "{YOUR_CLIENT_SECRET}" +} +``` + +**Response:** + +```json +{ + "token_type": "Bearer", + "access_token": "agpt_xt_...", + "access_token_expires_at": "2025-01-15T13:00:00Z", + "refresh_token": "agpt_rt_...", + "refresh_token_expires_at": "2025-02-14T12:00:00Z", + "scopes": ["EXECUTE_GRAPH", "READ_GRAPH"] +} +``` + +## Integration Setup Wizard + +The Integration Setup Wizard guides users through connecting third-party services (like GitHub, Google, etc.) to their AutoGPT account. This is useful when your application needs users to have specific integrations configured. + +### Redirect to the Wizard + +```url +https://platform.agpt.co/auth/integrations/setup-wizard? + client_id={YOUR_CLIENT_ID}& + providers={BASE64_ENCODED_PROVIDERS}& + redirect_uri=https://yourapp.com/callback& + state={RANDOM_STATE_TOKEN} +``` + +#### Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `client_id` | Yes | Your OAuth application's client ID | +| `providers` | Yes | Base64-encoded JSON array of provider configurations | +| `redirect_uri` | Yes | URL to redirect after setup completes | +| `state` | Yes | Random string to prevent CSRF attacks | + +#### Provider Configuration + +The `providers` parameter is a Base64-encoded JSON array: + +```javascript +const providers = [ + { provider: 'github', scopes: ['repo', 'read:user'] }, + { provider: 'google', scopes: ['https://www.googleapis.com/auth/calendar'] }, + { provider: 'slack' } // Uses default scopes +]; + +const providersBase64 = btoa(JSON.stringify(providers)); +``` + +### Handle the Callback + +After setup completes: + +**Success:** + +```url +https://yourapp.com/callback?success=true&state=RANDOM_STATE_TOKEN +``` + +**Failure/Cancelled:** + +```url +https://yourapp.com/callback?success=false&state=RANDOM_STATE_TOKEN +``` + +## Provider Scopes Reference + +When using the Integration Setup Wizard, you need to specify which scopes to request from each provider. Here are common providers and their scopes: + +### GitHub + +Documentation: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps + +| Scope | Description | +|-------|-------------| +| `repo` | Full control of private repositories | +| `read:user` | Read user profile data | +| `user:email` | Access user email addresses | +| `gist` | Create and manage gists | +| `workflow` | Update GitHub Actions workflows | + +**Example:** + +```javascript +{ provider: 'github', scopes: ['repo', 'read:user'] } +``` + +### Google + +Documentation: https://developers.google.com/identity/protocols/oauth2/scopes + +| Scope | Description | +|-------|-------------| +| `email` | View email address (default) | +| `profile` | View basic profile info (default) | +| `openid` | OpenID Connect (default) | +| `https://www.googleapis.com/auth/calendar` | Google Calendar access | +| `https://www.googleapis.com/auth/drive` | Google Drive access | +| `https://www.googleapis.com/auth/gmail.readonly` | Read Gmail messages | + +**Example:** + +```javascript +{ provider: 'google', scopes: ['https://www.googleapis.com/auth/calendar'] } +// Or use defaults (email, profile, openid): +{ provider: 'google' } +``` + +### Notion + +Documentation: https://developers.notion.com/reference/capabilities + +Notion uses a single OAuth scope that grants access based on pages the user selects during authorization. + +### Linear + +Documentation: https://developers.linear.app/docs/oauth/authentication + +| Scope | Description | +|-------|-------------| +| `read` | Read access to Linear data | +| `write` | Write access to Linear data | +| `issues:create` | Create issues | + +## PKCE Implementation + +PKCE (Proof Key for Code Exchange) is required for all authorization requests. Here's how to implement it: + +### JavaScript Example + +```javascript +async function generatePkce() { + // Generate a random code verifier + const array = new Uint8Array(32); + crypto.getRandomValues(array); + const verifier = Array.from(array, b => b.toString(16).padStart(2, '0')).join(''); + + // Create SHA-256 hash and base64url encode it + const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + const challenge = btoa(String.fromCharCode(...new Uint8Array(hash))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + return { verifier, challenge }; +} + +// Usage: +const pkce = await generatePkce(); +// Store pkce.verifier securely (e.g., in session storage) +// Use pkce.challenge in the authorization URL +``` + +### Python Example + +```python +import hashlib +import base64 +import secrets + +def generate_pkce(): + # Generate a random code verifier + verifier = secrets.token_urlsafe(32) + + # Create SHA-256 hash and base64url encode it + digest = hashlib.sha256(verifier.encode()).digest() + challenge = base64.urlsafe_b64encode(digest).decode().rstrip('=') + + return verifier, challenge + +# Usage: +verifier, challenge = generate_pkce() +# Store verifier securely in session +# Use challenge in the authorization URL +``` + +## Token Management + +### Token Lifetimes + +| Token Type | Lifetime | +|------------|----------| +| Access Token | 1 hour | +| Refresh Token | 30 days | +| Authorization Code | 10 minutes | + +### Token Introspection + +Check if a token is valid: + +```http +POST /api/oauth/introspect +Content-Type: application/json + +{ + "token": "agpt_xt_...", + "token_type_hint": "access_token", + "client_id": "{YOUR_CLIENT_ID}", + "client_secret": "{YOUR_CLIENT_SECRET}" +} +``` + +**Response:** + +```json +{ + "active": true, + "scopes": ["EXECUTE_GRAPH", "READ_GRAPH"], + "client_id": "agpt_client_...", + "user_id": "user-uuid", + "exp": 1705320000, + "token_type": "access_token" +} +``` + +### Token Revocation + +Revoke a token when the user logs out: + +```http +POST /api/oauth/revoke +Content-Type: application/json + +{ + "token": "agpt_xt_...", + "token_type_hint": "access_token", + "client_id": "{YOUR_CLIENT_ID}", + "client_secret": "{YOUR_CLIENT_SECRET}" +} +``` + +## Security Best Practices + +1. **Store client secrets securely** - Never expose them in client-side code or version control +2. **Always use PKCE** - Required for all authorization requests +3. **Validate state parameters** - Prevents CSRF attacks +4. **Use HTTPS** - All production redirect URIs must use HTTPS +5. **Request minimal scopes** - Only request the permissions your app needs +6. **Handle token expiration** - Implement automatic token refresh +7. **Revoke tokens on logout** - Clean up when users disconnect your app + +## Error Handling + +### Common OAuth Errors + +| Error | Description | Solution | +|-------|-------------|----------| +| `invalid_client` | Client ID not found or inactive | Verify client ID is correct | +| `invalid_redirect_uri` | Redirect URI not registered | Register URI with platform admin | +| `invalid_scope` | Requested scope not allowed | Check allowed scopes for your app | +| `invalid_grant` | Code expired or already used | Authorization codes are single-use | +| `access_denied` | User denied authorization | Handle gracefully in your UI | + +### HTTP Status Codes + +| Code | Meaning | +|------|---------| +| 200 | Success | +| 400 | Bad request (invalid parameters) | +| 401 | Unauthorized (invalid/expired token) | +| 403 | Forbidden (insufficient scope) | +| 404 | Resource not found | + +## Support + +For issues or questions about OAuth integration: + +- Open an issue on [GitHub](https://github.com/Significant-Gravitas/AutoGPT) +- See the [API Guide](api-guide.md) for general API information +- Check the [Swagger documentation](https://backend.agpt.co/external-api/docs) for endpoint details diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index ebf987f34b..876467633e 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -7,14 +7,14 @@ docs_dir: content nav: - Home: index.md - - The AutoGPT Platform 🆕: - - Getting Started: + - The AutoGPT Platform 🆕: + - Getting Started: - Setup AutoGPT (Local-Host): platform/getting-started.md - Edit an Agent: platform/edit-agent.md - Delete an Agent: platform/delete-agent.md - - Download & Import and Agent: platform/download-agent-from-marketplace-local.md + - Download & Import and Agent: platform/download-agent-from-marketplace-local.md - Create a Basic Agent: platform/create-basic-agent.md - - Submit an Agent to the Marketplace: platform/submit-agent-to-marketplace.md + - Submit an Agent to the Marketplace: platform/submit-agent-to-marketplace.md - Advanced Setup: platform/advanced_setup.md - Agent Blocks: platform/agent-blocks.md - Build your own Blocks: platform/new_blocks.md @@ -23,6 +23,9 @@ nav: - Using AI/ML API: platform/aimlapi.md - Using D-ID: platform/d_id.md - Blocks: platform/blocks/blocks.md + - API: + - Introduction: platform/integrating/api-guide.md + - OAuth & SSO: platform/integrating/oauth-guide.md - Contributing: - Tests: platform/contributing/tests.md - OAuth Flows: platform/contributing/oauth-integration-flow.md