Merge dev into native-auth

Resolved conflicts:
- rest_api.py: Keep both native auth and oauth router imports
- e2e_test_data.py: Keep AuthService import for native auth
- auth/callback/route.ts: Keep native auth implementation
- login/page.tsx: Add useSearchParams import
- useLoginPage.ts: Combine broadcastLogin/validateSession with nextUrl support
- signup/page.tsx: Add useSearchParams import
- useSignupPage.ts: Combine broadcastLogin/validateSession with nextUrl support
- openapi.json: Keep native auth TokenResponse, add OAuth types from dev

Kept deleted supabase files removed (native-auth replaces supabase).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Swifty
2025-12-19 21:25:22 +01:00
59 changed files with 8369 additions and 247 deletions

View File

@@ -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()

View File

@@ -0,0 +1 @@
"""CLI utilities for backend development & administration"""

View File

@@ -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()

File diff suppressed because it is too large Load Diff

View File

@@ -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]:

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,153 @@
"""
Direct email sending for authentication flows.
This module bypasses the notification queue system to ensure auth emails
(password reset, email verification) are sent immediately in all environments.
"""
import logging
import pathlib
from typing import Optional
from jinja2 import Environment, FileSystemLoader
from postmarker.core import PostmarkClient
from backend.util.settings import Settings
logger = logging.getLogger(__name__)
settings = Settings()
# Template directory
TEMPLATE_DIR = pathlib.Path(__file__).parent / "templates"
class AuthEmailSender:
"""Handles direct email sending for authentication flows."""
def __init__(self):
if settings.secrets.postmark_server_api_token:
self.postmark = PostmarkClient(
server_token=settings.secrets.postmark_server_api_token
)
else:
logger.warning(
"Postmark server API token not found, auth email sending disabled"
)
self.postmark = None
# Set up Jinja2 environment for templates
self.jinja_env = Environment(
loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=True,
)
def _get_frontend_url(self) -> str:
"""Get the frontend base URL for email links."""
return (
settings.config.frontend_base_url
or settings.config.platform_base_url
or "http://localhost:3000"
)
def _render_template(
self, template_name: str, subject: str, **context
) -> tuple[str, str]:
"""Render an email template with the base template wrapper."""
# Render the content template
content_template = self.jinja_env.get_template(template_name)
content = content_template.render(**context)
# Render with base template
base_template = self.jinja_env.get_template("base.html.jinja2")
html_body = base_template.render(
data={"title": subject, "message": content, "unsubscribe_link": None}
)
return subject, html_body
def _send_email(self, to_email: str, subject: str, html_body: str) -> bool:
"""Send an email directly via Postmark."""
if not self.postmark:
logger.warning(
f"Postmark not configured. Would send email to {to_email}: {subject}"
)
return False
try:
self.postmark.emails.send(
From=settings.config.postmark_sender_email,
To=to_email,
Subject=subject,
HtmlBody=html_body,
)
logger.info(f"Auth email sent to {to_email}: {subject}")
return True
except Exception as e:
logger.error(f"Failed to send auth email to {to_email}: {e}")
return False
def send_password_reset_email(
self, to_email: str, reset_token: str, user_name: Optional[str] = None
) -> bool:
"""
Send a password reset email.
Args:
to_email: Recipient email address
reset_token: The raw password reset token
user_name: Optional user name for personalization
Returns:
True if email was sent successfully, False otherwise
"""
frontend_url = self._get_frontend_url()
reset_link = f"{frontend_url}/reset-password?token={reset_token}"
subject, html_body = self._render_template(
"password_reset.html.jinja2",
subject="Reset Your AutoGPT Password",
reset_link=reset_link,
user_name=user_name,
frontend_url=frontend_url,
)
return self._send_email(to_email, subject, html_body)
def send_email_verification(
self, to_email: str, verification_token: str, user_name: Optional[str] = None
) -> bool:
"""
Send an email verification email.
Args:
to_email: Recipient email address
verification_token: The raw verification token
user_name: Optional user name for personalization
Returns:
True if email was sent successfully, False otherwise
"""
frontend_url = self._get_frontend_url()
verification_link = f"{frontend_url}/verify-email?token={verification_token}"
subject, html_body = self._render_template(
"email_verification.html.jinja2",
subject="Verify Your AutoGPT Email",
verification_link=verification_link,
user_name=user_name,
frontend_url=frontend_url,
)
return self._send_email(to_email, subject, html_body)
# Singleton instance
_auth_email_sender: Optional[AuthEmailSender] = None
def get_auth_email_sender() -> AuthEmailSender:
"""Get or create the auth email sender singleton."""
global _auth_email_sender
if _auth_email_sender is None:
_auth_email_sender = AuthEmailSender()
return _auth_email_sender

View File

@@ -5,15 +5,17 @@ Provides endpoints for:
- User registration and login
- Token refresh and logout
- Password reset
- Email verification
- Google OAuth
"""
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from pydantic import BaseModel, EmailStr, Field
from .email import get_auth_email_sender
from .service import AuthService
logger = logging.getLogger(__name__)
@@ -104,11 +106,12 @@ class UserResponse(BaseModel):
@router.post("/register", response_model=TokenResponse)
async def register(request: RegisterRequest):
async def register(request: RegisterRequest, background_tasks: BackgroundTasks):
"""
Register a new user with email and password.
Returns access and refresh tokens on successful registration.
Sends a verification email in the background.
"""
auth_service = get_auth_service()
@@ -118,6 +121,17 @@ async def register(request: RegisterRequest):
password=request.password,
name=request.name,
)
# Create verification token and send email in background
verification_token = await auth_service.create_email_verification_token(user.id)
email_sender = get_auth_email_sender()
background_tasks.add_task(
email_sender.send_email_verification,
to_email=user.email,
verification_token=verification_token,
user_name=user.name,
)
tokens = await auth_service.create_tokens(user)
return TokenResponse(**tokens)
except ValueError as e:
@@ -174,20 +188,28 @@ async def refresh_tokens(request: RefreshRequest):
@router.post("/password-reset/request", response_model=MessageResponse)
async def request_password_reset(request: PasswordResetRequest):
async def request_password_reset(
request: PasswordResetRequest, background_tasks: BackgroundTasks
):
"""
Request a password reset email.
Always returns success to prevent email enumeration attacks.
If the email exists, a reset token will be created (email sending not implemented).
If the email exists, a password reset email will be sent.
"""
auth_service = get_auth_service()
user = await auth_service.get_user_by_email(request.email)
if user:
token = await auth_service.create_password_reset_token(user.id)
# TODO: Send password reset email with token
logger.info(f"Password reset token created for user {user.id}: {token[:8]}...")
email_sender = get_auth_email_sender()
background_tasks.add_task(
email_sender.send_password_reset_email,
to_email=user.email,
reset_token=token,
user_name=user.name,
)
logger.info(f"Password reset email queued for user {user.id}")
# Always return success to prevent email enumeration
return MessageResponse(
@@ -211,6 +233,69 @@ async def confirm_password_reset(request: PasswordResetConfirm):
return MessageResponse(message="Password has been reset successfully")
# ============= Email Verification Endpoints =============
class EmailVerificationRequest(BaseModel):
"""Request model for email verification."""
token: str
class ResendVerificationRequest(BaseModel):
"""Request model for resending verification email."""
email: EmailStr
@router.post("/email/verify", response_model=MessageResponse)
async def verify_email(request: EmailVerificationRequest):
"""
Verify email address using a verification token.
Marks the user's email as verified if the token is valid.
"""
auth_service = get_auth_service()
success = await auth_service.verify_email_token(request.token)
if not success:
raise HTTPException(
status_code=400, detail="Invalid or expired verification token"
)
return MessageResponse(message="Email verified successfully")
@router.post("/email/resend-verification", response_model=MessageResponse)
async def resend_verification_email(
request: ResendVerificationRequest, background_tasks: BackgroundTasks
):
"""
Resend email verification email.
Always returns success to prevent email enumeration attacks.
If the email exists and is not verified, a new verification email will be sent.
"""
auth_service = get_auth_service()
user = await auth_service.get_user_by_email(request.email)
if user and not user.emailVerified:
token = await auth_service.create_email_verification_token(user.id)
email_sender = get_auth_email_sender()
background_tasks.add_task(
email_sender.send_email_verification,
to_email=user.email,
verification_token=token,
user_name=user.name,
)
logger.info(f"Verification email queued for user {user.id}")
# Always return success to prevent email enumeration
return MessageResponse(
message="If the email exists and is not verified, a verification link has been sent"
)
# ============= Google OAuth Endpoints =============

View File

@@ -351,6 +351,62 @@ class AuthService:
return raw_token
async def create_email_verification_token(self, user_id: str) -> str:
"""
Create an email verification token for a user.
:param user_id: The user's ID
:return: The raw token to send to the user
"""
raw_token, hashed_token = create_refresh_token() # Reuse token generation
expires_at = datetime.now(timezone.utc) + timedelta(hours=24)
await prisma.emailverificationtoken.create(
data={
"token": hashed_token,
"userId": user_id,
"expiresAt": expires_at,
}
)
return raw_token
async def verify_email_token(self, token: str) -> bool:
"""
Verify an email verification token and mark the user's email as verified.
:param token: The raw token from the user
:return: True if successful, False if token is invalid
"""
hashed_token = hash_token(token)
# Find and validate token
stored_token = await prisma.emailverificationtoken.find_first(
where={
"token": hashed_token,
"usedAt": None,
"expiresAt": {"gt": datetime.now(timezone.utc)},
}
)
if not stored_token:
return False
# Mark email as verified
await prisma.user.update(
where={"id": stored_token.userId},
data={"emailVerified": True},
)
# Mark token as used
await prisma.emailverificationtoken.update(
where={"id": stored_token.id},
data={"usedAt": datetime.now(timezone.utc)},
)
logger.info(f"Email verified for user {stored_token.userId}")
return True
async def verify_password_reset_token(self, token: str) -> Optional[str]:
"""
Verify a password reset token and return the user ID.

View File

@@ -0,0 +1,302 @@
{# Base Template for Auth Emails #}
{# Template variables:
data.message: the message to display in the email
data.title: the title of the email
data.unsubscribe_link: the link to unsubscribe from the email (optional for auth emails)
#}
<!doctype html>
<html lang="ltr" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=yes">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
<meta name="x-apple-disable-message-reformatting">
<!--[if !mso]>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<![endif]-->
<!--[if mso]>
<style>
* { font-family: sans-serif !important; }
</style>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<style type="text/css">
/* RESET STYLES */
html,
body {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
height: 100% !important;
}
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
.document {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
}
img {
border: 0;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
table {
border-collapse: collapse;
}
table,
td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
body,
table,
td,
a {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
h1,
h2,
h3,
h4,
h5,
p {
margin: 0;
word-break: break-word;
}
/* iOS BLUE LINKS */
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
/* ANDROID CENTER FIX */
div[style*="margin: 16px 0;"] {
margin: 0 !important;
}
/* MEDIA QUERIES */
@media all and (max-width:639px) {
.wrapper {
width: 100% !important;
}
.container {
width: 100% !important;
min-width: 100% !important;
padding: 0 !important;
}
.row {
padding-left: 20px !important;
padding-right: 20px !important;
}
.col-mobile {
width: 20px !important;
}
.col {
display: block !important;
width: 100% !important;
}
.mobile-center {
text-align: center !important;
float: none !important;
}
.mobile-mx-auto {
margin: 0 auto !important;
float: none !important;
}
.mobile-left {
text-align: center !important;
float: left !important;
}
.mobile-hide {
display: none !important;
}
.img {
width: 100% !important;
height: auto !important;
}
.ml-btn {
width: 100% !important;
max-width: 100% !important;
}
.ml-btn-container {
width: 100% !important;
max-width: 100% !important;
}
}
</style>
<style type="text/css">
@import url("https://assets.mlcdn.com/fonts-v2.css?version=1729862");
</style>
<style type="text/css">
@media screen {
body {
font-family: 'Poppins', sans-serif;
}
}
</style>
<title>{{data.title}}</title>
</head>
<body style="margin: 0 !important; padding: 0 !important; background-color:#070629;">
<div class="document" role="article" aria-roledescription="email" aria-label lang dir="ltr"
style="background-color:#070629; line-height: 100%; font-size:medium; font-size:max(16px, 1rem);">
<!-- Main Content -->
<table width="100%" align="center" cellspacing="0" cellpadding="0" border="0">
<tr>
<td class="background" bgcolor="#070629" align="center" valign="top" style="padding: 0 8px;">
<!-- Email Content -->
<table class="container" align="center" width="640" cellpadding="0" cellspacing="0" border="0"
style="max-width: 640px;">
<tr>
<td align="center">
<!-- Logo Section -->
<table class="container ml-4 ml-default-border" width="640" bgcolor="#E2ECFD" align="center" border="0"
cellspacing="0" cellpadding="0" style="width: 640px; min-width: 640px;">
<tr>
<td class="ml-default-border container" height="40" style="line-height: 40px; min-width: 640px;">
</td>
</tr>
<tr>
<td>
<table align="center" width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="row" align="center" style="padding: 0 50px;">
<img
src="https://storage.mlcdn.com/account_image/597379/8QJ8kOjXakVvfe1kJLY2wWCObU1mp5EiDLfBlbQa.png"
border="0" alt="" width="120" class="logo"
style="max-width: 120px; display: inline-block;">
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Main Content Section -->
<table class="container ml-6 ml-default-border" width="640" bgcolor="#E2ECFD" align="center" border="0"
cellspacing="0" cellpadding="0" style="color: #070629; width: 640px; min-width: 640px;">
<tr>
<td class="row" style="padding: 0 50px;">
{{data.message|safe}}
</td>
</tr>
</table>
<!-- Footer Section -->
<table class="container ml-10 ml-default-border" width="640" bgcolor="#ffffff" align="center" border="0"
cellspacing="0" cellpadding="0" style="width: 640px; min-width: 640px;">
<tr>
<td class="row" style="padding: 0 50px;">
<table align="center" width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td height="20" style="line-height: 20px;"></td>
</tr>
<tr>
<td>
<!-- Footer Content -->
<table align="center" width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="col" align="left" valign="middle" width="120">
<img
src="https://storage.mlcdn.com/account_image/597379/8QJ8kOjXakVvfe1kJLY2wWCObU1mp5EiDLfBlbQa.png"
border="0" alt="" width="120" class="logo"
style="max-width: 120px; display: inline-block;">
</td>
<td class="col" width="40" height="30" style="line-height: 30px;"></td>
<td class="col mobile-left" align="right" valign="middle" width="250">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center" valign="middle" width="18" style="padding: 0 5px 0 0;">
<a href="https://x.com/auto_gpt" target="blank" style="text-decoration: none;">
<img
src="https://assets.mlcdn.com/ml/images/icons/default/rounded_corners/black/x.png"
width="18" alt="x">
</a>
</td>
<td align="center" valign="middle" width="18" style="padding: 0 5px;">
<a href="https://discord.gg/autogpt" target="blank"
style="text-decoration: none;">
<img
src="https://assets.mlcdn.com/ml/images/icons/default/rounded_corners/black/discord.png"
width="18" alt="discord">
</a>
</td>
<td align="center" valign="middle" width="18" style="padding: 0 0 0 5px;">
<a href="https://agpt.co/" target="blank" style="text-decoration: none;">
<img
src="https://assets.mlcdn.com/ml/images/icons/default/rounded_corners/black/website.png"
width="18" alt="website">
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td height="15" style="line-height: 15px;"></td>
</tr>
<tr>
<td align="center" style="text-align: left!important;">
<p
style="font-family: 'Poppins', sans-serif; color: #070629; font-size: 12px; line-height: 150%; display: inline-block; margin-bottom: 0;">
This is an automated security email from AutoGPT. If you did not request this action, please ignore this email or contact support if you have concerns.
</p>
</td>
</tr>
<tr>
<td height="20" style="line-height: 20px;"></td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,65 @@
{# Email Verification Template #}
{# Variables:
verification_link: URL for email verification
user_name: Optional user name for personalization
frontend_url: Base frontend URL
#}
<table align="center" width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td height="30" style="line-height: 30px;"></td>
</tr>
<tr>
<td align="center">
<h1 style="font-family: 'Poppins', sans-serif; color: #070629; font-size: 28px; line-height: 125%; font-weight: bold; margin-bottom: 20px;">
Verify Your Email
</h1>
</td>
</tr>
<tr>
<td align="left">
<p style="font-family: 'Poppins', sans-serif; color: #070629; font-size: 16px; line-height: 165%; margin-bottom: 20px;">
{% if user_name %}Hi {{ user_name }},{% else %}Hi,{% endif %}
</p>
<p style="font-family: 'Poppins', sans-serif; color: #070629; font-size: 16px; line-height: 165%; margin-bottom: 20px;">
Welcome to AutoGPT! Please verify your email address by clicking the button below:
</p>
</td>
</tr>
<tr>
<td align="center" style="padding: 20px 0;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" bgcolor="#4285F4" style="border-radius: 8px;">
<a href="{{ verification_link }}" target="_blank"
style="display: inline-block; padding: 16px 36px; font-family: 'Poppins', sans-serif; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 8px;">
Verify Email
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="left">
<p style="font-family: 'Poppins', sans-serif; color: #070629; font-size: 16px; line-height: 165%; margin-bottom: 20px;">
This link will expire in <strong>24 hours</strong>.
</p>
<p style="font-family: 'Poppins', sans-serif; color: #070629; font-size: 16px; line-height: 165%; margin-bottom: 20px;">
If you didn't create an account with AutoGPT, you can safely ignore this email.
</p>
</td>
</tr>
<tr>
<td align="left">
<p style="font-family: 'Poppins', sans-serif; color: #888888; font-size: 14px; line-height: 165%; margin-bottom: 10px;">
If the button doesn't work, copy and paste this link into your browser:
</p>
<p style="font-family: 'Poppins', sans-serif; color: #4285F4; font-size: 14px; line-height: 165%; word-break: break-all;">
<a href="{{ verification_link }}" style="color: #4285F4; text-decoration: underline;">{{ verification_link }}</a>
</p>
</td>
</tr>
<tr>
<td height="30" style="line-height: 30px;"></td>
</tr>
</table>

View File

@@ -0,0 +1,65 @@
{# Password Reset Email Template #}
{# Variables:
reset_link: URL for password reset
user_name: Optional user name for personalization
frontend_url: Base frontend URL
#}
<table align="center" width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td height="30" style="line-height: 30px;"></td>
</tr>
<tr>
<td align="center">
<h1 style="font-family: 'Poppins', sans-serif; color: #070629; font-size: 28px; line-height: 125%; font-weight: bold; margin-bottom: 20px;">
Reset Your Password
</h1>
</td>
</tr>
<tr>
<td align="left">
<p style="font-family: 'Poppins', sans-serif; color: #070629; font-size: 16px; line-height: 165%; margin-bottom: 20px;">
{% if user_name %}Hi {{ user_name }},{% else %}Hi,{% endif %}
</p>
<p style="font-family: 'Poppins', sans-serif; color: #070629; font-size: 16px; line-height: 165%; margin-bottom: 20px;">
We received a request to reset your password for your AutoGPT account. Click the button below to create a new password:
</p>
</td>
</tr>
<tr>
<td align="center" style="padding: 20px 0;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" bgcolor="#4285F4" style="border-radius: 8px;">
<a href="{{ reset_link }}" target="_blank"
style="display: inline-block; padding: 16px 36px; font-family: 'Poppins', sans-serif; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 8px;">
Reset Password
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="left">
<p style="font-family: 'Poppins', sans-serif; color: #070629; font-size: 16px; line-height: 165%; margin-bottom: 20px;">
This link will expire in <strong>1 hour</strong> for security reasons.
</p>
<p style="font-family: 'Poppins', sans-serif; color: #070629; font-size: 16px; line-height: 165%; margin-bottom: 20px;">
If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.
</p>
</td>
</tr>
<tr>
<td align="left">
<p style="font-family: 'Poppins', sans-serif; color: #888888; font-size: 14px; line-height: 165%; margin-bottom: 10px;">
If the button doesn't work, copy and paste this link into your browser:
</p>
<p style="font-family: 'Poppins', sans-serif; color: #4285F4; font-size: 14px; line-height: 165%; word-break: break-all;">
<a href="{{ reset_link }}" style="color: #4285F4; text-decoration: underline;">{{ reset_link }}</a>
</p>
</td>
</tr>
<tr>
<td height="30" style="line-height: 30px;"></td>
</tr>
</table>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ import backend.data.graph
import backend.data.user
import backend.integrations.webhooks.utils
import backend.server.auth
import backend.server.routers.oauth
import backend.server.routers.postmark.postmark
import backend.server.routers.v1
import backend.server.v2.admin.credit_admin_routes
@@ -299,6 +300,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)

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
-- AlterEnum
ALTER TYPE "APIKeyPermission" ADD VALUE 'IDENTITY';
-- AlterTable
ALTER TABLE "OAuthApplication" ADD COLUMN "logoUrl" TEXT;

View File

@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "EmailVerificationToken" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmailVerificationToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "EmailVerificationToken_token_key" ON "EmailVerificationToken"("token");
-- CreateIndex
CREATE INDEX "EmailVerificationToken_userId_idx" ON "EmailVerificationToken"("userId");
-- CreateIndex
CREATE INDEX "EmailVerificationToken_token_idx" ON "EmailVerificationToken"("token");
-- AddForeignKey
ALTER TABLE "EmailVerificationToken" ADD CONSTRAINT "EmailVerificationToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -114,6 +114,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"

View File

@@ -45,8 +45,9 @@ model User {
timezone String @default("not-set")
// Auth token relations
RefreshTokens RefreshToken[]
PasswordResetTokens PasswordResetToken[]
RefreshTokens RefreshToken[]
PasswordResetTokens PasswordResetToken[]
EmailVerificationTokens EmailVerificationToken[]
// Relations
@@ -70,6 +71,12 @@ model User {
IntegrationWebhooks IntegrationWebhook[]
NotificationBatches UserNotificationBatch[]
PendingHumanReviews PendingHumanReview[]
// OAuth Provider relations
OAuthApplications OAuthApplication[]
OAuthAuthorizationCodes OAuthAuthorizationCode[]
OAuthAccessTokens OAuthAccessToken[]
OAuthRefreshTokens OAuthRefreshToken[]
}
// Refresh tokens for JWT authentication
@@ -101,6 +108,20 @@ model PasswordResetToken {
@@index([token])
}
// Email verification tokens
model EmailVerificationToken {
id String @id @default(uuid())
token String @unique // SHA-256 hashed token
userId String
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
@@index([token])
}
enum OnboardingStep {
// Introductory onboarding (Library)
WELCOME
@@ -962,6 +983,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
@@ -1013,3 +1035,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
}

View File

@@ -23,10 +23,11 @@ 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.server.auth.service import AuthService
@@ -468,7 +469,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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,8 +11,16 @@ import { environment } from "@/services/environment";
import { Controller, FormProvider } from "react-hook-form";
import { LoadingLogin } from "./components/LoadingLogin";
import { useLoginPage } from "./useLoginPage";
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,
@@ -104,7 +112,7 @@ export default function LoginPage() {
</FormProvider>
<AuthCard.BottomText
text="Don't have an account?"
link={{ text: "Sign up", href: "/signup" }}
link={{ text: "Sign up", href: signupHref }}
/>
</AuthCard>
<MobileWarningBanner />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,8 +15,16 @@ import { WarningOctagon } from "@phosphor-icons/react";
import { Controller, FormProvider } from "react-hook-form";
import { LoadingSignup } from "./components/LoadingSignup";
import { useSignupPage } from "./useSignupPage";
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,
@@ -176,7 +184,7 @@ export default function SignupPage() {
<AuthCard.BottomText
text="Already a member?"
link={{ text: "Log in", href: "/login" }}
link={{ text: "Log in", href: loginHref }}
/>
</AuthCard>
<MobileWarningBanner />

View File

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

View File

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

View File

@@ -5627,6 +5627,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"],
@@ -5675,29 +6038,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" },
@@ -5712,28 +6076,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",
@@ -5871,6 +6248,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" },
@@ -6120,6 +6563,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": {
@@ -8138,6 +8661,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": [
@@ -10221,6 +10823,97 @@
"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": {
"access_token": { "type": "string", "title": "Access Token" },
@@ -10283,6 +10976,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": {

View File

@@ -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({
<CardWrapper className={className}>
<div className="relative space-y-4 p-6">
<ErrorHeader />
<ErrorMessage errorMessage={errorMessage} context={context} />
<ActionButtons
onRetry={onRetry}
responseError={responseError}
httpError={httpError}
<ErrorMessage
errorMessage={errorMessage}
context={context}
hint={hint}
/>
{isOurProblem && (
<ActionButtons
onRetry={onRetry}
responseError={responseError}
httpError={httpError}
context={context}
/>
)}
</div>
</CardWrapper>
);

View File

@@ -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 (
<div className="space-y-2">
<Text variant="body" className="text-zinc-700">
@@ -17,6 +18,13 @@ export function ErrorMessage({ errorMessage, context }: Props) {
{errorMessage}
</Text>
</div>
{hint && (
<div className="!mt-4">
<Text variant="body" className="text-zinc-700">
{hint}
</Text>
</div>
)}
</div>
);
}

View File

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

View File

@@ -15,11 +15,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)$).*)",
],
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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