Compare commits

...

7 Commits

Author SHA1 Message Date
Felipe Cardoso
088c1725b0 Update ContextSection and TechStackSection with OAuth 2.0 and i18n features
- Replaced outdated features with 'OAuth 2.0 + Social Login' and 'i18n Ready' in ContextSection.
- Updated TechStackSection to include OAuth 2.0 (social login + provider mode) and next-intl (English, Italian) support.
- Refined descriptions in FeatureGrid and HeroSection to highlight new features.
- Improved messaging around OAuth and internationalization readiness across components.
2025-11-26 14:44:12 +01:00
Felipe Cardoso
7ba1767cea Refactor E2E tests for OAuth provider workflows
- Renamed unused `code_verifier` variables to `_code_verifier` for clarity.
- Improved test readability by reformatting long lines and assertions.
- Streamlined `get` request calls by consolidating parameters into single lines.
2025-11-26 14:10:25 +01:00
Felipe Cardoso
c63b6a4f76 Add E2E tests for OAuth consent page workflows
- Added tests for OAuth consent page covering parameter validation, unauthenticated user redirects, authenticated user interactions, scope management, and consent API calls.
- Verified behaviors such as error handling, toggling scopes, loading states, and authorize/deny actions.
- Updated utility methods with `loginViaUI` for improved test setup.
2025-11-26 14:06:36 +01:00
Felipe Cardoso
803b720530 Add comprehensive E2E tests for OAuth provider workflows
- Introduced E2E test coverage for OAuth Provider mode, covering metadata discovery, client management, authorization flows, token operations, consent management, and security checks.
- Verified PKCE enforcement, consent submission, token rotation, and introspection.
- Expanded fixtures and utility methods for testing real OAuth scenarios with PostgreSQL via Testcontainers.
2025-11-26 14:06:20 +01:00
Felipe Cardoso
7ff00426f2 Add detailed OAuth documentation and configuration examples
- Updated `ARCHITECTURE.md` with thorough explanations of OAuth Consumer and Provider modes, supported flows, security features, and endpoints.
- Enhanced `.env.template` with environment variables for OAuth Provider mode setup.
- Expanded `README.md` to highlight OAuth Provider mode capabilities and MCP integration features.
- Added OAuth configuration section to `AGENTS.md`, including key settings for both social login and provider mode.
2025-11-26 13:38:55 +01:00
Felipe Cardoso
b3f0dd4005 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.
2025-11-26 13:23:44 +01:00
Felipe Cardoso
707315facd Suppress jsdom XMLHttpRequest errors in Jest tests
- Added `jest.environment.js` to create a custom Jest environment that filters out harmless XMLHttpRequest errors from jsdom's VirtualConsole.
- Updated `jest.config.js` to use the custom environment, reducing noisy test outputs.
2025-11-26 11:23:56 +01:00
27 changed files with 2112 additions and 97 deletions

View File

@@ -40,6 +40,12 @@ OAUTH_AUTO_LINK_BY_EMAIL=true
# OAUTH_GITHUB_CLIENT_ID=your-github-client-id # OAUTH_GITHUB_CLIENT_ID=your-github-client-id
# OAUTH_GITHUB_CLIENT_SECRET=your-github-client-secret # OAUTH_GITHUB_CLIENT_SECRET=your-github-client-secret
# OAuth Provider Mode (Authorization Server for MCP/third-party clients)
# Set OAUTH_PROVIDER_ENABLED=true to act as an OAuth 2.0 Authorization Server
OAUTH_PROVIDER_ENABLED=true
# IMPORTANT: Must be HTTPS in production!
OAUTH_ISSUER=http://localhost:8000
# Frontend settings # Frontend settings
FRONTEND_PORT=3000 FRONTEND_PORT=3000
FRONTEND_URL=http://localhost:3000 FRONTEND_URL=http://localhost:3000

View File

@@ -40,6 +40,12 @@ OAUTH_AUTO_LINK_BY_EMAIL=true
# OAUTH_GITHUB_CLIENT_ID=your-github-client-id # OAUTH_GITHUB_CLIENT_ID=your-github-client-id
# OAUTH_GITHUB_CLIENT_SECRET=your-github-client-secret # OAUTH_GITHUB_CLIENT_SECRET=your-github-client-secret
# OAuth Provider Mode (Authorization Server for MCP/third-party clients)
# Set OAUTH_PROVIDER_ENABLED=true to act as an OAuth 2.0 Authorization Server
OAUTH_PROVIDER_ENABLED=false
# IMPORTANT: Must be HTTPS in production!
OAUTH_ISSUER=http://localhost:8000
# Frontend settings # Frontend settings
FRONTEND_PORT=3000 FRONTEND_PORT=3000
FRONTEND_URL=http://localhost:3000 FRONTEND_URL=http://localhost:3000

View File

@@ -90,6 +90,26 @@ Full OAuth 2.0 Authorization Server for MCP (Model Context Protocol) clients:
**Scopes supported:** `openid`, `profile`, `email`, `read:users`, `write:users`, `admin` **Scopes supported:** `openid`, `profile`, `email`, `read:users`, `write:users`, `admin`
**OAuth Configuration (backend `.env`):**
```bash
# OAuth Social Login (as OAuth Consumer)
OAUTH_ENABLED=true # Enable OAuth social login
OAUTH_AUTO_LINK_BY_EMAIL=true # Auto-link accounts by email
OAUTH_STATE_EXPIRE_MINUTES=10 # CSRF state expiration
# Google OAuth
OAUTH_GOOGLE_CLIENT_ID=your-google-client-id
OAUTH_GOOGLE_CLIENT_SECRET=your-google-client-secret
# GitHub OAuth
OAUTH_GITHUB_CLIENT_ID=your-github-client-id
OAUTH_GITHUB_CLIENT_SECRET=your-github-client-secret
# OAuth Provider Mode (as Authorization Server for MCP)
OAUTH_PROVIDER_ENABLED=true # Enable OAuth provider mode
OAUTH_ISSUER=https://api.yourdomain.com # JWT issuer URL (must be HTTPS in production)
```
### Database Pattern ### Database Pattern
- **Async SQLAlchemy 2.0** with PostgreSQL - **Async SQLAlchemy 2.0** with PostgreSQL
- **Connection pooling**: 20 base connections, 50 max overflow - **Connection pooling**: 20 base connections, 50 max overflow

View File

@@ -53,12 +53,25 @@ Whether you're building a SaaS, an internal tool, or a side project, PragmaStack
### 🔐 **Authentication & Security** ### 🔐 **Authentication & Security**
- JWT-based authentication with access + refresh tokens - JWT-based authentication with access + refresh tokens
- **OAuth/Social Login** (Google, GitHub) with PKCE support - **OAuth/Social Login** (Google, GitHub) with PKCE support
- **OAuth 2.0 Authorization Server** (MCP-ready) for third-party integrations
- Session management with device tracking and revocation - Session management with device tracking and revocation
- Password reset flow (email integration ready) - Password reset flow (email integration ready)
- Secure password hashing (bcrypt) - Secure password hashing (bcrypt)
- CSRF protection, rate limiting, and security headers - CSRF protection, rate limiting, and security headers
- Comprehensive security tests (JWT algorithm attacks, session hijacking, privilege escalation) - Comprehensive security tests (JWT algorithm attacks, session hijacking, privilege escalation)
### 🔌 **OAuth Provider Mode (MCP Integration)**
Full OAuth 2.0 Authorization Server for Model Context Protocol (MCP) and third-party clients:
- **RFC 7636**: Authorization Code Flow with PKCE (S256 only)
- **RFC 8414**: Server metadata discovery at `/.well-known/oauth-authorization-server`
- **RFC 7662**: Token introspection endpoint
- **RFC 7009**: Token revocation endpoint
- **JWT access tokens**: Self-contained, configurable lifetime
- **Opaque refresh tokens**: Secure rotation, database-backed revocation
- **Consent management**: Users can review and revoke app permissions
- **Client management**: Admin endpoints for registering OAuth clients
- **Scopes**: `openid`, `profile`, `email`, `read:users`, `write:users`, `admin`
### 👥 **Multi-Tenancy & Organizations** ### 👥 **Multi-Tenancy & Organizations**
- Full organization system with role-based access control (Owner, Admin, Member) - Full organization system with role-based access control (Owner, Admin, Member)
- Invite/remove members, manage permissions - Invite/remove members, manage permissions

View File

View File

@@ -27,7 +27,11 @@ from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from sqlalchemy.ext.asyncio import AsyncSession 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.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.crud import oauth_client as oauth_client_crud 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 from app.services import oauth_provider_service as provider_service
router = APIRouter() router = APIRouter()
# Separate router for RFC 8414 well-known endpoint (registered at root level)
wellknown_router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
limiter = Limiter(key_func=get_remote_address) 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", "/.well-known/oauth-authorization-server",
response_model=OAuthServerMetadata, response_model=OAuthServerMetadata,
summary="OAuth Server Metadata", summary="OAuth Server Metadata",
@@ -69,6 +75,8 @@ def require_provider_enabled():
Returns server metadata including supported endpoints, scopes, Returns server metadata including supported endpoints, scopes,
and capabilities. MCP clients use this to discover the server. 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", operation_id="get_oauth_server_metadata",
tags=["OAuth Provider"], tags=["OAuth Provider"],
@@ -153,7 +161,7 @@ async def authorize(
nonce: str | None = Query(default=None, description="OpenID Connect nonce"), nonce: str | None = Query(default=None, description="OpenID Connect nonce"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: None = Depends(require_provider_enabled), _: None = Depends(require_provider_enabled),
current_user: User | None = Depends(get_current_active_user), current_user: User | None = Depends(get_optional_current_user),
) -> Any: ) -> Any:
""" """
Authorization endpoint - initiates OAuth flow. Authorization endpoint - initiates OAuth flow.

View File

@@ -14,6 +14,7 @@ from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from app.api.main import api_router 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.config import settings
from app.core.database import check_database_health from app.core.database import check_database_health
from app.core.exceptions import ( from app.core.exceptions import (
@@ -324,3 +325,7 @@ async def health_check() -> JSONResponse:
app.include_router(api_router, prefix=settings.API_V1_STR) 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)

0
backend/app/models/oauth_account.py Normal file → Executable file
View File

0
backend/app/models/oauth_authorization_code.py Normal file → Executable file
View File

0
backend/app/models/oauth_client.py Normal file → Executable file
View File

0
backend/app/models/oauth_provider_token.py Normal file → Executable file
View File

0
backend/app/models/oauth_state.py Normal file → Executable file
View File

0
backend/app/services/oauth_provider_service.py Normal file → Executable file
View File

View File

@@ -818,6 +818,84 @@ def add_member(
pass pass
``` ```
### OAuth Integration
The system supports two OAuth modes:
#### OAuth Consumer Mode (Social Login)
Users can authenticate via Google or GitHub OAuth providers:
```python
# Get authorization URL with PKCE support
GET /oauth/authorize/{provider}?redirect_uri=https://yourapp.com/callback
# Handle callback and exchange code for tokens
POST /oauth/callback/{provider}
{
"code": "authorization_code_from_provider",
"state": "csrf_state_token"
}
```
**Security Features:**
- PKCE (S256) for Google
- State parameter for CSRF protection
- Nonce for Google OIDC replay attack prevention
- Google ID token signature verification via JWKS
- Email normalization to prevent account duplication
- Auto-linking by email (configurable)
#### OAuth Provider Mode (MCP Integration)
Full OAuth 2.0 Authorization Server for third-party clients (RFC compliant):
```
┌─────────────┐ ┌─────────────┐
│ MCP Client │ │ Backend │
└──────┬──────┘ └──────┬──────┘
│ │
│ GET /.well-known/oauth-authorization-server│
│─────────────────────────────────────────────>│
│ {metadata} │
│<─────────────────────────────────────────────│
│ │
│ GET /oauth/provider/authorize │
│ ?response_type=code&client_id=... │
│ &redirect_uri=...&code_challenge=... │
│─────────────────────────────────────────────>│
│ │
│ (User consents) │
│ │
│ 302 redirect_uri?code=AUTH_CODE&state=... │
│<─────────────────────────────────────────────│
│ │
│ POST /oauth/provider/token │
│ {grant_type=authorization_code, │
│ code=AUTH_CODE, code_verifier=...} │
│─────────────────────────────────────────────>│
│ │
│ {access_token, refresh_token, expires_in} │
│<─────────────────────────────────────────────│
│ │
```
**Endpoints:**
- `GET /.well-known/oauth-authorization-server` - RFC 8414 metadata
- `GET /oauth/provider/authorize` - Authorization endpoint
- `POST /oauth/provider/token` - Token endpoint (authorization_code, refresh_token)
- `POST /oauth/provider/revoke` - RFC 7009 token revocation
- `POST /oauth/provider/introspect` - RFC 7662 token introspection
**Security Features:**
- PKCE S256 required for public clients (plain method rejected)
- Authorization codes are single-use with 10-minute expiry
- Code reuse detection triggers security incident (all tokens revoked)
- Refresh token rotation on use
- Opaque refresh tokens (hashed in database)
- JWT access tokens with standard claims
- Consent management per client
## Error Handling ## Error Handling
### Exception Hierarchy ### Exception Hierarchy

View File

@@ -291,9 +291,8 @@ class TestOAuthProviderEndpoints:
with patch("app.api.routes.oauth_provider.settings") as mock_settings: with patch("app.api.routes.oauth_provider.settings") as mock_settings:
mock_settings.OAUTH_PROVIDER_ENABLED = False mock_settings.OAUTH_PROVIDER_ENABLED = False
response = await client.get( # RFC 8414: well-known endpoint is at root level
"/api/v1/oauth/.well-known/oauth-authorization-server" response = await client.get("/.well-known/oauth-authorization-server")
)
assert response.status_code == 404 assert response.status_code == 404
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -303,9 +302,8 @@ class TestOAuthProviderEndpoints:
mock_settings.OAUTH_PROVIDER_ENABLED = True mock_settings.OAUTH_PROVIDER_ENABLED = True
mock_settings.OAUTH_ISSUER = "https://api.example.com" mock_settings.OAUTH_ISSUER = "https://api.example.com"
response = await client.get( # RFC 8414: well-known endpoint is at root level
"/api/v1/oauth/.well-known/oauth-authorization-server" response = await client.get("/.well-known/oauth-authorization-server")
)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["issuer"] == "https://api.example.com" assert data["issuer"] == "https://api.example.com"
@@ -344,8 +342,10 @@ class TestOAuthProviderEndpoints:
assert response.status_code == 404 assert response.status_code == 404
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_provider_authorize_requires_auth(self, client, async_test_db): async def test_provider_authorize_public_client_requires_pkce(
"""Test provider authorize requires authentication.""" self, client, async_test_db
):
"""Test provider authorize requires PKCE for public clients."""
_test_engine, AsyncTestingSessionLocal = async_test_db _test_engine, AsyncTestingSessionLocal = async_test_db
# Create a test client # Create a test client
@@ -373,9 +373,54 @@ class TestOAuthProviderEndpoints:
"client_id": test_client_id, "client_id": test_client_id,
"redirect_uri": "http://localhost:3000/callback", "redirect_uri": "http://localhost:3000/callback",
}, },
follow_redirects=False,
) )
# Authorize endpoint requires authentication # Public client without PKCE gets redirect with error
assert response.status_code == 401 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 @pytest.mark.asyncio
async def test_provider_token_requires_client_id(self, client): async def test_provider_token_requires_client_id(self, client):

View File

@@ -0,0 +1,942 @@
"""
E2E Tests for OAuth Provider (Authorization Server) Workflows.
Tests the complete OAuth 2.0 Authorization Server functionality using
real PostgreSQL containers via Testcontainers.
Requirements:
- Docker must be running
- E2E dependencies: make install-e2e
Run with:
make test-e2e
# or specific file:
TESTCONTAINERS_RYUK_DISABLED=true IS_TEST=True uv run pytest tests/e2e/test_oauth_provider_workflows.py -v
"""
import base64
import hashlib
import secrets
from unittest.mock import patch
import pytest
import pytest_asyncio
from app.core.config import settings
def generate_pkce_pair():
"""Generate PKCE code_verifier and code_challenge pair."""
code_verifier = secrets.token_urlsafe(64)
# S256: SHA256(code_verifier) then base64url encode
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
return code_verifier, code_challenge
def get_error_message(response_json: dict) -> str:
"""Extract error message from API response (supports both formats)."""
# New standardized format with errors array
if response_json.get("errors"):
return response_json["errors"][0].get("message", "")
# Legacy format with detail
return response_json.get("detail", "")
@pytest.mark.e2e
@pytest.mark.postgres
class TestOAuthProviderServerMetadata:
"""Test OAuth Provider server metadata endpoint."""
@pytest.mark.asyncio
async def test_server_metadata_endpoint(self, e2e_client):
"""Test RFC 8414 well-known endpoint returns correct metadata."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
with patch.object(settings, "OAUTH_ISSUER", "https://api.example.com"):
response = await e2e_client.get(
"/.well-known/oauth-authorization-server"
)
assert response.status_code == 200
data = response.json()
# Verify required metadata fields
assert data["issuer"] == "https://api.example.com"
assert "authorization_endpoint" in data
assert "token_endpoint" in data
assert "revocation_endpoint" in data
assert "introspection_endpoint" in data
# Verify supported features
assert "code" in data["response_types_supported"]
assert "authorization_code" in data["grant_types_supported"]
assert "refresh_token" in data["grant_types_supported"]
assert "S256" in data["code_challenge_methods_supported"]
@pytest.mark.asyncio
async def test_server_metadata_disabled(self, e2e_client):
"""Test server metadata returns 404 when provider mode is disabled."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", False):
response = await e2e_client.get("/.well-known/oauth-authorization-server")
assert response.status_code == 404
@pytest.mark.e2e
@pytest.mark.postgres
class TestOAuthProviderClientManagement:
"""Test OAuth client registration and management."""
@pytest.mark.asyncio
async def test_register_public_client(self, e2e_client, e2e_superuser):
"""Test registering a public OAuth client."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
response = await e2e_client.post(
"/api/v1/oauth/provider/clients",
headers={
"Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}"
},
data={
"client_name": "Test MCP Client",
"redirect_uris": "http://localhost:3000/callback",
"client_type": "public",
"scopes": "openid profile email",
},
)
assert response.status_code == 200
data = response.json()
assert "client_id" in data
assert data["client_name"] == "Test MCP Client"
assert data["client_type"] == "public"
# Public clients don't get a secret
assert "client_secret" not in data
@pytest.mark.asyncio
async def test_register_confidential_client(self, e2e_client, e2e_superuser):
"""Test registering a confidential OAuth client."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
response = await e2e_client.post(
"/api/v1/oauth/provider/clients",
headers={
"Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}"
},
data={
"client_name": "Confidential App",
"redirect_uris": "http://localhost:8080/callback",
"client_type": "confidential",
"scopes": "openid profile email read:users",
},
)
assert response.status_code == 200
data = response.json()
assert "client_id" in data
assert "client_secret" in data
assert data["client_type"] == "confidential"
assert "warning" in data # Security warning about storing secret
@pytest.mark.asyncio
async def test_list_clients(self, e2e_client, e2e_superuser):
"""Test listing all OAuth clients."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
# First create a client
await e2e_client.post(
"/api/v1/oauth/provider/clients",
headers={
"Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}"
},
data={
"client_name": "List Test Client",
"redirect_uris": "http://localhost:3000/callback",
"client_type": "public",
"scopes": "openid",
},
)
# Then list all
response = await e2e_client.get(
"/api/v1/oauth/provider/clients",
headers={
"Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}"
},
)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) >= 1
@pytest.mark.asyncio
async def test_delete_client(self, e2e_client, e2e_superuser):
"""Test deleting an OAuth client."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
# Create a client
create_response = await e2e_client.post(
"/api/v1/oauth/provider/clients",
headers={
"Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}"
},
data={
"client_name": "Delete Test Client",
"redirect_uris": "http://localhost:3000/callback",
"client_type": "public",
"scopes": "openid",
},
)
client_id = create_response.json()["client_id"]
# Delete it
delete_response = await e2e_client.delete(
f"/api/v1/oauth/provider/clients/{client_id}",
headers={
"Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}"
},
)
assert delete_response.status_code == 204
# Verify it's gone
list_response = await e2e_client.get(
"/api/v1/oauth/provider/clients",
headers={
"Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}"
},
)
clients = list_response.json()
assert not any(c["client_id"] == client_id for c in clients)
@pytest.mark.e2e
@pytest.mark.postgres
class TestOAuthProviderAuthorizationFlow:
"""Test OAuth authorization code flow."""
@pytest_asyncio.fixture
async def oauth_client(self, e2e_client, e2e_superuser):
"""Create a test OAuth client for authorization tests."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
response = await e2e_client.post(
"/api/v1/oauth/provider/clients",
headers={
"Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}"
},
data={
"client_name": "Auth Flow Test Client",
"redirect_uris": "http://localhost:3000/callback,http://localhost:3000/oauth/callback",
"client_type": "public",
"scopes": "openid profile email read:users",
},
)
return response.json()
@pytest.mark.asyncio
async def test_authorize_requires_pkce_for_public_client(
self, e2e_client, oauth_client
):
"""Test that public clients must use PKCE."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
with patch.object(settings, "FRONTEND_URL", "http://localhost:3000"):
response = await e2e_client.get(
"/api/v1/oauth/provider/authorize",
params={
"response_type": "code",
"client_id": oauth_client["client_id"],
"redirect_uri": "http://localhost:3000/callback",
"scope": "openid profile",
"state": "test_state_123",
},
follow_redirects=False,
)
# Should redirect with error
assert response.status_code == 302
location = response.headers.get("location", "")
assert "error=invalid_request" in location
assert "PKCE" in location
@pytest.mark.asyncio
async def test_authorize_redirects_unauthenticated_to_login(
self, e2e_client, oauth_client
):
"""Test that unauthenticated users are redirected to login."""
_code_verifier, code_challenge = generate_pkce_pair()
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
with patch.object(settings, "FRONTEND_URL", "http://localhost:3000"):
response = await e2e_client.get(
"/api/v1/oauth/provider/authorize",
params={
"response_type": "code",
"client_id": oauth_client["client_id"],
"redirect_uri": "http://localhost:3000/callback",
"scope": "openid profile",
"state": "test_state_123",
"code_challenge": code_challenge,
"code_challenge_method": "S256",
},
follow_redirects=False,
)
# Should redirect 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_authorize_redirects_to_consent_for_authenticated_user(
self, e2e_client, oauth_client, e2e_superuser
):
"""Test that authenticated users without consent are redirected to consent page."""
_code_verifier, code_challenge = generate_pkce_pair()
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
with patch.object(settings, "FRONTEND_URL", "http://localhost:3000"):
response = await e2e_client.get(
"/api/v1/oauth/provider/authorize",
params={
"response_type": "code",
"client_id": oauth_client["client_id"],
"redirect_uri": "http://localhost:3000/callback",
"scope": "openid profile",
"state": "test_state_123",
"code_challenge": code_challenge,
"code_challenge_method": "S256",
},
headers={
"Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}"
},
follow_redirects=False,
)
# Should redirect to consent page
assert response.status_code == 302
location = response.headers.get("location", "")
assert "/auth/consent" in location
@pytest.mark.e2e
@pytest.mark.postgres
class TestOAuthProviderCompleteFlow:
"""Test complete OAuth authorization code flow with token exchange."""
@pytest_asyncio.fixture
async def oauth_setup(self, e2e_client, e2e_superuser):
"""Create OAuth client and prepare for authorization flow."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
# Create a public client
response = await e2e_client.post(
"/api/v1/oauth/provider/clients",
headers={
"Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}"
},
data={
"client_name": "Complete Flow Test Client",
"redirect_uris": "http://localhost:3000/callback",
"client_type": "public",
"scopes": "openid profile email",
},
)
client_data = response.json()
# Generate PKCE pair
code_verifier, code_challenge = generate_pkce_pair()
return {
"client": client_data,
"code_verifier": code_verifier,
"code_challenge": code_challenge,
"user": e2e_superuser,
}
@pytest.mark.asyncio
async def test_consent_submission_generates_code(self, e2e_client, oauth_setup):
"""Test that submitting consent generates an authorization code."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
response = await e2e_client.post(
"/api/v1/oauth/provider/authorize/consent",
headers={
"Authorization": f"Bearer {oauth_setup['user']['tokens']['access_token']}"
},
data={
"approved": "true",
"client_id": oauth_setup["client"]["client_id"],
"redirect_uri": "http://localhost:3000/callback",
"scope": "openid profile email",
"state": "test_state_456",
"code_challenge": oauth_setup["code_challenge"],
"code_challenge_method": "S256",
},
follow_redirects=False,
)
# Should redirect with code
assert response.status_code == 302
location = response.headers.get("location", "")
assert "code=" in location
assert "state=test_state_456" in location
assert "error" not in location
@pytest.mark.asyncio
async def test_consent_denial_returns_error(self, e2e_client, oauth_setup):
"""Test that denying consent returns access_denied error."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
response = await e2e_client.post(
"/api/v1/oauth/provider/authorize/consent",
headers={
"Authorization": f"Bearer {oauth_setup['user']['tokens']['access_token']}"
},
data={
"approved": "false",
"client_id": oauth_setup["client"]["client_id"],
"redirect_uri": "http://localhost:3000/callback",
"scope": "openid profile",
"state": "test_state_789",
},
follow_redirects=False,
)
# Should redirect with error
assert response.status_code == 302
location = response.headers.get("location", "")
assert "error=access_denied" in location
assert "state=test_state_789" in location
@pytest.mark.asyncio
async def test_complete_authorization_code_flow(self, e2e_client, oauth_setup):
"""Test complete flow: consent -> code -> token exchange."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
with patch.object(settings, "OAUTH_ISSUER", "http://e2e-test"):
# Step 1: Submit consent
consent_response = await e2e_client.post(
"/api/v1/oauth/provider/authorize/consent",
headers={
"Authorization": f"Bearer {oauth_setup['user']['tokens']['access_token']}"
},
data={
"approved": "true",
"client_id": oauth_setup["client"]["client_id"],
"redirect_uri": "http://localhost:3000/callback",
"scope": "openid profile email",
"state": "flow_test_state",
"code_challenge": oauth_setup["code_challenge"],
"code_challenge_method": "S256",
},
follow_redirects=False,
)
assert consent_response.status_code == 302
location = consent_response.headers.get("location", "")
# Extract code from redirect URL
from urllib.parse import parse_qs, urlparse
parsed = urlparse(location)
params = parse_qs(parsed.query)
assert "code" in params
auth_code = params["code"][0]
# Step 2: Exchange code for tokens
token_response = await e2e_client.post(
"/api/v1/oauth/provider/token",
data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": "http://localhost:3000/callback",
"client_id": oauth_setup["client"]["client_id"],
"code_verifier": oauth_setup["code_verifier"],
},
)
assert token_response.status_code == 200
tokens = token_response.json()
assert "access_token" in tokens
assert "refresh_token" in tokens
assert tokens["token_type"] == "Bearer"
assert "expires_in" in tokens
assert "scope" in tokens
@pytest.mark.e2e
@pytest.mark.postgres
class TestOAuthProviderTokenOperations:
"""Test token refresh, revocation, and introspection."""
@pytest_asyncio.fixture
async def tokens_setup(self, e2e_client, e2e_superuser):
"""Get tokens through complete OAuth flow for testing."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
with patch.object(settings, "OAUTH_ISSUER", "http://e2e-test"):
# Create client
client_response = await e2e_client.post(
"/api/v1/oauth/provider/clients",
headers={
"Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}"
},
data={
"client_name": "Token Ops Test Client",
"redirect_uris": "http://localhost:3000/callback",
"client_type": "public",
"scopes": "openid profile email",
},
)
client_data = client_response.json()
# Generate PKCE
code_verifier, code_challenge = generate_pkce_pair()
# Submit consent
consent_response = await e2e_client.post(
"/api/v1/oauth/provider/authorize/consent",
headers={
"Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}"
},
data={
"approved": "true",
"client_id": client_data["client_id"],
"redirect_uri": "http://localhost:3000/callback",
"scope": "openid profile email",
"code_challenge": code_challenge,
"code_challenge_method": "S256",
},
follow_redirects=False,
)
# Extract code
from urllib.parse import parse_qs, urlparse
location = consent_response.headers.get("location", "")
parsed = urlparse(location)
params = parse_qs(parsed.query)
auth_code = params["code"][0]
# Exchange for tokens
token_response = await e2e_client.post(
"/api/v1/oauth/provider/token",
data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": "http://localhost:3000/callback",
"client_id": client_data["client_id"],
"code_verifier": code_verifier,
},
)
return {
"tokens": token_response.json(),
"client": client_data,
"user": e2e_superuser,
}
@pytest.mark.asyncio
async def test_token_introspection_active(self, e2e_client, tokens_setup):
"""Test introspecting an active access token."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
with patch.object(settings, "OAUTH_ISSUER", "http://e2e-test"):
response = await e2e_client.post(
"/api/v1/oauth/provider/introspect",
data={
"token": tokens_setup["tokens"]["access_token"],
"token_type_hint": "access_token",
},
)
assert response.status_code == 200
data = response.json()
assert data["active"] is True
assert "scope" in data
assert data["client_id"] == tokens_setup["client"]["client_id"]
@pytest.mark.asyncio
async def test_token_introspection_refresh_token(self, e2e_client, tokens_setup):
"""Test introspecting a refresh token."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
response = await e2e_client.post(
"/api/v1/oauth/provider/introspect",
data={
"token": tokens_setup["tokens"]["refresh_token"],
"token_type_hint": "refresh_token",
},
)
assert response.status_code == 200
data = response.json()
assert data["active"] is True
assert data["token_type"] == "refresh_token"
@pytest.mark.asyncio
async def test_token_refresh(self, e2e_client, tokens_setup):
"""Test refreshing an access token."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
with patch.object(settings, "OAUTH_ISSUER", "http://e2e-test"):
response = await e2e_client.post(
"/api/v1/oauth/provider/token",
data={
"grant_type": "refresh_token",
"refresh_token": tokens_setup["tokens"]["refresh_token"],
"client_id": tokens_setup["client"]["client_id"],
},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert "refresh_token" in data
# New tokens should be different (token rotation)
assert data["access_token"] != tokens_setup["tokens"]["access_token"]
assert data["refresh_token"] != tokens_setup["tokens"]["refresh_token"]
@pytest.mark.asyncio
async def test_token_revocation(self, e2e_client, tokens_setup):
"""Test revoking a refresh token."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
# Revoke refresh token
revoke_response = await e2e_client.post(
"/api/v1/oauth/provider/revoke",
data={
"token": tokens_setup["tokens"]["refresh_token"],
"token_type_hint": "refresh_token",
},
)
# Per RFC 7009, always returns 200
assert revoke_response.status_code == 200
# Verify token is no longer active
introspect_response = await e2e_client.post(
"/api/v1/oauth/provider/introspect",
data={
"token": tokens_setup["tokens"]["refresh_token"],
"token_type_hint": "refresh_token",
},
)
assert introspect_response.status_code == 200
assert introspect_response.json()["active"] is False
@pytest.mark.asyncio
async def test_revoked_refresh_token_cannot_be_used(self, e2e_client, tokens_setup):
"""Test that a revoked refresh token cannot be used for refresh."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
# Revoke first
await e2e_client.post(
"/api/v1/oauth/provider/revoke",
data={
"token": tokens_setup["tokens"]["refresh_token"],
},
)
# Try to use revoked token
refresh_response = await e2e_client.post(
"/api/v1/oauth/provider/token",
data={
"grant_type": "refresh_token",
"refresh_token": tokens_setup["tokens"]["refresh_token"],
"client_id": tokens_setup["client"]["client_id"],
},
)
assert refresh_response.status_code == 400
assert "invalid_grant" in get_error_message(refresh_response.json())
@pytest.mark.e2e
@pytest.mark.postgres
class TestOAuthProviderConsentManagement:
"""Test user consent listing and revocation."""
@pytest_asyncio.fixture
async def consent_setup(self, e2e_client, e2e_superuser):
"""Create OAuth client and grant consent for testing."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
with patch.object(settings, "OAUTH_ISSUER", "http://e2e-test"):
# Create client
client_response = await e2e_client.post(
"/api/v1/oauth/provider/clients",
headers={
"Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}"
},
data={
"client_name": "Consent Test Client",
"redirect_uris": "http://localhost:3000/callback",
"client_type": "public",
"scopes": "openid profile email",
},
)
client_data = client_response.json()
# Generate PKCE and grant consent
_code_verifier, code_challenge = generate_pkce_pair()
await e2e_client.post(
"/api/v1/oauth/provider/authorize/consent",
headers={
"Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}"
},
data={
"approved": "true",
"client_id": client_data["client_id"],
"redirect_uri": "http://localhost:3000/callback",
"scope": "openid profile email",
"code_challenge": code_challenge,
"code_challenge_method": "S256",
},
follow_redirects=False,
)
return {
"client": client_data,
"user": e2e_superuser,
}
@pytest.mark.asyncio
async def test_list_my_consents(self, e2e_client, consent_setup):
"""Test listing user's OAuth consents."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
response = await e2e_client.get(
"/api/v1/oauth/provider/consents",
headers={
"Authorization": f"Bearer {consent_setup['user']['tokens']['access_token']}"
},
)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) >= 1
# Find our consent
consent = next(
(
c
for c in data
if c["client_id"] == consent_setup["client"]["client_id"]
),
None,
)
assert consent is not None
assert consent["client_name"] == "Consent Test Client"
assert "granted_scopes" in consent
assert "granted_at" in consent
@pytest.mark.asyncio
async def test_revoke_my_consent(self, e2e_client, consent_setup):
"""Test revoking user's OAuth consent."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
# Revoke consent
revoke_response = await e2e_client.delete(
f"/api/v1/oauth/provider/consents/{consent_setup['client']['client_id']}",
headers={
"Authorization": f"Bearer {consent_setup['user']['tokens']['access_token']}"
},
)
assert revoke_response.status_code == 204
# Verify consent is gone
list_response = await e2e_client.get(
"/api/v1/oauth/provider/consents",
headers={
"Authorization": f"Bearer {consent_setup['user']['tokens']['access_token']}"
},
)
consents = list_response.json()
assert not any(
c["client_id"] == consent_setup["client"]["client_id"] for c in consents
)
@pytest.mark.e2e
@pytest.mark.postgres
class TestOAuthProviderSecurityChecks:
"""Test OAuth Provider security features."""
@pytest_asyncio.fixture
async def security_setup(self, e2e_client, e2e_superuser):
"""Create OAuth client for security tests."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
response = await e2e_client.post(
"/api/v1/oauth/provider/clients",
headers={
"Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}"
},
data={
"client_name": "Security Test Client",
"redirect_uris": "http://localhost:3000/callback",
"client_type": "public",
"scopes": "openid profile email",
},
)
return {
"client": response.json(),
"user": e2e_superuser,
}
@pytest.mark.asyncio
async def test_invalid_redirect_uri_rejected(self, e2e_client, security_setup):
"""Test that unregistered redirect_uri is rejected."""
_code_verifier, code_challenge = generate_pkce_pair()
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
response = await e2e_client.get(
"/api/v1/oauth/provider/authorize",
params={
"response_type": "code",
"client_id": security_setup["client"]["client_id"],
"redirect_uri": "http://evil.com/callback", # Not registered
"scope": "openid",
"code_challenge": code_challenge,
"code_challenge_method": "S256",
},
headers={
"Authorization": f"Bearer {security_setup['user']['tokens']['access_token']}"
},
)
# Should return 400, not redirect
assert response.status_code == 400
assert "Invalid redirect_uri" in get_error_message(response.json())
@pytest.mark.asyncio
async def test_plain_pkce_method_rejected(self, e2e_client, security_setup):
"""Test that 'plain' PKCE method is rejected (security requirement)."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
with patch.object(settings, "FRONTEND_URL", "http://localhost:3000"):
response = await e2e_client.get(
"/api/v1/oauth/provider/authorize",
params={
"response_type": "code",
"client_id": security_setup["client"]["client_id"],
"redirect_uri": "http://localhost:3000/callback",
"scope": "openid",
"code_challenge": "some_challenge",
"code_challenge_method": "plain", # Should be rejected
},
headers={
"Authorization": f"Bearer {security_setup['user']['tokens']['access_token']}"
},
)
assert response.status_code == 400
assert "S256" in get_error_message(response.json())
@pytest.mark.asyncio
async def test_authorization_code_single_use(self, e2e_client, security_setup):
"""Test that authorization codes can only be used once."""
code_verifier, code_challenge = generate_pkce_pair()
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
with patch.object(settings, "OAUTH_ISSUER", "http://e2e-test"):
# Get an authorization code
consent_response = await e2e_client.post(
"/api/v1/oauth/provider/authorize/consent",
headers={
"Authorization": f"Bearer {security_setup['user']['tokens']['access_token']}"
},
data={
"approved": "true",
"client_id": security_setup["client"]["client_id"],
"redirect_uri": "http://localhost:3000/callback",
"scope": "openid profile",
"code_challenge": code_challenge,
"code_challenge_method": "S256",
},
follow_redirects=False,
)
from urllib.parse import parse_qs, urlparse
location = consent_response.headers.get("location", "")
parsed = urlparse(location)
params = parse_qs(parsed.query)
auth_code = params["code"][0]
# First use - should succeed
first_response = await e2e_client.post(
"/api/v1/oauth/provider/token",
data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": "http://localhost:3000/callback",
"client_id": security_setup["client"]["client_id"],
"code_verifier": code_verifier,
},
)
assert first_response.status_code == 200
# Second use - should fail
second_response = await e2e_client.post(
"/api/v1/oauth/provider/token",
data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": "http://localhost:3000/callback",
"client_id": security_setup["client"]["client_id"],
"code_verifier": code_verifier,
},
)
assert second_response.status_code == 400
assert "already been used" in get_error_message(second_response.json())
@pytest.mark.asyncio
async def test_invalid_pkce_verifier_rejected(self, e2e_client, security_setup):
"""Test that wrong code_verifier is rejected."""
_code_verifier, code_challenge = generate_pkce_pair()
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
with patch.object(settings, "OAUTH_ISSUER", "http://e2e-test"):
# Get an authorization code
consent_response = await e2e_client.post(
"/api/v1/oauth/provider/authorize/consent",
headers={
"Authorization": f"Bearer {security_setup['user']['tokens']['access_token']}"
},
data={
"approved": "true",
"client_id": security_setup["client"]["client_id"],
"redirect_uri": "http://localhost:3000/callback",
"scope": "openid profile",
"code_challenge": code_challenge,
"code_challenge_method": "S256",
},
follow_redirects=False,
)
from urllib.parse import parse_qs, urlparse
location = consent_response.headers.get("location", "")
parsed = urlparse(location)
params = parse_qs(parsed.query)
auth_code = params["code"][0]
# Use wrong verifier
response = await e2e_client.post(
"/api/v1/oauth/provider/token",
data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": "http://localhost:3000/callback",
"client_id": security_setup["client"]["client_id"],
"code_verifier": "wrong_verifier_value",
},
)
assert response.status_code == 400
assert "code_verifier" in get_error_message(response.json()).lower()
@pytest.mark.asyncio
async def test_introspect_invalid_token(self, e2e_client):
"""Test that introspecting an invalid token returns active=false."""
with patch.object(settings, "OAUTH_PROVIDER_ENABLED", True):
response = await e2e_client.post(
"/api/v1/oauth/provider/introspect",
data={
"token": "invalid_token_that_does_not_exist",
},
)
assert response.status_code == 200
assert response.json()["active"] is False

View File

@@ -4,7 +4,7 @@
*/ */
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { setupAuthenticatedMocks } from './helpers/auth'; import { setupAuthenticatedMocks, loginViaUI } from './helpers/auth';
test.describe('OAuth Authentication', () => { test.describe('OAuth Authentication', () => {
test.describe('Login Page OAuth', () => { test.describe('Login Page OAuth', () => {
@@ -167,4 +167,255 @@ test.describe('OAuth Authentication', () => {
expect(authorizationCalled).toBe(true); expect(authorizationCalled).toBe(true);
}); });
}); });
test.describe('OAuth Provider Consent Page', () => {
const mockConsentParams = {
client_id: 'test-mcp-client-id',
client_name: 'Test MCP Application',
redirect_uri: 'http://localhost:3001/callback',
scope: 'openid profile email',
state: 'mock-state-token-123',
code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
code_challenge_method: 'S256',
};
test.beforeEach(async ({ page }) => {
// Set up API mocks
await setupAuthenticatedMocks(page);
});
test('should display error when required params are missing', async ({ page }) => {
// Navigate to consent page without params
await page.goto('/en/auth/consent');
// Should show error alert
await expect(page.getByText(/invalid authorization request/i)).toBeVisible({
timeout: 10000,
});
await expect(page.getByText(/missing required parameters/i)).toBeVisible();
});
test('should redirect unauthenticated users to login', async ({ page }) => {
// Build consent URL with params
const params = new URLSearchParams(mockConsentParams);
// Navigate to consent page without being logged in
await page.goto(`/en/auth/consent?${params.toString()}`);
// Should redirect to login page with return_to parameter
await expect(page).toHaveURL(/\/login\?return_to=/i, { timeout: 10000 });
});
test('should display consent page with client info for authenticated user', async ({
page,
}) => {
// First log in
await loginViaUI(page);
// Build consent URL with params
const params = new URLSearchParams(mockConsentParams);
// Navigate to consent page
await page.goto(`/en/auth/consent?${params.toString()}`);
// Should display authorization request header
await expect(page.getByText(/authorization request/i)).toBeVisible({ timeout: 10000 });
// Should display client name
await expect(page.getByText('Test MCP Application')).toBeVisible();
// Should display the requested scopes (use exact match to avoid duplicates)
await expect(page.getByText('OpenID Connect')).toBeVisible();
await expect(page.getByLabel('Profile')).toBeVisible();
await expect(page.getByLabel('Email')).toBeVisible();
// Should display redirect URI
await expect(page.getByText(/localhost:3001\/callback/i)).toBeVisible();
// Should display Authorize and Deny buttons
await expect(page.getByRole('button', { name: /authorize/i })).toBeVisible();
await expect(page.getByRole('button', { name: /deny/i })).toBeVisible();
});
test('should allow toggling scopes', async ({ page }) => {
// First log in
await loginViaUI(page);
// Build consent URL with params
const params = new URLSearchParams(mockConsentParams);
await page.goto(`/en/auth/consent?${params.toString()}`);
// Wait for page to load
await expect(page.getByText(/authorization request/i)).toBeVisible({ timeout: 10000 });
// Find OpenID scope checkbox and toggle it
const openidCheckbox = page.locator('#scope-openid');
await expect(openidCheckbox).toBeChecked();
// Uncheck it
await openidCheckbox.click();
await expect(openidCheckbox).not.toBeChecked();
// Check it again
await openidCheckbox.click();
await expect(openidCheckbox).toBeChecked();
});
test('should disable Authorize button when no scopes selected', async ({ page }) => {
// First log in
await loginViaUI(page);
// Build consent URL with single scope for easier testing
const params = new URLSearchParams({
...mockConsentParams,
scope: 'openid',
});
await page.goto(`/en/auth/consent?${params.toString()}`);
// Wait for page to load
await expect(page.getByText(/authorization request/i)).toBeVisible({ timeout: 10000 });
// Initially Authorize button should be enabled
const authorizeButton = page.getByRole('button', { name: /authorize/i });
await expect(authorizeButton).toBeEnabled();
// Uncheck the only scope
await page.locator('#scope-openid').click();
// Now Authorize button should be disabled
await expect(authorizeButton).toBeDisabled();
});
test('should call consent API when clicking Authorize', async ({ page }) => {
// First log in
await loginViaUI(page);
// Mock the consent submission endpoint (use wildcard to catch all variations)
let consentSubmitted = false;
await page.route('**/api/v1/oauth/provider/authorize/consent', async (route) => {
if (route.request().method() === 'POST') {
consentSubmitted = true;
// Simulate redirect response
await route.fulfill({
status: 302,
headers: {
Location: `${mockConsentParams.redirect_uri}?code=mock-auth-code&state=${mockConsentParams.state}`,
},
});
} else {
await route.continue();
}
});
// Prevent actual navigation to callback URL
await page.route('http://localhost:3001/**', (route) => route.fulfill({ status: 200 }));
// Build consent URL with params
const params = new URLSearchParams(mockConsentParams);
await page.goto(`/en/auth/consent?${params.toString()}`);
// Wait for page to load
await expect(page.getByText(/authorization request/i)).toBeVisible({ timeout: 10000 });
// Click Authorize
await page.getByRole('button', { name: /authorize/i }).click();
// Wait for API call
await page.waitForTimeout(1000);
// Verify consent was submitted
expect(consentSubmitted).toBe(true);
});
test('should call consent API with approved=false when clicking Deny', async ({ page }) => {
// First log in
await loginViaUI(page);
// Track the request
let requestMade = false;
let postDataContainsFalse = false;
// Mock the consent submission endpoint
await page.route('**/api/v1/oauth/provider/authorize/consent', async (route) => {
requestMade = true;
const postData = route.request().postData();
// FormData is multipart, so we check if "false" appears after "approved"
if (postData && postData.includes('name="approved"')) {
// The multipart format has approved value after the field name line
postDataContainsFalse = postData.includes('false');
}
// Return a simple success response
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true }),
});
});
// Build consent URL with params
const params = new URLSearchParams(mockConsentParams);
await page.goto(`/en/auth/consent?${params.toString()}`);
// Wait for page to load
await expect(page.getByText(/authorization request/i)).toBeVisible({ timeout: 10000 });
// Click Deny and wait for the request
await Promise.all([
page.waitForResponse('**/api/v1/oauth/provider/authorize/consent'),
page.getByRole('button', { name: /deny/i }).click(),
]);
// Verify the request was made with approved=false
expect(requestMade).toBe(true);
expect(postDataContainsFalse).toBe(true);
});
test('should show loading state while submitting', async ({ page }) => {
// First log in
await loginViaUI(page);
// We'll use a promise that we can resolve manually to control when the request completes
let resolveRequest: () => void;
const requestComplete = new Promise<void>((resolve) => {
resolveRequest = resolve;
});
// Mock the consent submission endpoint with controlled delay
await page.route('**/api/v1/oauth/provider/authorize/consent', async (route) => {
// Wait until we've verified the loading state, then complete
await requestComplete;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true }),
});
});
// Build consent URL with params
const params = new URLSearchParams(mockConsentParams);
await page.goto(`/en/auth/consent?${params.toString()}`);
// Wait for page to load
await expect(page.getByText(/authorization request/i)).toBeVisible({ timeout: 10000 });
// Get buttons before clicking
const authorizeBtn = page.getByRole('button', { name: /authorize/i });
const denyBtn = page.getByRole('button', { name: /deny/i });
// Verify buttons are initially enabled
await expect(authorizeBtn).toBeEnabled();
await expect(denyBtn).toBeEnabled();
// Click Authorize (don't await - let it start the request)
authorizeBtn.click();
// Should show loading spinner while request is pending
await expect(page.locator('.animate-spin').first()).toBeVisible({ timeout: 5000 });
// Now resolve the request to clean up
resolveRequest!();
});
});
}); });

View File

@@ -8,7 +8,8 @@ const createJestConfig = nextJest({
// Add any custom config to be passed to Jest // Add any custom config to be passed to Jest
const customJestConfig = { const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'], setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom', // Custom environment that suppresses jsdom VirtualConsole XMLHttpRequest errors
testEnvironment: '<rootDir>/jest.environment.js',
moduleNameMapper: { moduleNameMapper: {
'^next-intl$': '<rootDir>/tests/__mocks__/next-intl.tsx', '^next-intl$': '<rootDir>/tests/__mocks__/next-intl.tsx',
'^next-intl/routing$': '<rootDir>/tests/__mocks__/next-intl-routing.tsx', '^next-intl/routing$': '<rootDir>/tests/__mocks__/next-intl-routing.tsx',

View File

@@ -0,0 +1,54 @@
/**
* Custom Jest environment that suppresses jsdom VirtualConsole XMLHttpRequest errors
*
* These errors occur when jsdom tries to make network requests during tests
* (e.g., XMLHttpRequest to localhost:8000) and they fail. They're harmless
* noise that clutters test output.
*/
// This file is executed by Jest in a CommonJS context; using require() here is intentional.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const JSDOMEnvironment = require('jest-environment-jsdom').default;
class CustomJSDOMEnvironment extends JSDOMEnvironment {
constructor(config, context) {
// Customize virtualConsole options to suppress specific errors
const customConfig = {
...config,
projectConfig: {
...config.projectConfig,
testEnvironmentOptions: {
...config.projectConfig?.testEnvironmentOptions,
// Custom error handling via virtualConsole.sendTo
},
},
};
super(customConfig, context);
}
async setup() {
await super.setup();
// After setup, intercept console.error to filter XMLHttpRequest noise
// This is called by jsdom's VirtualConsole when errors occur
const originalConsoleError = this.global.console.error;
this.global.console.error = (...args) => {
const message = args[0]?.toString() || '';
const errorType = args[0]?.type || '';
// Filter out XMLHttpRequest/AggregateError noise from jsdom
if (
message.includes('AggregateError') ||
message.includes('XMLHttpRequest') ||
errorType === 'XMLHttpRequest'
) {
return;
}
originalConsoleError.apply(this.global.console, args);
};
}
}
module.exports = CustomJSDOMEnvironment;

View File

@@ -95,7 +95,7 @@ export default function OAuthConsentPage() {
// Note: t is available for future i18n use // Note: t is available for future i18n use
const _t = useTranslations('auth.oauth'); const _t = useTranslations('auth.oauth');
void _t; // Suppress unused warning - ready for i18n 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 [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -181,9 +181,14 @@ export default function OAuthConsentPage() {
// Submit consent to backend // Submit consent to backend
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; 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`, { const response = await fetch(`${apiUrl}/api/v1/oauth/provider/authorize/consent`, {
method: 'POST', method: 'POST',
body: formData, body: formData,
headers,
credentials: 'include', credentials: 'include',
}); });

View File

@@ -11,8 +11,8 @@ import { CheckCircle2 } from 'lucide-react';
export function ContextSection() { export function ContextSection() {
const features = [ const features = [
'Clone & Deploy in < 5 minutes', 'Clone & Deploy in < 5 minutes',
'Comprehensive Test Suite', 'OAuth 2.0 + Social Login',
'12+ Documentation Guides', 'i18n Ready (EN, IT)',
'Zero Commercial Dependencies', 'Zero Commercial Dependencies',
]; ];

View File

@@ -14,8 +14,8 @@ const features = [
icon: Shield, icon: Shield,
title: 'Authentication & Security', title: 'Authentication & Security',
description: description:
'JWT authentication with refresh tokens, session management, password reset flow, rate limiting, CSRF protection, and comprehensive security tests preventing common attacks (CVE-2015-9235, session hijacking)', 'JWT with refresh tokens, OAuth social login (Google, GitHub with PKCE), OAuth Provider mode for MCP clients, session management, rate limiting, and comprehensive security tests',
highlight: 'Battle-tested security', highlight: 'OAuth 2.0 + MCP ready',
ctaText: 'View Auth Flow', ctaText: 'View Auth Flow',
ctaHref: '/login', ctaHref: '/login',
}, },
@@ -59,8 +59,8 @@ const features = [
icon: Code, icon: Code,
title: 'Developer Experience', title: 'Developer Experience',
description: description:
'Auto-generated TypeScript API client from OpenAPI spec, hot reload in development, migration helpers (python migrate.py auto), VS Code settings, and comprehensive component library', 'Auto-generated TypeScript API client, i18n with next-intl (English, Italian), hot reload, migration helpers, VS Code settings, and a comprehensive component library',
highlight: 'Delightful DX', highlight: 'Delightful DX + i18n',
ctaText: 'Explore Components', ctaText: 'Explore Components',
ctaHref: '/dev', ctaHref: '/dev',
}, },

View File

@@ -46,7 +46,7 @@ export function HeroSection({ onOpenDemoModal }: HeroSectionProps) {
/> />
<span className="font-medium">MIT Licensed</span> <span className="font-medium">MIT Licensed</span>
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<span className="font-medium">Comprehensive Tests</span> <span className="font-medium">OAuth 2.0 + i18n</span>
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<span className="font-medium">Pragmatic by Design</span> <span className="font-medium">Pragmatic by Design</span>
</div> </div>

View File

@@ -35,25 +35,25 @@ const technologies: Tech[] = [
color: 'from-blue-500 to-blue-700', color: 'from-blue-500 to-blue-700',
}, },
{ {
name: 'Docker', name: 'OAuth 2.0',
description: 'Containerized deployment', description: 'Social login + MCP Provider mode with PKCE',
color: 'from-blue-400 to-blue-600', color: 'from-orange-500 to-red-600',
}, },
{ {
name: 'TailwindCSS', name: 'next-intl',
description: 'Utility-first styling with OKLCH colors', description: 'Type-safe internationalization (EN, IT)',
color: 'from-cyan-500 to-blue-500', color: 'from-violet-500 to-purple-600',
},
{
name: 'shadcn/ui',
description: 'Accessible component library (New York variant)',
color: 'from-slate-800 to-slate-600',
}, },
{ {
name: 'Playwright', name: 'Playwright',
description: 'Reliable E2E testing (zero flaky tests)', description: 'Frontend E2E testing (zero flaky tests)',
color: 'from-green-600 to-emerald-700', color: 'from-green-600 to-emerald-700',
}, },
{
name: 'pytest',
description: 'Backend testing with Testcontainers',
color: 'from-yellow-500 to-orange-500',
},
]; ];
export function TechStackSection() { export function TechStackSection() {

View File

@@ -3,7 +3,7 @@
import { type Client, type Options as Options2, type TDataShape, urlSearchParamsBodySerializer } from './client'; import { type Client, type Options as Options2, type TDataShape, urlSearchParamsBodySerializer } from './client';
import { client } from './client.gen'; 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<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/** /**
@@ -353,34 +353,26 @@ export const startOauthLink = <ThrowOnError extends boolean = false>(options: Op
}; };
/** /**
* OAuth Server Metadata * Authorization Endpoint
*
* OAuth 2.0 Authorization Server Metadata (RFC 8414).
*
* Returns server metadata including supported endpoints, scopes,
* and capabilities for MCP clients.
*/
export const getOauthServerMetadata = <ThrowOnError extends boolean = false>(options?: Options<GetOauthServerMetadataData, ThrowOnError>) => {
return (options?.client ?? client).get<GetOauthServerMetadataResponses, unknown, ThrowOnError>({
responseType: 'json',
url: '/api/v1/oauth/.well-known/oauth-authorization-server',
...options
});
};
/**
* Authorization Endpoint (Skeleton)
* *
* OAuth 2.0 Authorization Endpoint. * OAuth 2.0 Authorization Endpoint.
* *
* **NOTE**: This is a skeleton implementation. In a full implementation, * Initiates the authorization code flow:
* this would: * 1. Validates client and parameters
* 1. Validate client_id and redirect_uri * 2. Checks if user is authenticated (redirects to login if not)
* 2. Display consent screen to user * 3. Checks existing consent
* 3. Generate authorization code * 4. Redirects to consent page if needed
* 4. Redirect back to client with code * 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 = <ThrowOnError extends boolean = false>(options: Options<OauthProviderAuthorizeData, ThrowOnError>) => { export const oauthProviderAuthorize = <ThrowOnError extends boolean = false>(options: Options<OauthProviderAuthorizeData, ThrowOnError>) => {
return (options.client ?? client).get<OauthProviderAuthorizeResponses, OauthProviderAuthorizeErrors, ThrowOnError>({ return (options.client ?? client).get<OauthProviderAuthorizeResponses, OauthProviderAuthorizeErrors, ThrowOnError>({
@@ -391,14 +383,43 @@ export const oauthProviderAuthorize = <ThrowOnError extends boolean = false>(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 = <ThrowOnError extends boolean = false>(options: Options<OauthProviderConsentData, ThrowOnError>) => {
return (options.client ?? client).post<OauthProviderConsentResponses, OauthProviderConsentErrors, ThrowOnError>({
...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. * OAuth 2.0 Token Endpoint.
* *
* **NOTE**: This is a skeleton implementation. In a full implementation, * Supports:
* this would exchange authorization codes for access tokens. * - 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 = <ThrowOnError extends boolean = false>(options: Options<OauthProviderTokenData, ThrowOnError>) => { export const oauthProviderToken = <ThrowOnError extends boolean = false>(options: Options<OauthProviderTokenData, ThrowOnError>) => {
return (options.client ?? client).post<OauthProviderTokenResponses, OauthProviderTokenErrors, ThrowOnError>({ return (options.client ?? client).post<OauthProviderTokenResponses, OauthProviderTokenErrors, ThrowOnError>({
@@ -414,13 +435,12 @@ export const oauthProviderToken = <ThrowOnError extends boolean = false>(options
}; };
/** /**
* Token Revocation Endpoint (Skeleton) * Token Revocation Endpoint
* *
* OAuth 2.0 Token Revocation Endpoint (RFC 7009). * OAuth 2.0 Token Revocation Endpoint (RFC 7009).
* *
* **NOTE**: This is a skeleton implementation. * Revokes an access token or refresh token.
* * Always returns 200 OK (even if token is invalid) per spec.
* Currently returns a 501 Not Implemented response.
*/ */
export const oauthProviderRevoke = <ThrowOnError extends boolean = false>(options: Options<OauthProviderRevokeData, ThrowOnError>) => { export const oauthProviderRevoke = <ThrowOnError extends boolean = false>(options: Options<OauthProviderRevokeData, ThrowOnError>) => {
return (options.client ?? client).post<OauthProviderRevokeResponses, OauthProviderRevokeErrors, ThrowOnError>({ return (options.client ?? client).post<OauthProviderRevokeResponses, OauthProviderRevokeErrors, ThrowOnError>({
@@ -436,19 +456,65 @@ export const oauthProviderRevoke = <ThrowOnError extends boolean = false>(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 = <ThrowOnError extends boolean = false>(options: Options<OauthProviderIntrospectData, ThrowOnError>) => {
return (options.client ?? client).post<OauthProviderIntrospectResponses, OauthProviderIntrospectErrors, ThrowOnError>({
...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 = <ThrowOnError extends boolean = false>(options?: Options<ListOauthClientsData, ThrowOnError>) => {
return (options?.client ?? client).get<ListOauthClientsResponses, unknown, ThrowOnError>({
responseType: 'json',
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/api/v1/oauth/provider/clients',
...options
});
};
/**
* Register OAuth Client
* *
* Register a new OAuth client (admin only). * Register a new OAuth client (admin only).
* *
* This endpoint allows creating MCP clients that can authenticate * Creates an MCP client that can authenticate against this API.
* 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 = <ThrowOnError extends boolean = false>(options: Options<RegisterOauthClientData, ThrowOnError>) => { export const registerOauthClient = <ThrowOnError extends boolean = false>(options: Options<RegisterOauthClientData, ThrowOnError>) => {
return (options.client ?? client).post<RegisterOauthClientResponses, RegisterOauthClientErrors, ThrowOnError>({ return (options.client ?? client).post<RegisterOauthClientResponses, RegisterOauthClientErrors, ThrowOnError>({
...urlSearchParamsBodySerializer, ...urlSearchParamsBodySerializer,
responseType: 'json', responseType: 'json',
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/api/v1/oauth/provider/clients', url: '/api/v1/oauth/provider/clients',
...options, ...options,
headers: { headers: {
@@ -458,6 +524,61 @@ export const registerOauthClient = <ThrowOnError extends boolean = false>(option
}); });
}; };
/**
* Delete OAuth Client
*
* Delete an OAuth client (admin only). Revokes all tokens.
*/
export const deleteOauthClient = <ThrowOnError extends boolean = false>(options: Options<DeleteOauthClientData, ThrowOnError>) => {
return (options.client ?? client).delete<DeleteOauthClientResponses, DeleteOauthClientErrors, ThrowOnError>({
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 = <ThrowOnError extends boolean = false>(options?: Options<ListMyOauthConsentsData, ThrowOnError>) => {
return (options?.client ?? client).get<ListMyOauthConsentsResponses, unknown, ThrowOnError>({
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 = <ThrowOnError extends boolean = false>(options: Options<RevokeMyOauthConsentData, ThrowOnError>) => {
return (options.client ?? client).delete<RevokeMyOauthConsentResponses, RevokeMyOauthConsentErrors, ThrowOnError>({
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/api/v1/oauth/provider/consents/{client_id}',
...options
});
};
/** /**
* List Users * List Users
* *
@@ -1166,3 +1287,21 @@ export const getOrganizationMembers = <ThrowOnError extends boolean = false>(opt
...options ...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 = <ThrowOnError extends boolean = false>(options?: Options<GetOauthServerMetadataData, ThrowOnError>) => {
return (options?.client ?? client).get<GetOauthServerMetadataResponses, unknown, ThrowOnError>({
responseType: 'json',
url: '/.well-known/oauth-authorization-server',
...options
});
};

View File

@@ -145,6 +145,84 @@ export type BodyLoginOauth = {
client_secret?: string | null; 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 * Body_oauth_provider_revoke
*/ */
@@ -182,7 +260,7 @@ export type BodyOauthProviderToken = {
/** /**
* Grant Type * Grant Type
* *
* Grant type (authorization_code) * Grant type
*/ */
grant_type: string; grant_type: string;
/** /**
@@ -221,6 +299,12 @@ export type BodyOauthProviderToken = {
* Refresh token * Refresh token
*/ */
refresh_token?: string | null; refresh_token?: string | null;
/**
* Scope
*
* Scope (for refresh)
*/
scope?: string | null;
}; };
/** /**
@@ -236,7 +320,7 @@ export type BodyRegisterOauthClient = {
/** /**
* Redirect Uris * Redirect Uris
* *
* Comma-separated list of redirect URIs * Comma-separated redirect URIs
*/ */
redirect_uris: string; redirect_uris: string;
/** /**
@@ -245,6 +329,18 @@ export type BodyRegisterOauthClient = {
* public or confidential * public or confidential
*/ */
client_type?: string; 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; 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<string>;
/**
* Allowed Scopes
*
* Allowed OAuth scopes
*/
allowed_scopes?: Array<string>;
/**
* 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 * OAuthProviderInfo
* *
@@ -536,6 +686,12 @@ export type OAuthServerMetadata = {
* Token revocation endpoint * Token revocation endpoint
*/ */
revocation_endpoint?: string | null; revocation_endpoint?: string | null;
/**
* Introspection Endpoint
*
* Token introspection endpoint (RFC 7662)
*/
introspection_endpoint?: string | null;
/** /**
* Scopes Supported * Scopes Supported
* *
@@ -560,6 +716,124 @@ export type OAuthServerMetadata = {
* Supported PKCE methods * Supported PKCE methods
*/ */
code_challenge_methods_supported?: Array<string>; code_challenge_methods_supported?: Array<string>;
/**
* Token Endpoint Auth Methods Supported
*
* Supported client authentication methods
*/
token_endpoint_auth_methods_supported?: Array<string>;
};
/**
* 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 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 = { export type OauthProviderAuthorizeData = {
body?: never; body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string;
};
path?: never; path?: never;
query: { query: {
/** /**
@@ -1651,7 +1915,7 @@ export type OauthProviderAuthorizeData = {
/** /**
* Scope * Scope
* *
* Requested scopes * Requested scopes (space-separated)
*/ */
scope?: string; scope?: string;
/** /**
@@ -1672,6 +1936,12 @@ export type OauthProviderAuthorizeData = {
* PKCE method (S256) * PKCE method (S256)
*/ */
code_challenge_method?: string | null; code_challenge_method?: string | null;
/**
* Nonce
*
* OpenID Connect nonce
*/
nonce?: string | null;
}; };
url: '/api/v1/oauth/provider/authorize'; url: '/api/v1/oauth/provider/authorize';
}; };
@@ -1694,6 +1964,31 @@ export type OauthProviderAuthorizeResponses = {
200: unknown; 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 = { export type OauthProviderTokenData = {
body: BodyOauthProviderToken; body: BodyOauthProviderToken;
path?: never; path?: never;
@@ -1712,13 +2007,13 @@ export type OauthProviderTokenError = OauthProviderTokenErrors[keyof OauthProvid
export type OauthProviderTokenResponses = { export type OauthProviderTokenResponses = {
/** /**
* Response Oauth Provider Token
*
* Successful Response * Successful Response
*/ */
200: unknown; 200: OAuthTokenResponse;
}; };
export type OauthProviderTokenResponse = OauthProviderTokenResponses[keyof OauthProviderTokenResponses];
export type OauthProviderRevokeData = { export type OauthProviderRevokeData = {
body: BodyOauthProviderRevoke; body: BodyOauthProviderRevoke;
path?: never; path?: never;
@@ -1741,9 +2036,56 @@ export type OauthProviderRevokeResponses = {
* *
* Successful Response * 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<OAuthClientResponse>;
};
export type ListOauthClientsResponse = ListOauthClientsResponses[keyof ListOauthClientsResponses];
export type RegisterOauthClientData = { export type RegisterOauthClientData = {
body: BodyRegisterOauthClient; body: BodyRegisterOauthClient;
path?: never; path?: never;
@@ -1766,9 +2108,93 @@ export type RegisterOauthClientResponses = {
* *
* Successful Response * 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 = { export type ListUsersData = {
body?: never; body?: never;
path?: never; path?: never;
@@ -2759,3 +3185,19 @@ export type GetOrganizationMembersResponses = {
}; };
export type GetOrganizationMembersResponse = GetOrganizationMembersResponses[keyof 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];

View File

@@ -8,7 +8,7 @@
* *
* For custom handler behavior, use src/mocks/handlers/overrides.ts * 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'; import { http, HttpResponse, delay } from 'msw';