Add OAuth provider mode and MCP integration

- Introduced full OAuth 2.0 Authorization Server functionality for MCP clients.
- Updated documentation with details on endpoints, scopes, and consent management.
- Added a new frontend OAuth consent page for user authorization flows.
- Implemented database models for authorization codes, refresh tokens, and user consents.
- Created unit tests for service methods (PKCE verification, client validation, scope handling).
- Included comprehensive integration tests for OAuth provider workflows.
This commit is contained in:
Felipe Cardoso
2025-11-25 23:18:19 +01:00
parent fbb030da69
commit 48f052200f
12 changed files with 3335 additions and 142 deletions

View File

@@ -8,9 +8,13 @@ from app.core.database import Base
from .base import TimestampMixin, UUIDMixin
# OAuth models
# OAuth models (client mode - authenticate via Google/GitHub)
from .oauth_account import OAuthAccount
# OAuth provider models (server mode - act as authorization server for MCP)
from .oauth_authorization_code import OAuthAuthorizationCode
from .oauth_client import OAuthClient
from .oauth_provider_token import OAuthConsent, OAuthProviderRefreshToken
from .oauth_state import OAuthState
from .organization import Organization
@@ -22,7 +26,10 @@ from .user_session import UserSession
__all__ = [
"Base",
"OAuthAccount",
"OAuthAuthorizationCode",
"OAuthClient",
"OAuthConsent",
"OAuthProviderRefreshToken",
"OAuthState",
"Organization",
"OrganizationRole",

View File

@@ -0,0 +1,91 @@
"""OAuth authorization code model for OAuth provider mode."""
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from .base import Base, TimestampMixin, UUIDMixin
class OAuthAuthorizationCode(Base, UUIDMixin, TimestampMixin):
"""
OAuth 2.0 Authorization Code for the authorization code flow.
Authorization codes are:
- Single-use (marked as used after exchange)
- Short-lived (10 minutes default)
- Bound to specific client, user, redirect_uri
- Support PKCE (code_challenge/code_challenge_method)
Security considerations:
- Code must be cryptographically random (64 chars, URL-safe)
- Must validate redirect_uri matches exactly
- Must verify PKCE code_verifier for public clients
- Must be consumed within expiration time
"""
__tablename__ = "oauth_authorization_codes"
# The authorization code (cryptographically random, URL-safe)
code = Column(String(128), unique=True, nullable=False, index=True)
# Client that requested the code
client_id = Column(
String(64),
ForeignKey("oauth_clients.client_id", ondelete="CASCADE"),
nullable=False,
)
# User who authorized the request
user_id = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
# Redirect URI (must match exactly on token exchange)
redirect_uri = Column(String(2048), nullable=False)
# Granted scopes (space-separated)
scope = Column(String(1000), nullable=False, default="")
# PKCE support (required for public clients)
code_challenge = Column(String(128), nullable=True)
code_challenge_method = Column(String(10), nullable=True) # "S256" or "plain"
# State parameter (for CSRF protection, returned to client)
state = Column(String(256), nullable=True)
# Nonce (for OpenID Connect, included in ID token)
nonce = Column(String(256), nullable=True)
# Expiration (codes are short-lived, typically 10 minutes)
expires_at = Column(DateTime(timezone=True), nullable=False)
# Single-use flag (set to True after successful exchange)
used = Column(Boolean, default=False, nullable=False)
# Relationships
client = relationship("OAuthClient", backref="authorization_codes")
user = relationship("User", backref="oauth_authorization_codes")
# Indexes for efficient cleanup queries
__table_args__ = (
Index("ix_oauth_authorization_codes_expires_at", "expires_at"),
Index("ix_oauth_authorization_codes_client_user", "client_id", "user_id"),
)
def __repr__(self):
return f"<OAuthAuthorizationCode {self.code[:8]}... for {self.client_id}>"
@property
def is_expired(self) -> bool:
"""Check if the authorization code has expired."""
return datetime.utcnow() > self.expires_at.replace(tzinfo=None)
@property
def is_valid(self) -> bool:
"""Check if the authorization code is valid (not used, not expired)."""
return not self.used and not self.is_expired

View File

@@ -0,0 +1,153 @@
"""OAuth provider token models for OAuth provider mode."""
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from .base import Base, TimestampMixin, UUIDMixin
class OAuthProviderRefreshToken(Base, UUIDMixin, TimestampMixin):
"""
OAuth 2.0 Refresh Token for the OAuth provider.
Refresh tokens are:
- Opaque (stored as hash in DB, actual token given to client)
- Long-lived (configurable, default 30 days)
- Revocable (via revoked flag or deletion)
- Bound to specific client, user, and scope
Access tokens are JWTs and not stored in DB (self-contained).
This model only tracks refresh tokens for revocation support.
Security considerations:
- Store token hash, not plaintext
- Support token rotation (new refresh token on use)
- Track last used time for security auditing
- Support revocation by user, client, or admin
"""
__tablename__ = "oauth_provider_refresh_tokens"
# Hash of the refresh token (SHA-256)
# We store hash, not plaintext, for security
token_hash = Column(String(64), unique=True, nullable=False, index=True)
# Unique token ID (JTI) - used in JWT access tokens to reference this refresh token
jti = Column(String(64), unique=True, nullable=False, index=True)
# Client that owns this token
client_id = Column(
String(64),
ForeignKey("oauth_clients.client_id", ondelete="CASCADE"),
nullable=False,
)
# User who authorized this token
user_id = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
# Granted scopes (space-separated)
scope = Column(String(1000), nullable=False, default="")
# Token expiration
expires_at = Column(DateTime(timezone=True), nullable=False)
# Revocation flag
revoked = Column(Boolean, default=False, nullable=False, index=True)
# Last used timestamp (for security auditing)
last_used_at = Column(DateTime(timezone=True), nullable=True)
# Device/session info (optional, for user visibility)
device_info = Column(String(500), nullable=True)
ip_address = Column(String(45), nullable=True)
# Relationships
client = relationship("OAuthClient", backref="refresh_tokens")
user = relationship("User", backref="oauth_provider_refresh_tokens")
# Indexes
__table_args__ = (
Index("ix_oauth_provider_refresh_tokens_expires_at", "expires_at"),
Index("ix_oauth_provider_refresh_tokens_client_user", "client_id", "user_id"),
Index(
"ix_oauth_provider_refresh_tokens_user_revoked",
"user_id",
"revoked",
),
)
def __repr__(self):
status = "revoked" if self.revoked else "active"
return f"<OAuthProviderRefreshToken {self.jti[:8]}... ({status})>"
@property
def is_expired(self) -> bool:
"""Check if the refresh token has expired."""
return datetime.utcnow() > self.expires_at.replace(tzinfo=None)
@property
def is_valid(self) -> bool:
"""Check if the refresh token is valid (not revoked, not expired)."""
return not self.revoked and not self.is_expired
class OAuthConsent(Base, UUIDMixin, TimestampMixin):
"""
OAuth consent record - remembers user consent for a client.
When a user grants consent to an OAuth client, we store the record
so they don't have to re-consent on subsequent authorizations
(unless scopes change).
This enables a better UX - users only see consent screen once per client,
unless the client requests additional scopes.
"""
__tablename__ = "oauth_consents"
# User who granted consent
user_id = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
# Client that received consent
client_id = Column(
String(64),
ForeignKey("oauth_clients.client_id", ondelete="CASCADE"),
nullable=False,
)
# Granted scopes (space-separated)
granted_scopes = Column(String(1000), nullable=False, default="")
# Relationships
client = relationship("OAuthClient", backref="consents")
user = relationship("User", backref="oauth_consents")
# Unique constraint: one consent record per user+client
__table_args__ = (
Index(
"ix_oauth_consents_user_client",
"user_id",
"client_id",
unique=True,
),
)
def __repr__(self):
return f"<OAuthConsent user={self.user_id} client={self.client_id}>"
def has_scopes(self, requested_scopes: list[str]) -> bool:
"""Check if all requested scopes are already granted."""
granted = set(self.granted_scopes.split()) if self.granted_scopes else set()
requested = set(requested_scopes)
return requested.issubset(granted)