From d2596b7385ebb268a7a56f5d18824a68c499ed7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Thu, 10 Jul 2025 17:58:03 +0200 Subject: [PATCH 1/3] :sparkles: initial commit of OAuth and JWT support This needs some love in validation and token processing --- api/oauth/__init__.py | 5 + api/oauth/oauth_provider.py | 433 +++++++++++++++++++++++++ api/oauth/router.py | 6 + api/oauth/utils.py | 51 +++ ayon_server/api/server.py | 58 ++++ ayon_server/oauth/__init__.py | 11 + ayon_server/oauth/jwt_manager.py | 148 +++++++++ ayon_server/oauth/models.py | 116 +++++++ ayon_server/oauth/server.py | 441 ++++++++++++++++++++++++++ ayon_server/oauth/storage.py | 236 ++++++++++++++ pyproject.toml | 1 + schemas/migrations/00000007_oauth.sql | 37 +++ schemas/schema.public.sql | 35 ++ 13 files changed, 1578 insertions(+) create mode 100644 api/oauth/__init__.py create mode 100644 api/oauth/oauth_provider.py create mode 100644 api/oauth/router.py create mode 100644 api/oauth/utils.py create mode 100644 ayon_server/oauth/__init__.py create mode 100644 ayon_server/oauth/jwt_manager.py create mode 100644 ayon_server/oauth/models.py create mode 100644 ayon_server/oauth/server.py create mode 100644 ayon_server/oauth/storage.py create mode 100644 schemas/migrations/00000007_oauth.sql diff --git a/api/oauth/__init__.py b/api/oauth/__init__.py new file mode 100644 index 000000000..1f91a012a --- /dev/null +++ b/api/oauth/__init__.py @@ -0,0 +1,5 @@ +# OAuth Provider Implementation for AYON Server + +from .oauth_provider import router + +__all__ = ["router"] diff --git a/api/oauth/oauth_provider.py b/api/oauth/oauth_provider.py new file mode 100644 index 000000000..4a0cfac1a --- /dev/null +++ b/api/oauth/oauth_provider.py @@ -0,0 +1,433 @@ +"""OAuth provider endpoints for AYON Server.""" + +from typing import Any +from urllib.parse import urlencode + +from fastapi import Form, Query, Request +from fastapi.responses import RedirectResponse + +from ayon_server.api.dependencies import CurrentUser +from ayon_server.api.responses import EmptyResponse +from ayon_server.exceptions import ( + BadRequestException, + ForbiddenException, + NotFoundException, +) +from ayon_server.oauth import JWTTokenManager +from ayon_server.oauth.models import ( + JWTTokenResponse, + OAuthClient, + OAuthClientCreate, + OAuthErrorResponse, + OAuthIntrospectionResponse, + OAuthTokenResponse, + OAuthUserInfoResponse, +) +from ayon_server.oauth.server import OAuthServer +from ayon_server.oauth.storage import OAuthStorage + +from .router import router + +# Initialize OAuth server +oauth_server = OAuthServer() + + +@router.get("/clients") +async def list_oauth_clients(current_user: CurrentUser) -> list[OAuthClient]: + """List active OAuth clients (admin only).""" + if not current_user.is_admin: + raise ForbiddenException("Admin access required") + + return await OAuthStorage.get_clients(active=True) + return [] + + +@router.post("/clients") +async def create_oauth_client( + current_user: CurrentUser, + client_data: OAuthClientCreate +) -> OAuthClient: + """Create a new OAuth client (admin only).""" + if not current_user.is_admin: + raise ForbiddenException("Admin access required") + + return await OAuthStorage.create_client(client_data) + + +@router.get("/clients/{client_id}") +async def get_oauth_client( + current_user: CurrentUser, + client_id: str +) -> OAuthClient: + """Get OAuth client by ID (admin only).""" + if not current_user.is_admin: + raise ForbiddenException("Admin access required") + + client = await OAuthStorage.get_client(client_id) + if not client: + raise NotFoundException("Client not found") + + return client + + +@router.delete("/clients/{client_id}") +async def delete_oauth_client( + current_user: CurrentUser, + client_id: str +) -> EmptyResponse: + """Delete OAuth client (admin only).""" + if not current_user.is_admin: + raise ForbiddenException("Admin access required") + + await OAuthStorage.delete_client(client_id) + return EmptyResponse() + +@router.get("/authorize", response_model=None) +async def authorize_endpoint( + request: Request, + current_user: CurrentUser, + response_type: str = Query(..., description="Response type"), + client_id: str = Query(..., description="Client ID"), + redirect_uri: str = Query(None, description="Redirect URI"), + scope: str = Query("read", description="Requested scope"), + state: str = Query(None, description="State parameter"), + code_challenge: str = Query(None, description="PKCE code challenge"), + code_challenge_method: str = Query(None, description="PKCE method"), +) -> Any: + """OAuth authorization endpoint.""" + + # Validate client + client = await OAuthStorage.get_client(client_id) + if not client: + error_params = { + "error": "invalid_client", + "error_description": "Invalid client_id" + } + if state: + error_params["state"] = state + if redirect_uri: + return RedirectResponse( + f"{redirect_uri}?{urlencode(error_params)}" + ) + raise BadRequestException("Invalid client_id") + + # Validate redirect_uri + if redirect_uri and redirect_uri not in client.redirect_uris: + raise BadRequestException("Invalid redirect_uri") + + if not redirect_uri: + redirect_uri = client.redirect_uris[0] if client.redirect_uris else None + + if not redirect_uri: + raise BadRequestException("No valid redirect_uri") + + # Validate response_type + if response_type not in client.response_types: + error_params = { + "error": "unsupported_response_type", + "error_description": f"Response type '{response_type}' not supported" + } + if state: + error_params["state"] = state + return RedirectResponse(f"{redirect_uri}?{urlencode(error_params)}") + + # If user is not authenticated, redirect to login + if not current_user: + # Store authorization request and redirect to login + login_url = f"/auth/login?next={request.url}" + return RedirectResponse(login_url) + + # Generate authorization code and redirect + headers, redirect_url, status = await oauth_server.create_authorization_response( + uri=str(request.url), + user_name=current_user.name + ) + + if status == 302: + return RedirectResponse(redirect_url) + else: + # redirect to user consent page + # TODO: Implement user consent page in frontend + return RedirectResponse( + f"/consent?client_name={client.client_name}&" + f"client_id={client.client_id}&" + f"response_type={response_type}&" + f"redirect_uri={redirect_uri}&" + f"scope={scope}&" + f"state={state or ''}&" + f"code_challenge={code_challenge or ''}&" + f"code_challenge_method={code_challenge_method or ''}" + ) + + +@router.post("/consent") +async def consent_endpoint( + request: Request, + current_user: CurrentUser, + client_id: str = Form(...), + response_type: str = Form(...), + redirect_uri: str = Form(...), + scope: str = Form("read"), + state: str = Form(None), + code_challenge: str = Form(None), + code_challenge_method: str = Form(None), + approved: str = Form(...), +) -> RedirectResponse: + """Handle OAuth consent.""" + + if approved.lower() != "true": + # User denied access + error_params = { + "error": "access_denied", + "error_description": "User denied access" + } + if state: + error_params["state"] = state + return RedirectResponse(f"{redirect_uri}?{urlencode(error_params)}") + + # User approved, generate authorization code + auth_url = ( + f"{request.url.scheme}://{request.url.netloc}/oauth/authorize?" + f"response_type={response_type}&client_id={client_id}&" + f"redirect_uri={redirect_uri}&scope={scope}" + ) + if state: + auth_url += f"&state={state}" + if code_challenge: + auth_url += f"&code_challenge={code_challenge}" + if code_challenge_method: + auth_url += f"&code_challenge_method={code_challenge_method}" + + headers, redirect_url, status = await oauth_server.create_authorization_response( + uri=auth_url, + user_name=current_user.name + ) + + return RedirectResponse(redirect_url) + + +@router.post("/token") +async def token_endpoint( + grant_type: str = Form(...), + code: str = Form(None), + redirect_uri: str = Form(None), + client_id: str = Form(None), + client_secret: str = Form(None), + refresh_token: str = Form(None), + scope: str = Form(None), + code_verifier: str = Form(None), +) -> OAuthTokenResponse | OAuthErrorResponse: + """OAuth token endpoint.""" + + # Create form body for server + form_data = { + "grant_type": grant_type, + "client_id": client_id, + "client_secret": client_secret, + } + + if code: + form_data["code"] = code + if redirect_uri: + form_data["redirect_uri"] = redirect_uri + if refresh_token: + form_data["refresh_token"] = refresh_token + if scope: + form_data["scope"] = scope + if code_verifier: + form_data["code_verifier"] = code_verifier + + # Convert to URL-encoded string + from urllib.parse import urlencode + body = urlencode({k: v for k, v in form_data.items() if v is not None}) + + response_data, response_body, status_code = ( + await oauth_server.create_token_response(uri="", body=body) + ) + + if status_code == 200: + return OAuthTokenResponse(**response_data) + else: + return OAuthErrorResponse(**response_data) + + +@router.post("/jwt") +async def jwt_token_endpoint( + current_user: CurrentUser, + include_id_token: bool = Form(default=False), + expires_in: int = Form(default=3600), + audience: str = Form(None), +) -> JWTTokenResponse | OAuthErrorResponse: + """Generate JWT tokens from OAuth access token. + + This endpoint generates JWT access and ID tokens based on the authenticated user. + It can be used to provide JWT tokens for clients that require them, such as + those using OpenID Connect or other JWT-based authentication mechanisms. + + Todo: + - Implement proper OAuth token validation + - Handle client_id and scope extraction from OAuth token + + """ + + try: + # This is a placeholder for OAuth token validation. + client_id = "default" + scope = "read" + + # Generate JWT access token + jwt_access_token = JWTTokenManager.create_jwt_access_token( + user=current_user, + client_id=client_id, + scope=scope, + expires_in=expires_in, + audience=audience + ) + + # Generate ID token if requested (OpenID Connect) + jwt_id_token = None + if include_id_token: + jwt_id_token = JWTTokenManager.create_jwt_id_token( + user=current_user, + client_id=client_id, + expires_in=expires_in + ) + + return JWTTokenResponse( + access_token=jwt_access_token, + id_token=jwt_id_token, + token_type="Bearer", + expires_in=expires_in, + scope=scope + ) + + except Exception as e: + return OAuthErrorResponse( + error="server_error", + error_description=f"Failed to generate JWT token: {str(e)}" + ) + + +@router.post("/jwt/exchange") +async def jwt_exchange_endpoint( + request: Request, + include_id_token: bool = Form(default=False), + expires_in: int = Form(default=3600), + audience: str = Form(None), +) -> JWTTokenResponse | OAuthErrorResponse: + """Exchange OAuth access token for JWT tokens.""" + + # Get access token from Authorization header + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + return OAuthErrorResponse( + error="invalid_request", + error_description="Missing or invalid Authorization header" + ) + + access_token = auth_header[7:] # Remove "Bearer " prefix + + try: + # Introspect the OAuth token to get user and client info + introspection_result = await oauth_server.introspect_token(access_token) + + if not introspection_result.get("active"): + return OAuthErrorResponse( + error="invalid_token", + error_description="Token is not active" + ) + + # Get user information + username = introspection_result.get("username") + if not username: + return OAuthErrorResponse( + error="invalid_token", + error_description="Token does not contain user information" + ) + + # Get user entity + from ayon_server.entities import UserEntity + user = await UserEntity.load(username) + if not user: + return OAuthErrorResponse( + error="invalid_token", + error_description="User not found" + ) + + # Extract client and scope information + client_id = introspection_result.get("client_id", "unknown") + scope = introspection_result.get("scope", "read") + + # Generate JWT access token + jwt_access_token = JWTTokenManager.create_jwt_access_token( + user=user, + client_id=client_id, + scope=scope, + expires_in=expires_in, + audience=audience + ) + + # Generate ID token if requested (OpenID Connect) + jwt_id_token = None + if include_id_token: + jwt_id_token = JWTTokenManager.create_jwt_id_token( + user=user, + client_id=client_id, + expires_in=expires_in + ) + + return JWTTokenResponse( + access_token=jwt_access_token, + id_token=jwt_id_token, + token_type="Bearer", + expires_in=expires_in, + scope=scope + ) + + except Exception as e: + return OAuthErrorResponse( + error="server_error", + error_description=f"Failed to exchange token: {str(e)}" + ) + + +@router.post("/introspect") +async def introspect_endpoint( + token: str = Form(...), + token_type_hint: str = Form(None), +) -> OAuthIntrospectionResponse: + """OAuth token introspection endpoint.""" + + result = await oauth_server.introspect_token(token) + return OAuthIntrospectionResponse(**result) + + +@router.get("/userinfo") +async def userinfo_endpoint(current_user: CurrentUser) -> OAuthUserInfoResponse: + """OAuth user info endpoint.""" + + return OAuthUserInfoResponse( + sub=current_user.name, + name=current_user.attrib.get("fullName"), + preferred_username=current_user.name, + email=current_user.attrib.get("email"), + email_verified=bool(current_user.attrib.get("email")), + ) + +@router.get("/validate") +async def validate_jwt_endpoint( + token: str = Query(..., description="JWT token to validate") +) -> dict[str, Any]: + """Validate a JWT token and return its claims.""" + + try: + payload = JWTTokenManager.validate_jwt_access_token(token) + return { + "valid": True, + "claims": payload + } + except Exception as e: + return { + "valid": False, + "error": str(e) + } diff --git a/api/oauth/router.py b/api/oauth/router.py new file mode 100644 index 000000000..787fcd0fa --- /dev/null +++ b/api/oauth/router.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter + +router = APIRouter( + prefix="/oauth", + tags=["OAuth"], +) diff --git a/api/oauth/utils.py b/api/oauth/utils.py new file mode 100644 index 000000000..3031a1d39 --- /dev/null +++ b/api/oauth/utils.py @@ -0,0 +1,51 @@ +"""OAuth utilities for AYON Server.""" + +from typing import Any + +from ayon_server.entities import UserEntity +from ayon_server.exceptions import UnauthorizedException +from ayon_server.oauth.storage import OAuthStorage + + +async def verify_oauth_token(token: str) -> UserEntity: + """Verify OAuth access token and return user. + + Args: + token (str): The OAuth access token to verify. + + Returns: + UserEntity: The user associated with the OAuth token. + + Raises: + UnauthorizedException: If the token is invalid or user not found. + + """ + token_data = await OAuthStorage.get_access_token(token) + + if not token_data: + raise UnauthorizedException("Invalid OAuth token") + + # Load user entity + user = await UserEntity.load(token_data["user_name"]) + if not user: + raise UnauthorizedException("User not found") + + return user + + +async def get_oauth_token_info(token: str) -> dict[str, Any] | None: + """Get OAuth token information. + + Args: + token (str): The OAuth access token. + + Returns: + dict[str, Any] | None: The token information if found, otherwise None. + + """ + return await OAuthStorage.get_access_token(token) + + +async def revoke_oauth_token(token: str) -> None: + """Revoke OAuth access token.""" + await OAuthStorage.revoke_access_token(token) diff --git a/ayon_server/api/server.py b/ayon_server/api/server.py index 23e7d185e..33945eb26 100644 --- a/ayon_server/api/server.py +++ b/ayon_server/api/server.py @@ -2,6 +2,7 @@ import os import pathlib import sys +from typing import Any, TypedDict import fastapi from fastapi import Request @@ -20,6 +21,7 @@ from ayon_server.config import ayonconfig from ayon_server.graphql import router as graphql_router from ayon_server.logging import log_traceback, logger +from ayon_server.oauth import JWTTokenManager # # We just need the log collector to be initialized. @@ -167,6 +169,62 @@ async def ws_endpoint(websocket: WebSocket) -> None: except KeyError: pass +# +# OpenID Connect Discovery +# + +class OpenIDResponse(TypedDict): + issuer: str + authorization_endpoint: str + token_endpoint: str + userinfo_endpoint: str + introspection_endpoint: str + jwks_uri: str + jwt_endpoint: str + jwt_exchange_endpoint: str + jwt_validation_endpoint: str + response_types_supported: list[str] + grant_types_supported: list[str] + subject_types_supported: list[str] + id_token_signing_alg_values_supported: list[str] + scopes_supported: list[str] + token_endpoint_auth_methods_supported: list[str] + code_challenge_methods_supported: list[str] + + +@app.get("/.well-known/openid_configuration") +async def openid_configuration(request: Request) -> OpenIDResponse: + """OpenID Connect Discovery endpoint.""" + + base_url = f"{request.url.scheme}://{request.url.netloc}" + + return { + "issuer": base_url, + "authorization_endpoint": f"{base_url}/api/oauth/authorize", + "token_endpoint": f"{base_url}/api/oauth/token", + "userinfo_endpoint": f"{base_url}/api/oauth/userinfo", + "introspection_endpoint": f"{base_url}/oauth/api/introspect", + "jwks_uri": f"{base_url}/.well-known/jwks.json", + "jwt_endpoint": f"{base_url}/oauth/jwt", + "jwt_exchange_endpoint": f"{base_url}/api/oauth/jwt/exchange", + "jwt_validation_endpoint": f"{base_url}/api/oauth/validate", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["HS256"], + "scopes_supported": ["openid", "profile", "email", "read", "write"], + "token_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post" + ], + "code_challenge_methods_supported": ["S256", "plain"], + } + + +@app.get("/.well-known/jwks.json") +async def jwks_endpoint() -> dict[str, Any]: + """JSON Web Key Set endpoint for token verification.""" + return JWTTokenManager.get_jwks() # # REST endpoints diff --git a/ayon_server/oauth/__init__.py b/ayon_server/oauth/__init__.py new file mode 100644 index 000000000..445f4c320 --- /dev/null +++ b/ayon_server/oauth/__init__.py @@ -0,0 +1,11 @@ +"""OAuth related functionality for AYON Server.""" +from .jwt_manager import JWTTokenManager +from .server import AyonOAuthRequestValidator, OAuthServer +from .storage import OAuthStorage + +__all__ = [ + "AyonOAuthRequestValidator", + "JWTTokenManager", + "OAuthServer", + "OAuthStorage", +] diff --git a/ayon_server/oauth/jwt_manager.py b/ayon_server/oauth/jwt_manager.py new file mode 100644 index 000000000..950f023ea --- /dev/null +++ b/ayon_server/oauth/jwt_manager.py @@ -0,0 +1,148 @@ +"""JWT token utilities for OAuth provider.""" + +import time +from typing import Any + +import jwt + +from ayon_server.config import ayonconfig +from ayon_server.entities import UserEntity + + +class JWTTokenManager: + """Manages JWT token creation and validation.""" + + @staticmethod + def _get_jwt_secret() -> str: + """Get JWT signing secret.""" + # Use the same secret as AYON's JWT implementation + return ayonconfig.secret + + @staticmethod + def _get_issuer() -> str: + """Get JWT issuer.""" + return ayonconfig.site_id or "ayon-server" + + @classmethod + def create_jwt_access_token( + cls, + user: UserEntity, + client_id: str, + scope: str = "read", + expires_in: int = 3600, + audience: str | None = None + ) -> str: + """Create a JWT access token.""" + now = time.time() + + payload = { + # Standard JWT claims + "iss": cls._get_issuer(), # Issuer + "sub": user.name, # Subject (user identifier) + "aud": audience or client_id, # Audience (client or resource server) + "exp": int(now + expires_in), # Expiration time + "iat": int(now), # Issued at + "jti": f"oauth_{int(now)}_{user.name}", # JWT ID + + # OAuth-specific claims + "client_id": client_id, + "scope": scope, + "token_type": "access_token", + + # User-specific claims + "username": user.name, + "email": user.attrib.get("email"), + "full_name": user.attrib.get("fullName"), + "is_admin": user.is_admin, + "is_manager": user.is_manager, + "is_service": user.is_service, + "active": user.active, + + # AYON-specific claims + "access_groups": user.data.get("accessGroups", {}), + } + + return jwt.encode(payload, cls._get_jwt_secret(), algorithm="HS256") + + @classmethod + def create_jwt_id_token( + cls, + user: UserEntity, + client_id: str, + nonce: str | None = None, + expires_in: int = 3600 + ) -> str: + """Create an OpenID Connect ID token.""" + now = time.time() + + payload = { + # Standard OIDC claims + "iss": cls._get_issuer(), + "sub": user.name, + "aud": client_id, + "exp": int(now + expires_in), + "iat": int(now), + "auth_time": int(now), # Time when user was authenticated + + # User profile claims + "name": user.attrib.get("fullName"), + "preferred_username": user.name, + "email": user.attrib.get("email"), + "email_verified": bool(user.attrib.get("email")), + "updated_at": int( + user.updated_at.timestamp()) if user.updated_at else int(now), + + # AYON-specific claims + "is_admin": user.is_admin, + "is_manager": user.is_manager, + "is_service": user.is_service, + "active": user.active, + } + + if nonce: + payload["nonce"] = nonce + + return jwt.encode(payload, cls._get_jwt_secret(), algorithm="HS256") + + @classmethod + def decode_jwt_token(cls, token: str) -> dict[str, Any]: + """Decode and validate a JWT token.""" + try: + payload = jwt.decode( + token, + cls._get_jwt_secret(), + algorithms=["HS256"], + issuer=cls._get_issuer() + ) + return payload + except jwt.ExpiredSignatureError: + raise ValueError("Token has expired") + except jwt.InvalidTokenError as e: + raise ValueError(f"Invalid token: {str(e)}") + + @classmethod + def validate_jwt_access_token(cls, token: str) -> dict[str, Any]: + """Validate a JWT access token and return claims.""" + payload = cls.decode_jwt_token(token) + + # Verify it's an access token + if payload.get("token_type") != "access_token": + raise ValueError("Not an access token") + + return payload + @classmethod + def get_jwks(cls) -> dict[str, Any]: + """Get JSON Web Key Set for token verification.""" + # For symmetric keys (HS256), we don't expose the key + # In production, you might want to use asymmetric keys (RS256) + return { + "keys": [ + { + "kty": "oct", # Key type: octet sequence (symmetric) + "alg": "HS256", # Algorithm + "use": "sig", # Usage: signature + "kid": "ayon-oauth-key-1", # Key ID + # Note: We don't include the actual key for security + } + ] + } diff --git a/ayon_server/oauth/models.py b/ayon_server/oauth/models.py new file mode 100644 index 000000000..5a7fd7441 --- /dev/null +++ b/ayon_server/oauth/models.py @@ -0,0 +1,116 @@ +"""OAuth models for AYON Server OAuth provider implementation.""" + +from pydantic import Field + +from ayon_server.types import OPModel + + +class OAuthClient(OPModel): + """OAuth client model.""" + client_id: str = Field(..., description="Unique client identifier") + client_secret: str | None = Field(None, description="Client secret (hashed)") + client_name: str = Field(..., description="Human-readable client name") + redirect_uris: list[str] = Field( + default_factory=list, description="Allowed redirect URIs" + ) + grant_types: list[str] = Field( + default_factory=lambda: ["authorization_code", "refresh_token"], + description="Allowed grant types" + ) + response_types: list[str] = Field( + default_factory=lambda: ["code"], + description="Allowed response types" + ) + scope: str = Field(default="read", description="Default scope") + client_type: str = Field( + default="confidential", description="Client type (public/confidential)" + ) + is_active: bool = Field(default=True, description="Whether client is active") + created_at: float = Field(..., description="Creation timestamp") + updated_at: float = Field(..., description="Last update timestamp") + + +class OAuthClientCreate(OPModel): + """Model for creating OAuth clients.""" + client_name: str = Field(..., description="Human-readable client name") + redirect_uris: list[str] = Field(..., description="Allowed redirect URIs") + grant_types: list[str] = Field( + default_factory=lambda: ["authorization_code", "refresh_token"], + description="Allowed grant types" + ) + response_types: list[str] = Field( + default_factory=lambda: ["code"], + description="Allowed response types" + ) + scope: str = Field(default="read", description="Default scope") + client_type: str = Field( + default="confidential", description="Client type (public/confidential)" + ) + + +class OAuthTokenResponse(OPModel): + """OAuth token response model.""" + access_token: str = Field(..., description="Access token") + token_type: str = Field(default="Bearer", description="Token type") + expires_in: int | None = Field(None, description="Token expiration in seconds") + refresh_token: str | None = Field(None, description="Refresh token") + scope: str | None = Field(None, description="Granted scope") + + +class OAuthErrorResponse(OPModel): + """OAuth error response model.""" + error: str = Field(..., description="Error code") + error_description: str | None = Field(None, description="Error description") + error_uri: str | None = Field(None, description="Error URI") + state: str | None = Field(None, description="State parameter") + + +class OAuthIntrospectionResponse(OPModel): + """OAuth token introspection response model.""" + active: bool = Field(..., description="Whether token is active") + scope: str | None = Field(None, description="Token scope") + client_id: str | None = Field(None, description="Client identifier") + username: str | None = Field(None, description="Username") + token_type: str | None = Field(None, description="Token type") + exp: int | None = Field(None, description="Expiration timestamp") + iat: int | None = Field(None, description="Issued at timestamp") + sub: str | None = Field(None, description="Subject") + + +class OAuthUserInfoResponse(OPModel): + """OAuth user info response model.""" + sub: str = Field(..., description="Subject identifier") + name: str | None = Field(None, description="Full name") + preferred_username: str | None = Field(None, description="Preferred username") + email: str | None = Field(None, description="Email address") + email_verified: bool | None = Field(None, description="Whether email is verified") + + +class OAuthConsentRequest(OPModel): + """OAuth consent request model.""" + client_id: str = Field(..., description="Client identifier") + scope: str | None = Field(None, description="Requested scope") + approved: bool = Field(..., description="Whether user approved the request") + + +class JWTTokenResponse(OPModel): + """JWT token response model.""" + access_token: str = Field(..., description="JWT access token") + id_token: str | None = Field(None, description="JWT ID token (OIDC)") + token_type: str = Field(default="Bearer", description="Token type") + expires_in: int = Field(default=3600, description="Token expiration in seconds") + scope: str = Field(default="read", description="Token scope") + + +class JWTTokenRequest(OPModel): + """JWT token request model.""" + token_type: str = Field( + default="access_token", description="Type of JWT token to generate" + ) + include_id_token: bool = Field( + default=False, description="Whether to include ID token" + ) + expires_in: int = Field( + default=3600, description="Token expiration in seconds" + ) + audience: str | None = Field(None, description="Token audience") diff --git a/ayon_server/oauth/server.py b/ayon_server/oauth/server.py new file mode 100644 index 000000000..8419da141 --- /dev/null +++ b/ayon_server/oauth/server.py @@ -0,0 +1,441 @@ +"""OAuth server implementation using oauthlib.""" + +from typing import Any +from urllib.parse import urlencode, urlparse + +from oauthlib.common import Request, generate_token +from oauthlib.oauth2 import RequestValidator, WebApplicationServer +from oauthlib.oauth2.rfc6749.errors import InvalidClientError, OAuth2Error + +from ayon_server.auth.utils import hash_password +from ayon_server.entities import UserEntity +from ayon_server.oauth import JWTTokenManager + +from .storage import OAuthStorage + + +class AyonOAuthRequestValidator(RequestValidator): + """OAuth request validator for AYON server.""" + + def authenticate_client(self, request: Request, *args, **kwargs) -> bool: + """Authenticate the client.""" + if not request.client_id: + return False + + client = None + if hasattr(request, '_client'): + client = request._client + else: + # This will be handled in validate_client_id + pass + + if not client: + return False + + # For public clients, no secret is required + if client.client_type == "public": + return True + + # For confidential clients, check the secret + if not request.client_secret: + return False + + # Verify client secret + hashed_secret = hash_password(request.client_secret) + return client.client_secret == hashed_secret + + def client_authentication_required( + self, request: Request, *args, **kwargs) -> bool: + """Check if client authentication is required.""" + # Authentication is required for confidential clients + if hasattr(request, '_client') and request._client: + return request._client.client_type == "confidential" + return True + + def get_default_scopes( + self, client_id: str, request, *args, **kwargs) -> list[str]: + """Get default scopes for client.""" + return ["read"] + + def get_default_redirect_uri( + self, client_id: str, request) -> str | None: + """Get default redirect URI for client. + + Args: + client_id (str): The ID of the OAuth client. + request: The request object containing client information. + + Returns: + str | None: The default redirect URI if available, otherwise None. + + """ + if hasattr(request, '_client') and request._client: + if request._client.redirect_uris: + return request._client.redirect_uris[0] + return None + + def invalidate_authorization_code( + self, client_id: str, code: str, request, *args, **kwargs) -> None: + """Invalidate authorization code. + + This is already handled in get_authorization_code() + + """ + pass + + def revoke_token( + self, token: str, request: Request, *args, **kwargs) -> None: + """Revoke a token.""" + + # Try to revoke as access token + # Try to revoke as refresh token + pass # This will be implemented based on token type + + def save_authorization_code( + self, client_id: str, code: dict, request, *args, **kwargs) -> None: + """Save authorization code.""" + # This is handled in the authorization endpoint + pass + + def save_bearer_token(self, token: dict, request, *args, **kwargs) -> None: + """Save bearer token.""" + # This is handled in the token endpoint + pass + + def validate_bearer_token(self, token: str, scopes: list[str], request) -> bool: + """Validate bearer token.""" + # This will be called during token introspection + return False # Implemented separately + + def validate_client_id(self, client_id: str, request, *args, **kwargs) -> bool: + """Validate client ID.""" + # Store client for later use + if not hasattr(request, '_client') or not request._client: + return False + return request._client.is_active + + def validate_code( + self, client_id: str, code: str, client, request, *args, **kwargs) -> bool: + """Validate authorization code.""" + # This will be handled in get_authorization_code + return True + + def validate_grant_type( + self, client_id: str, grant_type: str, + client, request, *args, **kwargs) -> bool: + """Validate grant type.""" + if hasattr(request, '_client') and request._client: + return grant_type in request._client.grant_types + return False + + def validate_redirect_uri( + self, client_id: str, redirect_uri: str, + request, *args, **kwargs) -> bool: + """Validate redirect URI.""" + if hasattr(request, '_client') and request._client: + return redirect_uri in request._client.redirect_uris + return False + + def validate_response_type( + self, client_id: str, response_type: str, + client, request, *args, **kwargs) -> bool: + """Validate response type.""" + if hasattr(request, '_client') and request._client: + return response_type in request._client.response_types + return False + + def validate_scopes( + self, client_id: str, scopes: list[str], client, + request, *args, **kwargs) -> bool: + """Validate scopes.""" + # For now, allow all requested scopes + return True + + def validate_user( + self, username: str, + password: str, client, request, *args, **kwargs) -> bool: + """Validate user credentials (for password grant).""" + # This would use the existing password authentication + return False # Not implemented for security reasons + + def get_authorization_code( + self, client_id: str, code: str, + redirect_uri: str, request) -> dict[str, Any] | None: + """Get authorization code.""" + # This will be implemented async in the main server + return None + + +class OAuthServer: + """OAuth server implementation.""" + + def __init__(self): + self.validator = AyonOAuthRequestValidator() + self.server = WebApplicationServer(self.validator) + + async def create_authorization_response( + self, + uri: str, + http_method: str = "GET", + body: str | None = None, + headers: dict[str, str] | None = None, + scopes: list[str] | None = None, + user_name: str | None = None + ) -> tuple[dict[str, str], str, int]: + """Create authorization response.""" + try: + # Pre-populate client data + parsed_uri = urlparse(uri) + from urllib.parse import parse_qs + query_params = parse_qs(parsed_uri.query) + + client_id = query_params.get('client_id', [None])[0] + if client_id: + client = await OAuthStorage.get_client(client_id) + if client: + # Create a mock request object to store client + class MockRequest: + def __init__(self): + self._client = client + self.client_id = client_id + + # Store client for validator + self.validator._current_client = client + else: + raise InvalidClientError("Invalid client_id") + + # If user is authenticated, generate code + if user_name: + code = generate_token() + redirect_uri = query_params.get('redirect_uri', [None])[0] + scope = query_params.get('scope', ['read'])[0] + + # Save authorization code + await OAuthStorage.save_authorization_code( + code=code, + client_id=client_id, + user_name=user_name, + redirect_uri=redirect_uri, + scope=scope + ) + + # Build redirect response + state = query_params.get('state', [None])[0] + response_params = {"code": code} + if state: + response_params["state"] = state + + redirect_url = f"{redirect_uri}?{urlencode(response_params)}" + return {}, redirect_url, 302 + + # Return authorization page + return {}, "", 200 + + except OAuth2Error as e: + return {"error": str(e)}, "", 400 + + async def create_token_response( + self, + uri: str, + http_method: str = "POST", + body: str | None = None, + headers: dict[str, str] | None = None + ) -> tuple[dict[str, Any], str, int]: + """Create token response.""" + try: + # Parse form data + from urllib.parse import parse_qs + if body: + form_data = parse_qs(body) + else: + form_data = {} + + grant_type = form_data.get('grant_type', [None])[0] + # client_id = form_data.get('client_id', [None])[0] + + if grant_type == "authorization_code": + return await self._handle_authorization_code_grant(form_data) + elif grant_type == "refresh_token": + return await self._handle_refresh_token_grant(form_data) + else: + return {"error": "unsupported_grant_type"}, "", 400 + + except Exception as e: + return { + "error": "server_error", + "error_description": str(e)}, "", 500 + + async def _handle_authorization_code_grant( + self, form_data: dict) -> tuple[dict[str, Any], str, int]: + """Handle authorization code grant.""" + code = form_data.get('code', [None])[0] + client_id = form_data.get('client_id', [None])[0] + redirect_uri = form_data.get('redirect_uri', [None])[0] + + if not code or not client_id: + return {"error": "invalid_request"}, "", 400 + + # Verify client + client = await OAuthStorage.get_client(client_id) + if not client: + return {"error": "invalid_client"}, "", 400 + + # Get authorization code + auth_code = await OAuthStorage.get_authorization_code(code) + if not auth_code: + return {"error": "invalid_grant"}, "", 400 + + # Verify client matches + if auth_code["client_id"] != client_id: + return {"error": "invalid_grant"}, "", 400 + + # Verify redirect URI if provided + if redirect_uri and auth_code.get("redirect_uri") != redirect_uri: + return {"error": "invalid_grant"}, "", 400 + + # Generate tokens + access_token = generate_token() + refresh_token = generate_token() + expires_in = 3600 # 1 hour + + # Save tokens + await OAuthStorage.save_access_token( + access_token=access_token, + client_id=client_id, + user_name=auth_code["user_name"], + scope=auth_code.get("scope"), + expires_in=expires_in + ) + + await OAuthStorage.save_refresh_token( + refresh_token=refresh_token, + access_token=access_token, + client_id=client_id, + user_name=auth_code["user_name"], + scope=auth_code.get("scope") + ) + + response = { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": expires_in, + "refresh_token": refresh_token, + "scope": auth_code.get("scope", "read") + } + + # Optionally add JWT tokens if requested + # This could be controlled by a scope like "jwt" or client configuration + scope = auth_code.get("scope", "read") + if "jwt" in scope or "openid" in scope: + try: + # Get user entity for JWT creation + user = await UserEntity.load(auth_code["user_name"]) + if user: + # Add JWT access token + jwt_access_token = JWTTokenManager.create_jwt_access_token( + user=user, + client_id=client_id, + scope=scope, + expires_in=expires_in + ) + response["jwt_access_token"] = jwt_access_token + + # Add ID token for OpenID Connect + if "openid" in scope: + id_token = JWTTokenManager.create_jwt_id_token( + user=user, + client_id=client_id, + expires_in=expires_in + ) + response["id_token"] = id_token + except Exception: + # JWT creation failed, but continue with regular OAuth tokens + pass + + return response, "", 200 + + async def _handle_refresh_token_grant( + self, form_data: dict) -> tuple[dict[str, Any], str, int]: + """Handle refresh token grant.""" + refresh_token = form_data.get('refresh_token', [None])[0] + client_id = form_data.get('client_id', [None])[0] + + if not refresh_token or not client_id: + return {"error": "invalid_request"}, "", 400 + + # Get refresh token data + token_data = await OAuthStorage.get_refresh_token(refresh_token) + if not token_data: + return {"error": "invalid_grant"}, "", 400 + + # Verify client + if token_data["client_id"] != client_id: + return {"error": "invalid_grant"}, "", 400 + + # Generate new access token + access_token = generate_token() + expires_in = 3600 # 1 hour + + # Save new access token + await OAuthStorage.save_access_token( + access_token=access_token, + client_id=client_id, + user_name=token_data["user_name"], + scope=token_data.get("scope"), + expires_in=expires_in + ) + + response = { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": expires_in, + "scope": token_data.get("scope", "read") + } + + # Optionally add JWT tokens if requested + scope = token_data.get("scope", "read") + if "jwt" in scope or "openid" in scope: + try: + # Get user entity for JWT creation + user = await UserEntity.load(token_data["user_name"]) + if user: + # Add JWT access token + jwt_access_token = JWTTokenManager.create_jwt_access_token( + user=user, + client_id=client_id, + scope=scope, + expires_in=expires_in + ) + response["jwt_access_token"] = jwt_access_token + + # Add ID token for OpenID Connect + if "openid" in scope: + id_token = JWTTokenManager.create_jwt_id_token( + user=user, + client_id=client_id, + expires_in=expires_in + ) + response["id_token"] = id_token + except Exception: + # JWT creation failed, but continue with regular OAuth tokens + pass + + return response, "", 200 + + async def introspect_token(self, token: str) -> dict[str, Any]: + """Introspect a token.""" + token_data = await OAuthStorage.get_access_token(token) + + if not token_data: + return {"active": False} + + return { + "active": True, + "client_id": token_data["client_id"], + "username": token_data["user_name"], + "scope": token_data.get("scope", "read"), + "token_type": "Bearer", + "exp": int(token_data["expires_at"]), + "iat": int(token_data["created_at"]), + "sub": token_data["user_name"] + } diff --git a/ayon_server/oauth/storage.py b/ayon_server/oauth/storage.py new file mode 100644 index 000000000..10baa2f68 --- /dev/null +++ b/ayon_server/oauth/storage.py @@ -0,0 +1,236 @@ +"""OAuth storage implementation using PostgreSQL and Redis.""" + +import time +from typing import Any + +from ayon_server.auth.utils import hash_password +from ayon_server.lib.postgres import Postgres +from ayon_server.lib.redis import Redis +from ayon_server.utils import create_hash, create_uuid + +from .models import OAuthClient, OAuthClientCreate + + +class OAuthStorage: + """OAuth storage implementation.""" + + @staticmethod + async def get_client(client_id: str, active: bool = True) -> OAuthClient | None: + """Get OAuth client by ID. + + Args: + client_id (str): The ID of the OAuth client. + active (bool): Whether to filter by active clients only. Defaults to True. + + Returns: + OAuthClient | None: The OAuth client if found, otherwise None. + + """ + query = """ + SELECT client_id, client_secret, client_name, redirect_uris, + grant_types, response_types, scope, client_type, + is_active, created_at, updated_at + FROM oauth_clients + WHERE client_id = $1 AND is_active = $2 + """ + result = await Postgres.fetch(query, client_id, active) + if not result: + return None + + return OAuthClient(**result[0]) + + @staticmethod + async def get_clients(active: bool = True) -> list[OAuthClient]: + """Get all active OAuth clients. + + Args: + active (bool): Whether to filter by active clients only. Defaults to True. + + Returns: + list[OAuthClient]: List of OAuth clients. + + """ + query = """ + SELECT client_id, client_secret, client_name, redirect_uris, + grant_types, response_types, scope, client_type, + is_active, created_at, updated_at + FROM oauth_clients + WHERE is_active = $1 + """ + results = await Postgres.fetch(query, active) + return [OAuthClient(**row) for row in results] if results else [] + + @staticmethod + async def create_client(client_data: OAuthClientCreate) -> OAuthClient: + """Create a new OAuth client.""" + client_id = create_uuid() + client_secret = create_hash() + hashed_secret = hash_password(client_secret) + now = time.time() + + client = OAuthClient( + client_id=client_id, + client_secret=hashed_secret, + client_name=client_data.client_name, + redirect_uris=client_data.redirect_uris, + grant_types=client_data.grant_types, + response_types=client_data.response_types, + scope=client_data.scope, + client_type=client_data.client_type, + is_active=True, + created_at=now, + updated_at=now + ) + + query = """ + INSERT INTO oauth_clients ( + client_id, client_secret, client_name, redirect_uris, + grant_types, response_types, scope, client_type, + is_active, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + """ + + await Postgres.execute( + query, + client.client_id, + client.client_secret, + client.client_name, + client.redirect_uris, + client.grant_types, + client.response_types, + client.scope, + client.client_type, + client.is_active, + client.created_at, + client.updated_at + ) + + # Return client with plain secret for initial response + result_client = client.copy() + result_client.client_secret = client_secret + return result_client + + @staticmethod + async def delete_client(client_id: str) -> None: + """Delete an OAuth client by ID. + + Args: + client_id (str): The ID of the OAuth client to delete. + + """ + query = "DELETE FROM oauth_clients WHERE client_id = $1" + await Postgres.execute(query, client_id) + + @staticmethod + async def save_authorization_code( + code: str, + client_id: str, + user_name: str, + redirect_uri: str | None = None, + scope: str | None = None, + code_challenge: str | None = None, + code_challenge_method: str | None = None, + expires_in: int = 600 # 10 minutes + ) -> None: + """Save authorization code to Redis.""" + data = { + "client_id": client_id, + "user_name": user_name, + "redirect_uri": redirect_uri, + "scope": scope, + "code_challenge": code_challenge, + "code_challenge_method": code_challenge_method, + "created_at": time.time(), + "expires_at": time.time() + expires_in + } + await Redis.set_json("oauth_codes", code, data, ttl=expires_in) + + @staticmethod + async def get_authorization_code(code: str) -> dict[str, Any] | None: + """Get and consume authorization code from Redis.""" + data = await Redis.get_json("oauth_codes", code) + if data: + # Delete the code after retrieving (single use) + await Redis.delete("oauth_codes", code) + + # Check if expired + if time.time() > data.get("expires_at", 0): + return None + + return data + + @staticmethod + async def save_access_token( + access_token: str, + client_id: str, + user_name: str, + scope: str | None = None, + expires_in: int = 3600 # 1 hour + ) -> None: + """Save access token to Redis.""" + data = { + "client_id": client_id, + "user_name": user_name, + "scope": scope, + "token_type": "Bearer", + "created_at": time.time(), + "expires_at": time.time() + expires_in + } + await Redis.set_json("oauth_tokens", access_token, data, ttl=expires_in) + + @staticmethod + async def get_access_token(access_token: str) -> dict[str, Any] | None: + """Get access token from Redis.""" + data = await Redis.get_json("oauth_tokens", access_token) + if data: + # Check if expired + if time.time() > data.get("expires_at", 0): + await Redis.delete("oauth_tokens", access_token) + return None + return data + + @staticmethod + async def save_refresh_token( + refresh_token: str, + access_token: str, + client_id: str, + user_name: str, + scope: str | None = None, + expires_in: int | None = None # Refresh tokens can be long-lived + ) -> None: + """Save refresh token to Redis.""" + data = { + "access_token": access_token, + "client_id": client_id, + "user_name": user_name, + "scope": scope, + "created_at": time.time() + } + + if expires_in: + data["expires_at"] = time.time() + expires_in + + ttl = expires_in if expires_in else None + await Redis.set_json("oauth_refresh_tokens", refresh_token, data, ttl=ttl) + + @staticmethod + async def get_refresh_token(refresh_token: str) -> dict[str, Any] | None: + """Get refresh token from Redis.""" + data = await Redis.get_json("oauth_refresh_tokens", refresh_token) + if data: + # Check if expired (if expiration is set) + expires_at = data.get("expires_at") + if expires_at and time.time() > expires_at: + await Redis.delete("oauth_refresh_tokens", refresh_token) + return None + return data + + @staticmethod + async def revoke_refresh_token(refresh_token: str) -> None: + """Revoke a refresh token.""" + await Redis.delete("oauth_refresh_tokens", refresh_token) + + @staticmethod + async def revoke_access_token(access_token: str) -> None: + """Revoke an access token.""" + await Redis.delete("oauth_tokens", access_token) diff --git a/pyproject.toml b/pyproject.toml index 6e75b868b..1a65e392f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "unidecode>=1.3.8", "user-agents >=2.2.0", "uvicorn[standard] >=0.25", + "oauthlib[signedtoken] >=3.3.1" ] [dependency-groups] diff --git a/schemas/migrations/00000007_oauth.sql b/schemas/migrations/00000007_oauth.sql new file mode 100644 index 000000000..eff90bcd2 --- /dev/null +++ b/schemas/migrations/00000007_oauth.sql @@ -0,0 +1,37 @@ +-- OAuth Provider Migration for AYON Server +-- Add OAuth client support + +-- OAuth clients table +CREATE TABLE IF NOT EXISTS oauth_clients ( + client_id VARCHAR(64) PRIMARY KEY, + client_secret TEXT, + client_name VARCHAR(255) NOT NULL, + redirect_uris TEXT[] DEFAULT '{}', + grant_types TEXT[] DEFAULT '{authorization_code,refresh_token}', + response_types TEXT[] DEFAULT '{code}', + scope VARCHAR(255) DEFAULT 'read', + client_type VARCHAR(20) DEFAULT 'confidential', + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Create indexes for OAuth clients +CREATE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id); +CREATE INDEX IF NOT EXISTS idx_oauth_clients_active ON oauth_clients(is_active); +CREATE INDEX IF NOT EXISTS idx_oauth_clients_name ON oauth_clients(client_name); + +-- Add updated_at trigger for OAuth clients +CREATE OR REPLACE FUNCTION update_oauth_clients_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW."updated_at" := NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS oauth_clients_updated_at ON oauth_clients; +CREATE TRIGGER oauth_clients_updated_at + BEFORE UPDATE ON oauth_clients + FOR EACH ROW + EXECUTE FUNCTION update_oauth_clients_updated_at(); diff --git a/schemas/schema.public.sql b/schemas/schema.public.sql index 259ddf89c..0ddac4e49 100644 --- a/schemas/schema.public.sql +++ b/schemas/schema.public.sql @@ -382,3 +382,38 @@ BEGIN END LOOP; END; $$ LANGUAGE plpgsql; + +-- OAuth clients table +CREATE TABLE IF NOT EXISTS oauth_clients ( + client_id VARCHAR(64) PRIMARY KEY, + client_secret TEXT, + client_name VARCHAR(255) NOT NULL, + redirect_uris TEXT[] DEFAULT '{}', + grant_types TEXT[] DEFAULT '{authorization_code,refresh_token}', + response_types TEXT[] DEFAULT '{code}', + scope VARCHAR(255) DEFAULT 'read', + client_type VARCHAR(20) DEFAULT 'confidential', + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Create indexes for OAuth clients +CREATE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id); +CREATE INDEX IF NOT EXISTS idx_oauth_clients_active ON oauth_clients(is_active); +CREATE INDEX IF NOT EXISTS idx_oauth_clients_name ON oauth_clients(client_name); + +-- Add updated_at trigger for OAuth clients +CREATE OR REPLACE FUNCTION update_oauth_clients_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW."updated_at" := NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS oauth_clients_updated_at ON oauth_clients; +CREATE TRIGGER oauth_clients_updated_at + BEFORE UPDATE ON oauth_clients + FOR EACH ROW + EXECUTE FUNCTION update_oauth_clients_updated_at(); From a48d404b43cfebbff804fb6d90c101a3f6168b77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 14 Jul 2025 14:20:45 +0200 Subject: [PATCH 2/3] :recycle: some fixes --- api/oauth/oauth_provider.py | 1 - ayon_server/api/server.py | 4 ++-- ayon_server/oauth/storage.py | 3 ++- schemas/schema.public.sql | 1 - 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/api/oauth/oauth_provider.py b/api/oauth/oauth_provider.py index 4a0cfac1a..eba2c95a8 100644 --- a/api/oauth/oauth_provider.py +++ b/api/oauth/oauth_provider.py @@ -39,7 +39,6 @@ async def list_oauth_clients(current_user: CurrentUser) -> list[OAuthClient]: raise ForbiddenException("Admin access required") return await OAuthStorage.get_clients(active=True) - return [] @router.post("/clients") diff --git a/ayon_server/api/server.py b/ayon_server/api/server.py index 33945eb26..6da750e05 100644 --- a/ayon_server/api/server.py +++ b/ayon_server/api/server.py @@ -203,9 +203,9 @@ async def openid_configuration(request: Request) -> OpenIDResponse: "authorization_endpoint": f"{base_url}/api/oauth/authorize", "token_endpoint": f"{base_url}/api/oauth/token", "userinfo_endpoint": f"{base_url}/api/oauth/userinfo", - "introspection_endpoint": f"{base_url}/oauth/api/introspect", + "introspection_endpoint": f"{base_url}/api/oauth/introspect", "jwks_uri": f"{base_url}/.well-known/jwks.json", - "jwt_endpoint": f"{base_url}/oauth/jwt", + "jwt_endpoint": f"{base_url}/api/oauth/jwt", "jwt_exchange_endpoint": f"{base_url}/api/oauth/jwt/exchange", "jwt_validation_endpoint": f"{base_url}/api/oauth/validate", "response_types_supported": ["code"], diff --git a/ayon_server/oauth/storage.py b/ayon_server/oauth/storage.py index 10baa2f68..a4e0f6012 100644 --- a/ayon_server/oauth/storage.py +++ b/ayon_server/oauth/storage.py @@ -1,6 +1,7 @@ """OAuth storage implementation using PostgreSQL and Redis.""" import time +from datetime import datetime from typing import Any from ayon_server.auth.utils import hash_password @@ -66,7 +67,7 @@ async def create_client(client_data: OAuthClientCreate) -> OAuthClient: client_id = create_uuid() client_secret = create_hash() hashed_secret = hash_password(client_secret) - now = time.time() + now = datetime.now() client = OAuthClient( client_id=client_id, diff --git a/schemas/schema.public.sql b/schemas/schema.public.sql index 0ddac4e49..b1bd5edc4 100644 --- a/schemas/schema.public.sql +++ b/schemas/schema.public.sql @@ -399,7 +399,6 @@ CREATE TABLE IF NOT EXISTS oauth_clients ( ); -- Create indexes for OAuth clients -CREATE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id); CREATE INDEX IF NOT EXISTS idx_oauth_clients_active ON oauth_clients(is_active); CREATE INDEX IF NOT EXISTS idx_oauth_clients_name ON oauth_clients(client_name); From dd6372c42f16a02e7fb69056152268f8bad298ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 18 Jul 2025 18:45:03 +0200 Subject: [PATCH 3/3] :bug: some fixes --- api/oauth/oauth_provider.py | 8 ++++---- ayon_server/oauth/jwt_manager.py | 16 ++++++++-------- ayon_server/oauth/models.py | 5 +++-- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/api/oauth/oauth_provider.py b/api/oauth/oauth_provider.py index eba2c95a8..37dbf726b 100644 --- a/api/oauth/oauth_provider.py +++ b/api/oauth/oauth_provider.py @@ -112,7 +112,7 @@ async def authorize_endpoint( # Validate redirect_uri if redirect_uri and redirect_uri not in client.redirect_uris: - raise BadRequestException("Invalid redirect_uri") + raise BadRequestException(f"Invalid redirect_uri {redirect_uri}") if not redirect_uri: redirect_uri = client.redirect_uris[0] if client.redirect_uris else None @@ -407,10 +407,10 @@ async def userinfo_endpoint(current_user: CurrentUser) -> OAuthUserInfoResponse: return OAuthUserInfoResponse( sub=current_user.name, - name=current_user.attrib.get("fullName"), + name=current_user.attrib.fullName, preferred_username=current_user.name, - email=current_user.attrib.get("email"), - email_verified=bool(current_user.attrib.get("email")), + email=current_user.attrib.email, + email_verified=bool(current_user.attrib.email), ) @router.get("/validate") diff --git a/ayon_server/oauth/jwt_manager.py b/ayon_server/oauth/jwt_manager.py index 950f023ea..7c0a60a22 100644 --- a/ayon_server/oauth/jwt_manager.py +++ b/ayon_server/oauth/jwt_manager.py @@ -15,13 +15,13 @@ class JWTTokenManager: @staticmethod def _get_jwt_secret() -> str: """Get JWT signing secret.""" - # Use the same secret as AYON's JWT implementation - return ayonconfig.secret + return ayonconfig.auth_pass_pepper @staticmethod def _get_issuer() -> str: """Get JWT issuer.""" - return ayonconfig.site_id or "ayon-server" + # Use the server URL as the issuer + return "ayon-server" @classmethod def create_jwt_access_token( @@ -51,8 +51,8 @@ def create_jwt_access_token( # User-specific claims "username": user.name, - "email": user.attrib.get("email"), - "full_name": user.attrib.get("fullName"), + "email": user.attrib.email, + "full_name": user.attrib.fullName, "is_admin": user.is_admin, "is_manager": user.is_manager, "is_service": user.is_service, @@ -85,10 +85,10 @@ def create_jwt_id_token( "auth_time": int(now), # Time when user was authenticated # User profile claims - "name": user.attrib.get("fullName"), + "name": user.attrib.fullName, "preferred_username": user.name, - "email": user.attrib.get("email"), - "email_verified": bool(user.attrib.get("email")), + "email": user.attrib.email, + "email_verified": bool(user.attrib.email), "updated_at": int( user.updated_at.timestamp()) if user.updated_at else int(now), diff --git a/ayon_server/oauth/models.py b/ayon_server/oauth/models.py index 5a7fd7441..f41963825 100644 --- a/ayon_server/oauth/models.py +++ b/ayon_server/oauth/models.py @@ -1,4 +1,5 @@ """OAuth models for AYON Server OAuth provider implementation.""" +from datetime import datetime from pydantic import Field @@ -26,8 +27,8 @@ class OAuthClient(OPModel): default="confidential", description="Client type (public/confidential)" ) is_active: bool = Field(default=True, description="Whether client is active") - created_at: float = Field(..., description="Creation timestamp") - updated_at: float = Field(..., description="Last update timestamp") + created_at: datetime = Field(..., description="Creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") class OAuthClientCreate(OPModel):