From b3f0dd40057b79b107afbe3a5801997cc12b96f5 Mon Sep 17 00:00:00 2001 From: Felipe Cardoso Date: Wed, 26 Nov 2025 13:23:44 +0100 Subject: [PATCH] Add full OAuth provider functionality and enhance flows - Implemented OAuth 2.0 Authorization Server endpoints per RFCs, including token, introspection, revocation, and metadata discovery. - Added user consent submission, listing, and revocation APIs alongside frontend integration for improved UX. - Enforced stricter OAuth security measures (PKCE, state validation, scopes). - Refactored schemas and services for consistency and expanded coverage of OAuth workflows. - Updated documentation and type definitions for new API behaviors. --- .../f8c3d2e1a4b5_add_oauth_provider_models.py | 0 backend/app/api/routes/oauth_provider.py | 14 +- backend/app/main.py | 5 + backend/app/models/oauth_account.py | 0 .../app/models/oauth_authorization_code.py | 0 backend/app/models/oauth_client.py | 0 backend/app/models/oauth_provider_token.py | 0 backend/app/models/oauth_state.py | 0 .../app/services/oauth_provider_service.py | 0 backend/tests/api/test_oauth.py | 65 ++- .../app/[locale]/(auth)/auth/consent/page.tsx | 7 +- frontend/src/lib/api/generated/sdk.gen.ts | 213 ++++++-- frontend/src/lib/api/generated/types.gen.ts | 490 +++++++++++++++++- frontend/src/mocks/handlers/generated.ts | 2 +- 14 files changed, 720 insertions(+), 76 deletions(-) mode change 100644 => 100755 backend/app/alembic/versions/f8c3d2e1a4b5_add_oauth_provider_models.py mode change 100644 => 100755 backend/app/models/oauth_account.py mode change 100644 => 100755 backend/app/models/oauth_authorization_code.py mode change 100644 => 100755 backend/app/models/oauth_client.py mode change 100644 => 100755 backend/app/models/oauth_provider_token.py mode change 100644 => 100755 backend/app/models/oauth_state.py mode change 100644 => 100755 backend/app/services/oauth_provider_service.py diff --git a/backend/app/alembic/versions/f8c3d2e1a4b5_add_oauth_provider_models.py b/backend/app/alembic/versions/f8c3d2e1a4b5_add_oauth_provider_models.py old mode 100644 new mode 100755 diff --git a/backend/app/api/routes/oauth_provider.py b/backend/app/api/routes/oauth_provider.py index e73a86b..4699cdc 100644 --- a/backend/app/api/routes/oauth_provider.py +++ b/backend/app/api/routes/oauth_provider.py @@ -27,7 +27,11 @@ from slowapi import Limiter from slowapi.util import get_remote_address from sqlalchemy.ext.asyncio import AsyncSession -from app.api.dependencies.auth import get_current_active_user, get_current_superuser +from app.api.dependencies.auth import ( + get_current_active_user, + get_current_superuser, + get_optional_current_user, +) from app.core.config import settings from app.core.database import get_db from app.crud import oauth_client as oauth_client_crud @@ -42,6 +46,8 @@ from app.schemas.oauth import ( from app.services import oauth_provider_service as provider_service router = APIRouter() +# Separate router for RFC 8414 well-known endpoint (registered at root level) +wellknown_router = APIRouter() logger = logging.getLogger(__name__) limiter = Limiter(key_func=get_remote_address) @@ -60,7 +66,7 @@ def require_provider_enabled(): # ============================================================================ -@router.get( +@wellknown_router.get( "/.well-known/oauth-authorization-server", response_model=OAuthServerMetadata, summary="OAuth Server Metadata", @@ -69,6 +75,8 @@ def require_provider_enabled(): Returns server metadata including supported endpoints, scopes, and capabilities. MCP clients use this to discover the server. + + Note: This endpoint is at the root level per RFC 8414. """, operation_id="get_oauth_server_metadata", tags=["OAuth Provider"], @@ -153,7 +161,7 @@ async def authorize( nonce: str | None = Query(default=None, description="OpenID Connect nonce"), db: AsyncSession = Depends(get_db), _: None = Depends(require_provider_enabled), - current_user: User | None = Depends(get_current_active_user), + current_user: User | None = Depends(get_optional_current_user), ) -> Any: """ Authorization endpoint - initiates OAuth flow. diff --git a/backend/app/main.py b/backend/app/main.py index 2b5d16d..cb886c7 100755 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,7 @@ from slowapi.errors import RateLimitExceeded from slowapi.util import get_remote_address from app.api.main import api_router +from app.api.routes.oauth_provider import wellknown_router as oauth_wellknown_router from app.core.config import settings from app.core.database import check_database_health from app.core.exceptions import ( @@ -324,3 +325,7 @@ async def health_check() -> JSONResponse: app.include_router(api_router, prefix=settings.API_V1_STR) + +# OAuth 2.0 well-known endpoint at root level per RFC 8414 +# This allows MCP clients to discover the OAuth server metadata at /.well-known/oauth-authorization-server +app.include_router(oauth_wellknown_router) diff --git a/backend/app/models/oauth_account.py b/backend/app/models/oauth_account.py old mode 100644 new mode 100755 diff --git a/backend/app/models/oauth_authorization_code.py b/backend/app/models/oauth_authorization_code.py old mode 100644 new mode 100755 diff --git a/backend/app/models/oauth_client.py b/backend/app/models/oauth_client.py old mode 100644 new mode 100755 diff --git a/backend/app/models/oauth_provider_token.py b/backend/app/models/oauth_provider_token.py old mode 100644 new mode 100755 diff --git a/backend/app/models/oauth_state.py b/backend/app/models/oauth_state.py old mode 100644 new mode 100755 diff --git a/backend/app/services/oauth_provider_service.py b/backend/app/services/oauth_provider_service.py old mode 100644 new mode 100755 diff --git a/backend/tests/api/test_oauth.py b/backend/tests/api/test_oauth.py index 8e4be5d..2300e7d 100644 --- a/backend/tests/api/test_oauth.py +++ b/backend/tests/api/test_oauth.py @@ -291,9 +291,8 @@ class TestOAuthProviderEndpoints: with patch("app.api.routes.oauth_provider.settings") as mock_settings: mock_settings.OAUTH_PROVIDER_ENABLED = False - response = await client.get( - "/api/v1/oauth/.well-known/oauth-authorization-server" - ) + # RFC 8414: well-known endpoint is at root level + response = await client.get("/.well-known/oauth-authorization-server") assert response.status_code == 404 @pytest.mark.asyncio @@ -303,9 +302,8 @@ class TestOAuthProviderEndpoints: mock_settings.OAUTH_PROVIDER_ENABLED = True mock_settings.OAUTH_ISSUER = "https://api.example.com" - response = await client.get( - "/api/v1/oauth/.well-known/oauth-authorization-server" - ) + # RFC 8414: well-known endpoint is at root level + response = await client.get("/.well-known/oauth-authorization-server") assert response.status_code == 200 data = response.json() assert data["issuer"] == "https://api.example.com" @@ -344,8 +342,10 @@ class TestOAuthProviderEndpoints: assert response.status_code == 404 @pytest.mark.asyncio - async def test_provider_authorize_requires_auth(self, client, async_test_db): - """Test provider authorize requires authentication.""" + async def test_provider_authorize_public_client_requires_pkce( + self, client, async_test_db + ): + """Test provider authorize requires PKCE for public clients.""" _test_engine, AsyncTestingSessionLocal = async_test_db # Create a test client @@ -373,9 +373,54 @@ class TestOAuthProviderEndpoints: "client_id": test_client_id, "redirect_uri": "http://localhost:3000/callback", }, + follow_redirects=False, ) - # Authorize endpoint requires authentication - assert response.status_code == 401 + # Public client without PKCE gets redirect with error + assert response.status_code == 302 + assert "error=invalid_request" in response.headers.get("location", "") + assert "PKCE" in response.headers.get("location", "") + + @pytest.mark.asyncio + async def test_provider_authorize_redirects_to_login(self, client, async_test_db): + """Test provider authorize redirects unauthenticated users to login.""" + _test_engine, AsyncTestingSessionLocal = async_test_db + + # Create a test client + from app.crud.oauth import oauth_client + from app.schemas.oauth import OAuthClientCreate + + async with AsyncTestingSessionLocal() as session: + client_data = OAuthClientCreate( + client_name="Test App", + redirect_uris=["http://localhost:3000/callback"], + allowed_scopes=["read:users"], + ) + test_client, _ = await oauth_client.create_client( + session, obj_in=client_data + ) + test_client_id = test_client.client_id + + with patch("app.api.routes.oauth_provider.settings") as mock_settings: + mock_settings.OAUTH_PROVIDER_ENABLED = True + mock_settings.FRONTEND_URL = "http://localhost:3000" + + # Include PKCE parameters for public client + response = await client.get( + "/api/v1/oauth/provider/authorize", + params={ + "response_type": "code", + "client_id": test_client_id, + "redirect_uri": "http://localhost:3000/callback", + "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + "code_challenge_method": "S256", + }, + follow_redirects=False, + ) + # Unauthenticated users get redirected to login + assert response.status_code == 302 + location = response.headers.get("location", "") + assert "/login" in location + assert "return_to" in location @pytest.mark.asyncio async def test_provider_token_requires_client_id(self, client): diff --git a/frontend/src/app/[locale]/(auth)/auth/consent/page.tsx b/frontend/src/app/[locale]/(auth)/auth/consent/page.tsx index 46e2a04..2ea1529 100644 --- a/frontend/src/app/[locale]/(auth)/auth/consent/page.tsx +++ b/frontend/src/app/[locale]/(auth)/auth/consent/page.tsx @@ -95,7 +95,7 @@ export default function OAuthConsentPage() { // Note: t is available for future i18n use const _t = useTranslations('auth.oauth'); void _t; // Suppress unused warning - ready for i18n - const { isAuthenticated, isLoading: authLoading } = useAuth(); + const { isAuthenticated, isLoading: authLoading, accessToken } = useAuth(); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -181,9 +181,14 @@ export default function OAuthConsentPage() { // Submit consent to backend const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; + const headers: HeadersInit = {}; + if (accessToken) { + headers['Authorization'] = `Bearer ${accessToken}`; + } const response = await fetch(`${apiUrl}/api/v1/oauth/provider/authorize/consent`, { method: 'POST', body: formData, + headers, credentials: 'include', }); diff --git a/frontend/src/lib/api/generated/sdk.gen.ts b/frontend/src/lib/api/generated/sdk.gen.ts index 5b51b75..5fe9b19 100644 --- a/frontend/src/lib/api/generated/sdk.gen.ts +++ b/frontend/src/lib/api/generated/sdk.gen.ts @@ -3,7 +3,7 @@ import { type Client, type Options as Options2, type TDataShape, urlSearchParamsBodySerializer } from './client'; import { client } from './client.gen'; -import type { AdminActivateUserData, AdminActivateUserErrors, AdminActivateUserResponses, AdminAddOrganizationMemberData, AdminAddOrganizationMemberErrors, AdminAddOrganizationMemberResponses, AdminBulkUserActionData, AdminBulkUserActionErrors, AdminBulkUserActionResponses, AdminCreateOrganizationData, AdminCreateOrganizationErrors, AdminCreateOrganizationResponses, AdminCreateUserData, AdminCreateUserErrors, AdminCreateUserResponses, AdminDeactivateUserData, AdminDeactivateUserErrors, AdminDeactivateUserResponses, AdminDeleteOrganizationData, AdminDeleteOrganizationErrors, AdminDeleteOrganizationResponses, AdminDeleteUserData, AdminDeleteUserErrors, AdminDeleteUserResponses, AdminGetOrganizationData, AdminGetOrganizationErrors, AdminGetOrganizationResponses, AdminGetStatsData, AdminGetStatsResponses, AdminGetUserData, AdminGetUserErrors, AdminGetUserResponses, AdminListOrganizationMembersData, AdminListOrganizationMembersErrors, AdminListOrganizationMembersResponses, AdminListOrganizationsData, AdminListOrganizationsErrors, AdminListOrganizationsResponses, AdminListSessionsData, AdminListSessionsErrors, AdminListSessionsResponses, AdminListUsersData, AdminListUsersErrors, AdminListUsersResponses, AdminRemoveOrganizationMemberData, AdminRemoveOrganizationMemberErrors, AdminRemoveOrganizationMemberResponses, AdminUpdateOrganizationData, AdminUpdateOrganizationErrors, AdminUpdateOrganizationResponses, AdminUpdateUserData, AdminUpdateUserErrors, AdminUpdateUserResponses, ChangeCurrentUserPasswordData, ChangeCurrentUserPasswordErrors, ChangeCurrentUserPasswordResponses, CleanupExpiredSessionsData, CleanupExpiredSessionsResponses, ConfirmPasswordResetData, ConfirmPasswordResetErrors, ConfirmPasswordResetResponses, DeleteUserData, DeleteUserErrors, DeleteUserResponses, GetCurrentUserProfileData, GetCurrentUserProfileResponses, GetMyOrganizationsData, GetMyOrganizationsErrors, GetMyOrganizationsResponses, GetOauthAuthorizationUrlData, GetOauthAuthorizationUrlErrors, GetOauthAuthorizationUrlResponses, GetOauthServerMetadataData, GetOauthServerMetadataResponses, GetOrganizationData, GetOrganizationErrors, GetOrganizationMembersData, GetOrganizationMembersErrors, GetOrganizationMembersResponses, GetOrganizationResponses, GetUserByIdData, GetUserByIdErrors, GetUserByIdResponses, HandleOauthCallbackData, HandleOauthCallbackErrors, HandleOauthCallbackResponses, HealthCheckData, HealthCheckResponses, ListMySessionsData, ListMySessionsResponses, ListOauthAccountsData, ListOauthAccountsResponses, ListOauthProvidersData, ListOauthProvidersResponses, ListUsersData, ListUsersErrors, ListUsersResponses, LoginData, LoginErrors, LoginOauthData, LoginOauthErrors, LoginOauthResponses, LoginResponses, LogoutAllData, LogoutAllResponses, LogoutData, LogoutErrors, LogoutResponses, OauthProviderAuthorizeData, OauthProviderAuthorizeErrors, OauthProviderAuthorizeResponses, OauthProviderRevokeData, OauthProviderRevokeErrors, OauthProviderRevokeResponses, OauthProviderTokenData, OauthProviderTokenErrors, OauthProviderTokenResponses, RefreshTokenData, RefreshTokenErrors, RefreshTokenResponses, RegisterData, RegisterErrors, RegisterOauthClientData, RegisterOauthClientErrors, RegisterOauthClientResponses, RegisterResponses, RequestPasswordResetData, RequestPasswordResetErrors, RequestPasswordResetResponses, RevokeSessionData, RevokeSessionErrors, RevokeSessionResponses, RootGetData, RootGetResponses, StartOauthLinkData, StartOauthLinkErrors, StartOauthLinkResponses, UnlinkOauthAccountData, UnlinkOauthAccountErrors, UnlinkOauthAccountResponses, UpdateCurrentUserData, UpdateCurrentUserErrors, UpdateCurrentUserResponses, UpdateOrganizationData, UpdateOrganizationErrors, UpdateOrganizationResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses } from './types.gen'; +import type { AdminActivateUserData, AdminActivateUserErrors, AdminActivateUserResponses, AdminAddOrganizationMemberData, AdminAddOrganizationMemberErrors, AdminAddOrganizationMemberResponses, AdminBulkUserActionData, AdminBulkUserActionErrors, AdminBulkUserActionResponses, AdminCreateOrganizationData, AdminCreateOrganizationErrors, AdminCreateOrganizationResponses, AdminCreateUserData, AdminCreateUserErrors, AdminCreateUserResponses, AdminDeactivateUserData, AdminDeactivateUserErrors, AdminDeactivateUserResponses, AdminDeleteOrganizationData, AdminDeleteOrganizationErrors, AdminDeleteOrganizationResponses, AdminDeleteUserData, AdminDeleteUserErrors, AdminDeleteUserResponses, AdminGetOrganizationData, AdminGetOrganizationErrors, AdminGetOrganizationResponses, AdminGetStatsData, AdminGetStatsResponses, AdminGetUserData, AdminGetUserErrors, AdminGetUserResponses, AdminListOrganizationMembersData, AdminListOrganizationMembersErrors, AdminListOrganizationMembersResponses, AdminListOrganizationsData, AdminListOrganizationsErrors, AdminListOrganizationsResponses, AdminListSessionsData, AdminListSessionsErrors, AdminListSessionsResponses, AdminListUsersData, AdminListUsersErrors, AdminListUsersResponses, AdminRemoveOrganizationMemberData, AdminRemoveOrganizationMemberErrors, AdminRemoveOrganizationMemberResponses, AdminUpdateOrganizationData, AdminUpdateOrganizationErrors, AdminUpdateOrganizationResponses, AdminUpdateUserData, AdminUpdateUserErrors, AdminUpdateUserResponses, ChangeCurrentUserPasswordData, ChangeCurrentUserPasswordErrors, ChangeCurrentUserPasswordResponses, CleanupExpiredSessionsData, CleanupExpiredSessionsResponses, ConfirmPasswordResetData, ConfirmPasswordResetErrors, ConfirmPasswordResetResponses, DeleteOauthClientData, DeleteOauthClientErrors, DeleteOauthClientResponses, DeleteUserData, DeleteUserErrors, DeleteUserResponses, GetCurrentUserProfileData, GetCurrentUserProfileResponses, GetMyOrganizationsData, GetMyOrganizationsErrors, GetMyOrganizationsResponses, GetOauthAuthorizationUrlData, GetOauthAuthorizationUrlErrors, GetOauthAuthorizationUrlResponses, GetOauthServerMetadataData, GetOauthServerMetadataResponses, GetOrganizationData, GetOrganizationErrors, GetOrganizationMembersData, GetOrganizationMembersErrors, GetOrganizationMembersResponses, GetOrganizationResponses, GetUserByIdData, GetUserByIdErrors, GetUserByIdResponses, HandleOauthCallbackData, HandleOauthCallbackErrors, HandleOauthCallbackResponses, HealthCheckData, HealthCheckResponses, ListMyOauthConsentsData, ListMyOauthConsentsResponses, ListMySessionsData, ListMySessionsResponses, ListOauthAccountsData, ListOauthAccountsResponses, ListOauthClientsData, ListOauthClientsResponses, ListOauthProvidersData, ListOauthProvidersResponses, ListUsersData, ListUsersErrors, ListUsersResponses, LoginData, LoginErrors, LoginOauthData, LoginOauthErrors, LoginOauthResponses, LoginResponses, LogoutAllData, LogoutAllResponses, LogoutData, LogoutErrors, LogoutResponses, OauthProviderAuthorizeData, OauthProviderAuthorizeErrors, OauthProviderAuthorizeResponses, OauthProviderConsentData, OauthProviderConsentErrors, OauthProviderConsentResponses, OauthProviderIntrospectData, OauthProviderIntrospectErrors, OauthProviderIntrospectResponses, OauthProviderRevokeData, OauthProviderRevokeErrors, OauthProviderRevokeResponses, OauthProviderTokenData, OauthProviderTokenErrors, OauthProviderTokenResponses, RefreshTokenData, RefreshTokenErrors, RefreshTokenResponses, RegisterData, RegisterErrors, RegisterOauthClientData, RegisterOauthClientErrors, RegisterOauthClientResponses, RegisterResponses, RequestPasswordResetData, RequestPasswordResetErrors, RequestPasswordResetResponses, RevokeMyOauthConsentData, RevokeMyOauthConsentErrors, RevokeMyOauthConsentResponses, RevokeSessionData, RevokeSessionErrors, RevokeSessionResponses, RootGetData, RootGetResponses, StartOauthLinkData, StartOauthLinkErrors, StartOauthLinkResponses, UnlinkOauthAccountData, UnlinkOauthAccountErrors, UnlinkOauthAccountResponses, UpdateCurrentUserData, UpdateCurrentUserErrors, UpdateCurrentUserResponses, UpdateOrganizationData, UpdateOrganizationErrors, UpdateOrganizationResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses } from './types.gen'; export type Options = Options2 & { /** @@ -353,34 +353,26 @@ export const startOauthLink = (options: Op }; /** - * OAuth Server Metadata - * - * OAuth 2.0 Authorization Server Metadata (RFC 8414). - * - * Returns server metadata including supported endpoints, scopes, - * and capabilities for MCP clients. - */ -export const getOauthServerMetadata = (options?: Options) => { - return (options?.client ?? client).get({ - responseType: 'json', - url: '/api/v1/oauth/.well-known/oauth-authorization-server', - ...options - }); -}; - -/** - * Authorization Endpoint (Skeleton) + * Authorization Endpoint * * OAuth 2.0 Authorization Endpoint. * - * **NOTE**: This is a skeleton implementation. In a full implementation, - * this would: - * 1. Validate client_id and redirect_uri - * 2. Display consent screen to user - * 3. Generate authorization code - * 4. Redirect back to client with code + * Initiates the authorization code flow: + * 1. Validates client and parameters + * 2. Checks if user is authenticated (redirects to login if not) + * 3. Checks existing consent + * 4. Redirects to consent page if needed + * 5. Issues authorization code and redirects back to client * - * Currently returns a 501 Not Implemented response. + * Required parameters: + * - response_type: Must be "code" + * - client_id: Registered client ID + * - redirect_uri: Must match registered URI + * + * Recommended parameters: + * - state: CSRF protection + * - code_challenge + code_challenge_method: PKCE (required for public clients) + * - scope: Requested permissions */ export const oauthProviderAuthorize = (options: Options) => { return (options.client ?? client).get({ @@ -391,14 +383,43 @@ export const oauthProviderAuthorize = (opt }; /** - * Token Endpoint (Skeleton) + * Submit Authorization Consent + * + * Submit user consent for OAuth authorization. + * + * Called by the consent page after user approves or denies. + */ +export const oauthProviderConsent = (options: Options) => { + return (options.client ?? client).post({ + ...urlSearchParamsBodySerializer, + responseType: 'json', + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/oauth/provider/authorize/consent', + ...options, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...options.headers + } + }); +}; + +/** + * Token Endpoint * * OAuth 2.0 Token Endpoint. * - * **NOTE**: This is a skeleton implementation. In a full implementation, - * this would exchange authorization codes for access tokens. + * Supports: + * - authorization_code: Exchange code for tokens + * - refresh_token: Refresh access token * - * Currently returns a 501 Not Implemented response. + * Client authentication: + * - Confidential clients: client_secret (Basic auth or POST body) + * - Public clients: No secret, but PKCE code_verifier required */ export const oauthProviderToken = (options: Options) => { return (options.client ?? client).post({ @@ -414,13 +435,12 @@ export const oauthProviderToken = (options }; /** - * Token Revocation Endpoint (Skeleton) + * Token Revocation Endpoint * * OAuth 2.0 Token Revocation Endpoint (RFC 7009). * - * **NOTE**: This is a skeleton implementation. - * - * Currently returns a 501 Not Implemented response. + * Revokes an access token or refresh token. + * Always returns 200 OK (even if token is invalid) per spec. */ export const oauthProviderRevoke = (options: Options) => { return (options.client ?? client).post({ @@ -436,19 +456,65 @@ export const oauthProviderRevoke = (option }; /** - * Register OAuth Client (Admin) + * Token Introspection Endpoint + * + * OAuth 2.0 Token Introspection Endpoint (RFC 7662). + * + * Allows resource servers to query the authorization server + * to determine the active state and metadata of a token. + */ +export const oauthProviderIntrospect = (options: Options) => { + return (options.client ?? client).post({ + ...urlSearchParamsBodySerializer, + responseType: 'json', + url: '/api/v1/oauth/provider/introspect', + ...options, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...options.headers + } + }); +}; + +/** + * List OAuth Clients + * + * List all registered OAuth clients (admin only). + */ +export const listOauthClients = (options?: Options) => { + return (options?.client ?? client).get({ + responseType: 'json', + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/oauth/provider/clients', + ...options + }); +}; + +/** + * Register OAuth Client * * Register a new OAuth client (admin only). * - * This endpoint allows creating MCP clients that can authenticate - * against this API. + * Creates an MCP client that can authenticate against this API. + * Returns client_id and client_secret (for confidential clients). * - * **NOTE**: This is a minimal implementation. + * **Important:** Store the client_secret securely - it won't be shown again! */ export const registerOauthClient = (options: Options) => { return (options.client ?? client).post({ ...urlSearchParamsBodySerializer, responseType: 'json', + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], url: '/api/v1/oauth/provider/clients', ...options, headers: { @@ -458,6 +524,61 @@ export const registerOauthClient = (option }); }; +/** + * Delete OAuth Client + * + * Delete an OAuth client (admin only). Revokes all tokens. + */ +export const deleteOauthClient = (options: Options) => { + return (options.client ?? client).delete({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/oauth/provider/clients/{client_id}', + ...options + }); +}; + +/** + * List My Consents + * + * List OAuth applications the current user has authorized. + */ +export const listMyOauthConsents = (options?: Options) => { + return (options?.client ?? client).get({ + responseType: 'json', + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/oauth/provider/consents', + ...options + }); +}; + +/** + * Revoke My Consent + * + * Revoke authorization for an OAuth application. Also revokes all tokens. + */ +export const revokeMyOauthConsent = (options: Options) => { + return (options.client ?? client).delete({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/oauth/provider/consents/{client_id}', + ...options + }); +}; + /** * List Users * @@ -1166,3 +1287,21 @@ export const getOrganizationMembers = (opt ...options }); }; + +/** + * OAuth Server Metadata + * + * OAuth 2.0 Authorization Server Metadata (RFC 8414). + * + * Returns server metadata including supported endpoints, scopes, + * and capabilities. MCP clients use this to discover the server. + * + * Note: This endpoint is at the root level per RFC 8414. + */ +export const getOauthServerMetadata = (options?: Options) => { + return (options?.client ?? client).get({ + responseType: 'json', + url: '/.well-known/oauth-authorization-server', + ...options + }); +}; diff --git a/frontend/src/lib/api/generated/types.gen.ts b/frontend/src/lib/api/generated/types.gen.ts index 1729611..22dc731 100644 --- a/frontend/src/lib/api/generated/types.gen.ts +++ b/frontend/src/lib/api/generated/types.gen.ts @@ -145,6 +145,84 @@ export type BodyLoginOauth = { client_secret?: string | null; }; +/** + * Body_oauth_provider_consent + */ +export type BodyOauthProviderConsent = { + /** + * Approved + * + * Whether user approved + */ + approved: boolean; + /** + * Client Id + * + * OAuth client ID + */ + client_id: string; + /** + * Redirect Uri + * + * Redirect URI + */ + redirect_uri: string; + /** + * Scope + * + * Granted scopes + */ + scope?: string; + /** + * State + * + * CSRF state parameter + */ + state?: string; + /** + * Code Challenge + */ + code_challenge?: string | null; + /** + * Code Challenge Method + */ + code_challenge_method?: string | null; + /** + * Nonce + */ + nonce?: string | null; +}; + +/** + * Body_oauth_provider_introspect + */ +export type BodyOauthProviderIntrospect = { + /** + * Token + * + * Token to introspect + */ + token: string; + /** + * Token Type Hint + * + * Token type hint (access_token, refresh_token) + */ + token_type_hint?: string | null; + /** + * Client Id + * + * Client ID + */ + client_id?: string | null; + /** + * Client Secret + * + * Client secret + */ + client_secret?: string | null; +}; + /** * Body_oauth_provider_revoke */ @@ -182,7 +260,7 @@ export type BodyOauthProviderToken = { /** * Grant Type * - * Grant type (authorization_code) + * Grant type */ grant_type: string; /** @@ -221,6 +299,12 @@ export type BodyOauthProviderToken = { * Refresh token */ refresh_token?: string | null; + /** + * Scope + * + * Scope (for refresh) + */ + scope?: string | null; }; /** @@ -236,7 +320,7 @@ export type BodyRegisterOauthClient = { /** * Redirect Uris * - * Comma-separated list of redirect URIs + * Comma-separated redirect URIs */ redirect_uris: string; /** @@ -245,6 +329,18 @@ export type BodyRegisterOauthClient = { * public or confidential */ client_type?: string; + /** + * Scopes + * + * Allowed scopes (space-separated) + */ + scopes?: string; + /** + * Mcp Server Url + * + * MCP server URL + */ + mcp_server_url?: string | null; }; /** @@ -454,6 +550,60 @@ export type OAuthCallbackResponse = { is_new_user?: boolean; }; +/** + * OAuthClientResponse + * + * Schema for OAuth client response. + */ +export type OAuthClientResponse = { + /** + * Client Name + * + * Client application name + */ + client_name: string; + /** + * Client Description + * + * Client description + */ + client_description?: string | null; + /** + * Redirect Uris + * + * Allowed redirect URIs + */ + redirect_uris?: Array; + /** + * Allowed Scopes + * + * Allowed OAuth scopes + */ + allowed_scopes?: Array; + /** + * Id + */ + id: string; + /** + * Client Id + * + * OAuth client ID + */ + client_id: string; + /** + * Client Type + */ + client_type: string; + /** + * Is Active + */ + is_active: boolean; + /** + * Created At + */ + created_at: string; +}; + /** * OAuthProviderInfo * @@ -536,6 +686,12 @@ export type OAuthServerMetadata = { * Token revocation endpoint */ revocation_endpoint?: string | null; + /** + * Introspection Endpoint + * + * Token introspection endpoint (RFC 7662) + */ + introspection_endpoint?: string | null; /** * Scopes Supported * @@ -560,6 +716,124 @@ export type OAuthServerMetadata = { * Supported PKCE methods */ code_challenge_methods_supported?: Array; + /** + * Token Endpoint Auth Methods Supported + * + * Supported client authentication methods + */ + token_endpoint_auth_methods_supported?: Array; +}; + +/** + * OAuthTokenIntrospectionResponse + * + * OAuth 2.0 Token Introspection Response (RFC 7662). + */ +export type OAuthTokenIntrospectionResponse = { + /** + * Active + * + * Whether the token is currently active + */ + active: boolean; + /** + * Scope + * + * Space-separated list of scopes + */ + scope?: string | null; + /** + * Client Id + * + * Client identifier for the token + */ + client_id?: string | null; + /** + * Username + * + * Human-readable identifier for the resource owner + */ + username?: string | null; + /** + * Token Type + * + * Type of the token (e.g., 'Bearer') + */ + token_type?: string | null; + /** + * Exp + * + * Token expiration time (Unix timestamp) + */ + exp?: number | null; + /** + * Iat + * + * Token issue time (Unix timestamp) + */ + iat?: number | null; + /** + * Nbf + * + * Token not-before time (Unix timestamp) + */ + nbf?: number | null; + /** + * Sub + * + * Subject of the token (user ID) + */ + sub?: string | null; + /** + * Aud + * + * Intended audience of the token + */ + aud?: string | null; + /** + * Iss + * + * Issuer of the token + */ + iss?: string | null; +}; + +/** + * OAuthTokenResponse + * + * OAuth 2.0 Token Response (RFC 6749 Section 5.1). + */ +export type OAuthTokenResponse = { + /** + * Access Token + * + * The access token issued by the server + */ + access_token: string; + /** + * Token Type + * + * The type of token (typically 'Bearer') + */ + token_type?: string; + /** + * Expires In + * + * Token lifetime in seconds + */ + expires_in?: number | null; + /** + * Refresh Token + * + * Refresh token for obtaining new access tokens + */ + refresh_token?: string | null; + /** + * Scope + * + * Space-separated list of granted scopes + */ + scope?: string | null; }; /** @@ -1610,24 +1884,14 @@ export type StartOauthLinkResponses = { export type StartOauthLinkResponse = StartOauthLinkResponses[keyof StartOauthLinkResponses]; -export type GetOauthServerMetadataData = { - body?: never; - path?: never; - query?: never; - url: '/api/v1/oauth/.well-known/oauth-authorization-server'; -}; - -export type GetOauthServerMetadataResponses = { - /** - * Successful Response - */ - 200: OAuthServerMetadata; -}; - -export type GetOauthServerMetadataResponse = GetOauthServerMetadataResponses[keyof GetOauthServerMetadataResponses]; - export type OauthProviderAuthorizeData = { body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string; + }; path?: never; query: { /** @@ -1651,7 +1915,7 @@ export type OauthProviderAuthorizeData = { /** * Scope * - * Requested scopes + * Requested scopes (space-separated) */ scope?: string; /** @@ -1672,6 +1936,12 @@ export type OauthProviderAuthorizeData = { * PKCE method (S256) */ code_challenge_method?: string | null; + /** + * Nonce + * + * OpenID Connect nonce + */ + nonce?: string | null; }; url: '/api/v1/oauth/provider/authorize'; }; @@ -1694,6 +1964,31 @@ export type OauthProviderAuthorizeResponses = { 200: unknown; }; +export type OauthProviderConsentData = { + body: BodyOauthProviderConsent; + path?: never; + query?: never; + url: '/api/v1/oauth/provider/authorize/consent'; +}; + +export type OauthProviderConsentErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type OauthProviderConsentError = OauthProviderConsentErrors[keyof OauthProviderConsentErrors]; + +export type OauthProviderConsentResponses = { + /** + * Response Oauth Provider Consent + * + * Successful Response + */ + 200: unknown; +}; + export type OauthProviderTokenData = { body: BodyOauthProviderToken; path?: never; @@ -1712,13 +2007,13 @@ export type OauthProviderTokenError = OauthProviderTokenErrors[keyof OauthProvid export type OauthProviderTokenResponses = { /** - * Response Oauth Provider Token - * * Successful Response */ - 200: unknown; + 200: OAuthTokenResponse; }; +export type OauthProviderTokenResponse = OauthProviderTokenResponses[keyof OauthProviderTokenResponses]; + export type OauthProviderRevokeData = { body: BodyOauthProviderRevoke; path?: never; @@ -1741,9 +2036,56 @@ export type OauthProviderRevokeResponses = { * * Successful Response */ - 200: unknown; + 200: { + [key: string]: string; + }; }; +export type OauthProviderRevokeResponse = OauthProviderRevokeResponses[keyof OauthProviderRevokeResponses]; + +export type OauthProviderIntrospectData = { + body: BodyOauthProviderIntrospect; + path?: never; + query?: never; + url: '/api/v1/oauth/provider/introspect'; +}; + +export type OauthProviderIntrospectErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type OauthProviderIntrospectError = OauthProviderIntrospectErrors[keyof OauthProviderIntrospectErrors]; + +export type OauthProviderIntrospectResponses = { + /** + * Successful Response + */ + 200: OAuthTokenIntrospectionResponse; +}; + +export type OauthProviderIntrospectResponse = OauthProviderIntrospectResponses[keyof OauthProviderIntrospectResponses]; + +export type ListOauthClientsData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/oauth/provider/clients'; +}; + +export type ListOauthClientsResponses = { + /** + * Response List Oauth Clients + * + * Successful Response + */ + 200: Array; +}; + +export type ListOauthClientsResponse = ListOauthClientsResponses[keyof ListOauthClientsResponses]; + export type RegisterOauthClientData = { body: BodyRegisterOauthClient; path?: never; @@ -1766,9 +2108,93 @@ export type RegisterOauthClientResponses = { * * Successful Response */ - 200: unknown; + 200: { + [key: string]: unknown; + }; }; +export type RegisterOauthClientResponse = RegisterOauthClientResponses[keyof RegisterOauthClientResponses]; + +export type DeleteOauthClientData = { + body?: never; + path: { + /** + * Client Id + */ + client_id: string; + }; + query?: never; + url: '/api/v1/oauth/provider/clients/{client_id}'; +}; + +export type DeleteOauthClientErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type DeleteOauthClientError = DeleteOauthClientErrors[keyof DeleteOauthClientErrors]; + +export type DeleteOauthClientResponses = { + /** + * Successful Response + */ + 204: void; +}; + +export type DeleteOauthClientResponse = DeleteOauthClientResponses[keyof DeleteOauthClientResponses]; + +export type ListMyOauthConsentsData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/oauth/provider/consents'; +}; + +export type ListMyOauthConsentsResponses = { + /** + * Response List My Oauth Consents + * + * Successful Response + */ + 200: Array<{ + [key: string]: unknown; + }>; +}; + +export type ListMyOauthConsentsResponse = ListMyOauthConsentsResponses[keyof ListMyOauthConsentsResponses]; + +export type RevokeMyOauthConsentData = { + body?: never; + path: { + /** + * Client Id + */ + client_id: string; + }; + query?: never; + url: '/api/v1/oauth/provider/consents/{client_id}'; +}; + +export type RevokeMyOauthConsentErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type RevokeMyOauthConsentError = RevokeMyOauthConsentErrors[keyof RevokeMyOauthConsentErrors]; + +export type RevokeMyOauthConsentResponses = { + /** + * Successful Response + */ + 204: void; +}; + +export type RevokeMyOauthConsentResponse = RevokeMyOauthConsentResponses[keyof RevokeMyOauthConsentResponses]; + export type ListUsersData = { body?: never; path?: never; @@ -2759,3 +3185,19 @@ export type GetOrganizationMembersResponses = { }; export type GetOrganizationMembersResponse = GetOrganizationMembersResponses[keyof GetOrganizationMembersResponses]; + +export type GetOauthServerMetadataData = { + body?: never; + path?: never; + query?: never; + url: '/.well-known/oauth-authorization-server'; +}; + +export type GetOauthServerMetadataResponses = { + /** + * Successful Response + */ + 200: OAuthServerMetadata; +}; + +export type GetOauthServerMetadataResponse = GetOauthServerMetadataResponses[keyof GetOauthServerMetadataResponses]; diff --git a/frontend/src/mocks/handlers/generated.ts b/frontend/src/mocks/handlers/generated.ts index 9d0f81b..23e0bfa 100644 --- a/frontend/src/mocks/handlers/generated.ts +++ b/frontend/src/mocks/handlers/generated.ts @@ -8,7 +8,7 @@ * * For custom handler behavior, use src/mocks/handlers/overrides.ts * - * Generated: 2025-11-25T00:22:46.981Z + * Generated: 2025-11-26T12:21:51.098Z */ import { http, HttpResponse, delay } from 'msw';