can link google accounts!

This commit is contained in:
2026-01-20 15:54:00 +10:30
parent 900722d695
commit 4cf4a27c9a
17 changed files with 5325 additions and 4 deletions
+1
View File
@@ -10,6 +10,7 @@ SQLALCHEMY_LOG_LEVEL=WARNING
# Security # Security
BCRYPT_LOG_ROUNDS=12 BCRYPT_LOG_ROUNDS=12
ENCRYPTION_KEY=your-encryption-key-here-change-in-production
SESSION_COOKIE_SECURE=False SESSION_COOKIE_SECURE=False
SESSION_COOKIE_HTTPONLY=True SESSION_COOKIE_HTTPONLY=True
SESSION_COOKIE_SAMESITE=Lax SESSION_COOKIE_SAMESITE=Lax
+3
View File
@@ -26,6 +26,9 @@ class BaseConfig:
# Security # Security
BCRYPT_LOG_ROUNDS = int(os.getenv("BCRYPT_LOG_ROUNDS", "12")) BCRYPT_LOG_ROUNDS = int(os.getenv("BCRYPT_LOG_ROUNDS", "12"))
# Encryption key for sensitive data (client secrets, tokens, etc.)
ENCRYPTION_KEY = os.getenv("ENCRYPTION_KEY", "dev-encryption-key-change-in-production")
# Session configuration for WebAuthn cross-origin support # Session configuration for WebAuthn cross-origin support
SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "True").lower() == "true" SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "True").lower() == "true"
SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_HTTPONLY = True
+747
View File
@@ -0,0 +1,747 @@
# External Authentication API Documentation
## Overview
The Gatehouse External Authentication API provides endpoints for integrating external OAuth providers (Google, GitHub, Microsoft) for user authentication and account linking. This API supports three main workflows:
1. **Provider Configuration** - Organization admins can configure OAuth credentials
2. **Account Linking** - Users can link external accounts to their Gatehouse account
3. **OAuth Authentication** - Users can authenticate using their external provider credentials
## Base URL
```
https://api.gatehouse.dev/v1
```
## Authentication
All endpoints require Bearer token authentication except for OAuth callback endpoints.
```
Authorization: Bearer <your-jwt-token>
```
## Error Codes
| Code | Type | Description |
|------|------|-------------|
| 400 | BAD_REQUEST | Invalid request parameters |
| 401 | UNAUTHORIZED | Authentication required |
| 403 | FORBIDDEN | Insufficient permissions |
| 404 | NOT_FOUND | Resource not found |
| 500 | INTERNAL_ERROR | Server error |
### External Auth Specific Error Types
| Error Type | HTTP Code | Description |
|------------|-----------|-------------|
| PROVIDER_NOT_CONFIGURED | 400 | Provider not configured for organization |
| INVALID_REDIRECT_URI | 400 | Redirect URI not allowed |
| INVALID_STATE | 400 | Invalid or expired OAuth state |
| INVALID_FLOW_TYPE | 400 | Invalid flow type |
| PROVIDER_MISMATCH | 400 | Provider mismatch in flow |
| PROVIDER_NOT_LINKED | 400 | Provider not linked to account |
| CANNOT_UNLINK_LAST | 400 | Cannot unlink last authentication method |
| ACCOUNT_NOT_FOUND | 400 | No matching Gatehouse account |
| EMAIL_EXISTS | 400 | Email already exists |
| UNSUPPORTED_PROVIDER | 400 | Provider not supported |
---
## Endpoints
### Provider Configuration
#### List Available Providers
**GET** `/api/v1/auth/external/providers`
List all available external authentication providers for the current organization.
**Authentication:** Required (any authenticated user)
**Response (200):**
```json
{
"version": "1.0",
"success": true,
"code": 200,
"data": {
"providers": [
{
"id": "google",
"name": "Google",
"type": "google",
"is_configured": true,
"is_active": true,
"settings": {
"requires_domain": false,
"supports_refresh_tokens": true
}
},
{
"id": "github",
"name": "GitHub",
"type": "github",
"is_configured": false,
"is_active": false,
"settings": {
"requires_domain": false,
"supports_refresh_tokens": true
}
}
]
}
}
```
---
#### Get Provider Configuration
**GET** `/api/v1/auth/external/providers/{provider}/config`
Get provider configuration (admin only).
**Authentication:** Required (Organization Admin or Owner)
**Path Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| provider | string | Provider type (google, github, microsoft) |
**Response (200):**
```json
{
"version": "1.0",
"success": true,
"code": 200,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"organization_id": "550e8400-e29b-41d4-a716-446655440001",
"provider_type": "google",
"client_id": "client-id.apps.googleusercontent.com",
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"userinfo_url": "https://www.googleapis.com/oauth2/v3/userinfo",
"scopes": ["openid", "profile", "email"],
"redirect_uris": [
"https://app.gatehouse.dev/auth/external/google/callback"
],
"is_active": true,
"settings": {
"hosted_domain": "example.com"
},
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-20T14:45:00Z"
}
}
```
**Error Response (403):**
```json
{
"version": "1.0",
"success": false,
"code": 403,
"message": "Admin access required",
"error_type": "FORBIDDEN"
}
```
**Error Response (404):**
```json
{
"version": "1.0",
"success": false,
"code": 404,
"message": "Google OAuth is not configured",
"error_type": "NOT_FOUND"
}
```
---
#### Create/Update Provider Configuration
**POST** `/api/v1/auth/external/providers/{provider}/config`
Create or update provider configuration (admin only).
**Authentication:** Required (Organization Admin or Owner)
**Path Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| provider | string | Provider type (google, github, microsoft) |
**Request Body:**
```json
{
"client_id": "client-id.apps.googleusercontent.com",
"client_secret": "client-secret",
"scopes": ["openid", "profile", "email"],
"redirect_uris": [
"https://app.gatehouse.dev/auth/external/google/callback",
"http://localhost:3000/callback"
],
"settings": {
"hosted_domain": "example.com",
"access_type": "offline",
"prompt": "consent"
},
"is_active": true
}
```
**Response (201 - Created):**
```json
{
"version": "1.0",
"success": true,
"code": 201,
"message": "Provider configuration created successfully",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"provider_type": "google",
"client_id": "client-id.apps.googleusercontent.com",
"is_active": true
}
}
```
**Response (200 - Updated):**
```json
{
"version": "1.0",
"success": true,
"code": 200,
"message": "Provider configuration updated successfully",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"provider_type": "google",
"client_id": "updated-client-id",
"is_active": true
}
}
```
---
#### Delete Provider Configuration
**DELETE** `/api/v1/auth/external/providers/{provider}/config`
Delete provider configuration (admin only).
**Authentication:** Required (Organization Admin or Owner)
**Path Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| provider | string | Provider type (google, github, microsoft) |
**Response (200):**
```json
{
"version": "1.0",
"success": true,
"code": 200,
"message": "Google provider configuration deleted successfully"
}
```
---
### Account Linking
#### List Linked Accounts
**GET** `/api/v1/auth/external/linked-accounts`
List all linked external accounts for the current user.
**Authentication:** Required (any authenticated user)
**Response (200):**
```json
{
"version": "1.0",
"success": true,
"code": 200,
"data": {
"linked_accounts": [
{
"id": "550e8400-e29b-41d4-a716-446655440002",
"provider_type": "google",
"provider_user_id": "123456789",
"email": "user@gmail.com",
"name": "John Doe",
"picture": "https://lh3.googleusercontent.com/...",
"verified": true,
"linked_at": "2024-01-15T10:30:00Z",
"last_used_at": "2024-01-20T14:45:00Z"
},
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"provider_type": "github",
"provider_user_id": "987654321",
"email": "user@github.com",
"name": "johndoe",
"picture": "https://avatars.githubusercontent.com/...",
"verified": true,
"linked_at": "2024-01-10T08:00:00Z",
"last_used_at": null
}
],
"unlink_available": true
}
}
```
---
#### Initiate Account Linking
**POST** `/api/v1/auth/external/{provider}/link`
Initiate OAuth flow to link an external account.
**Authentication:** Required (any authenticated user)
**Path Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| provider | string | Provider type (google, github, microsoft) |
**Request Body:**
```json
{
"redirect_uri": "https://app.gatehouse.dev/settings/security"
}
```
**Response (200):**
```json
{
"version": "1.0",
"success": true,
"code": 200,
"data": {
"authorization_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
"state": "eyJmbG93X3R5cGUiOiJsaW5rIiwicHJvdmlkZXIiOiJnb29nbGUifQ..."
},
"message": "Link flow initiated. Redirect to authorization URL."
}
```
**Error Response (400 - Provider not configured):**
```json
{
"version": "1.0",
"success": false,
"code": 400,
"message": "Google OAuth is not configured for this organization",
"error_type": "PROVIDER_NOT_CONFIGURED"
}
```
---
#### Unlink Account
**DELETE** `/api/v1/auth/external/{provider}/unlink`
Unlink an external account from the user's profile.
**Authentication:** Required (any authenticated user)
**Path Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| provider | string | Provider type (google, github, microsoft) |
**Response (200):**
```json
{
"version": "1.0",
"success": true,
"code": 200,
"message": "Google account unlinked successfully"
}
```
**Error Response (400 - Last auth method):**
```json
{
"version": "1.0",
"success": false,
"code": 400,
"message": "Cannot unlink the last authentication method",
"error_type": "CANNOT_UNLINK_LAST"
}
```
**Error Response (404 - Not linked):**
```json
{
"version": "1.0",
"success": false,
"code": 400,
"message": "Provider not linked",
"error_type": "PROVIDER_NOT_LINKED"
}
```
---
### OAuth Flow
#### Initiate OAuth Authorization
**GET** `/api/v1/auth/external/{provider}/authorize`
Initiate OAuth authentication or account registration flow.
**Authentication:** Not required (for login/register flows)
**Path Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| provider | string | Provider type (google, github, microsoft) |
**Query Parameters:**
| Parameter | Required | Description |
|-----------|----------|-------------|
| flow | Yes | Flow type: `login` or `register` |
| redirect_uri | No | Override redirect URI (must be in allowed list) |
| organization_id | No | Organization context for SSO |
**Response (200):**
```json
{
"version": "1.0",
"success": true,
"code": 200,
"data": {
"authorization_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
"state": "eyJmbG93X3R5cGUiOiJsb2dpbiIsInByb3ZpZGVyIjoiZ29vZ2xlIn0..."
},
"message": "OAuth login flow initiated"
}
```
**Error Response (400 - Invalid flow):**
```json
{
"version": "1.0",
"success": false,
"code": 400,
"message": "Invalid flow type. Must be 'login' or 'register'",
"error_type": "VALIDATION_ERROR"
}
```
---
#### Handle OAuth Callback
**GET** `/api/v1/auth/external/{provider}/callback`
Handle OAuth callback from provider.
**Authentication:** Not required
**Path Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| provider | string | Provider type (google, github, microsoft) |
**Query Parameters:**
| Parameter | Description |
|-----------|-------------|
| code | Authorization code from provider |
| state | State parameter (contains flow context) |
| error | Error code if auth failed |
| error_description | Human-readable error description |
**Success Response (200 - Login Flow):**
```json
{
"version": "1.0",
"success": true,
"code": 200,
"data": {
"token": "gatehouse-jwt-token...",
"expires_in": 86400,
"token_type": "Bearer",
"user": {
"id": "550e8400-e29b-41d4-a716-446655440004",
"email": "user@example.com",
"full_name": "John Doe",
"organization_id": "550e8400-e29b-41d4-a716-446655440001"
}
},
"message": "Login successful"
}
```
**Success Response (200 - Register Flow):**
```json
{
"version": "1.0",
"success": true,
"code": 200,
"data": {
"token": "gatehouse-jwt-token...",
"expires_in": 86400,
"token_type": "Bearer",
"user": {
"id": "550e8400-e29b-41d4-a716-446655440005",
"email": "newuser@gmail.com",
"full_name": "New User",
"organization_id": "550e8400-e29b-41d4-a716-446655440001"
}
},
"message": "Registration successful"
}
```
**Success Response (200 - Link Flow):**
```json
{
"version": "1.0",
"success": true,
"code": 200,
"data": {
"linked_account": {
"id": "550e8400-e29b-41d4-a716-446655440006",
"provider_type": "google",
"provider_user_id": "123456789",
"verified": true
}
},
"message": "Account linked successfully"
}
```
**Error Response (400 - Invalid state):**
```json
{
"version": "1.0",
"success": false,
"code": 400,
"message": "Invalid or expired OAuth state",
"error_type": "INVALID_STATE"
}
```
**Error Response (400 - Account not found):**
```json
{
"version": "1.0",
"success": false,
"code": 400,
"message": "No Gatehouse account matches this external account. Please register first.",
"error_type": "ACCOUNT_NOT_FOUND"
}
```
**Error Response (400 - Email exists):**
```json
{
"version": "1.0",
"success": false,
"code": 400,
"message": "An account with email user@gmail.com already exists. Please log in with your password and link your Google account from settings.",
"error_type": "EMAIL_EXISTS"
}
```
**Error Response (400 - Provider error):**
```json
{
"version": "1.0",
"success": false,
"code": 400,
"message": "User denied access",
"error_type": "ACCESS_DENIED"
}
```
---
## OAuth Flow Documentation
### Account Linking Flow
```
1. User clicks "Connect Google" in settings
2. Frontend calls POST /api/v1/auth/external/google/link
3. API returns authorization_url and state
4. Frontend redirects user to authorization_url
5. User authenticates with Google and grants permission
6. Google redirects to /api/v1/auth/external/google/callback?code=xxx&state=yyy
7. API validates state, exchanges code for tokens, links account
8. API returns success response
9. Frontend shows confirmation
```
### Login Flow
```
1. User clicks "Login with Google" on login page
2. Frontend redirects to /api/v1/auth/external/google/authorize?flow=login
3. API creates state, returns authorization_url
4. User authenticates with Google
5. Google redirects to callback with code and state
6. API validates state, exchanges code, authenticates user
7. API returns JWT token and user info
8. Frontend stores token and redirects to dashboard
```
### Registration Flow
```
1. User clicks "Sign up with Google" on registration page
2. Frontend redirects to /api/v1/auth/external/google/authorize?flow=register
3. API creates state, returns authorization_url
4. User authenticates with Google
5. Google redirects to callback with code and state
6. API validates state, exchanges code, creates new user
7. API returns JWT token and user info
8. Frontend stores token and redirects to onboarding
```
---
## Security Considerations
### State Parameter
The OAuth state parameter provides CSRF protection and carries flow context:
- Cryptographically random (256-bit)
- Short-lived (10 minutes)
- Single-use (marked as used after callback)
- Bound to user (for link flows)
- Bound to redirect_uri
### PKCE Implementation
PKCE protects against authorization code interception attacks:
- Code verifier: 43-128 character random string
- Code challenge: S256 hash of verifier
- Verifier sent in token request
- Server validates challenge matches
### Token Storage
Provider tokens are encrypted at rest:
- Access tokens: Short-lived, minimal protection needed
- Refresh tokens: Encrypted using Fernet symmetric encryption
- ID tokens: Encrypted at rest
- Encryption keys stored separately from database
---
## Provider-Specific Configuration
### Google OAuth
**Endpoints:**
- Auth URL: `https://accounts.google.com/o/oauth2/v2/auth`
- Token URL: `https://oauth2.googleapis.com/token`
- UserInfo URL: `https://www.googleapis.com/oauth2/v3/userinfo`
**Default Scopes:**
- `openid` - OpenID Connect authentication
- `profile` - Basic profile information
- `email` - Email address
**Settings:**
- `hosted_domain` - Restrict to specific domain (optional)
- `access_type` - Request refresh token (`offline`)
- `prompt` - Force consent (`consent`)
### GitHub OAuth
**Endpoints:**
- Auth URL: `https://github.com/login/oauth/authorize`
- Token URL: `https://github.com/login/oauth/access_token`
- UserInfo URL: `https://api.github.com/user`
**Default Scopes:**
- `read:user` - Read user profile data
- `user:email` - Access user email addresses
### Microsoft OAuth
**Endpoints:**
- Auth URL: `https://login.microsoftonline.com/common/oauth2/v2.0/authorize`
- Token URL: `https://login.microsoftonline.com/common/oauth2/v2.0/token`
- UserInfo URL: `https://graph.microsoft.com/oidc/userinfo`
**Default Scopes:**
- `openid` - OpenID Connect
- `profile` - User profile
- `email` - Email address
- `User.Read` - Microsoft Graph access
---
## Rate Limiting
| Endpoint | Limit | Window |
|----------|-------|--------|
| `/authorize` | 10 | per minute |
| `/callback` | 20 | per minute |
| `/link` | 5 | per minute |
| `/unlink` | 10 | per minute |
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2024-01-20 | Initial release |
---
*Document Version: 1.0*
*Last Updated: 2024-01-20*
*Gatehouse Identity Platform*
+1 -1
View File
@@ -5,4 +5,4 @@ from flask import Blueprint
api_v1_bp = Blueprint("api_v1", __name__) api_v1_bp = Blueprint("api_v1", __name__)
# Import route modules to register them # Import route modules to register them
from gatehouse_app.api.v1 import auth, users, organizations, policies from gatehouse_app.api.v1 import auth, users, organizations, policies, external_auth
+706
View File
@@ -0,0 +1,706 @@
"""External authentication provider endpoints."""
from flask import request, g
from marshmallow import ValidationError
from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.utils.response import api_response
from gatehouse_app.utils.decorators import login_required
from gatehouse_app.utils.constants import AuthMethodType
from gatehouse_app.services.external_auth_service import (
ExternalAuthService,
ExternalAuthError,
)
from gatehouse_app.services.oauth_flow_service import (
OAuthFlowService,
OAuthFlowError,
)
from gatehouse_app.services.audit_service import AuditService
# Provider type mapping
PROVIDER_TYPE_MAP = {
"google": AuthMethodType.GOOGLE,
"github": AuthMethodType.GITHUB,
"microsoft": AuthMethodType.MICROSOFT,
}
def get_provider_type(provider: str) -> AuthMethodType:
"""Get AuthMethodType from provider string."""
provider_lower = provider.lower()
if provider_lower not in PROVIDER_TYPE_MAP:
raise ExternalAuthError(
f"Unsupported provider: {provider}",
"UNSUPPORTED_PROVIDER",
400,
)
return PROVIDER_TYPE_MAP[provider_lower]
# =============================================================================
# Provider Configuration Endpoints (Admin)
# =============================================================================
@api_v1_bp.route("/auth/external/providers", methods=["GET"])
@login_required
def list_providers():
"""
List available external authentication providers for current organization.
Returns:
200: List of providers with their configuration status
401: Not authenticated
"""
from gatehouse_app.models import Organization
from gatehouse_app.services.external_auth_service import ExternalProviderConfig
# Get user's primary organization
user_orgs = g.current_user.get_organizations()
if not user_orgs:
return api_response(
success=False,
message="No organizations found for user",
status=400,
error_type="BAD_REQUEST",
)
organization_id = user_orgs[0].id
# Get all configured providers for organization
configs = ExternalProviderConfig.query.filter_by(
organization_id=organization_id,
).all()
configured_providers = {c.provider_type.lower(): c for c in configs}
# Provider definitions
providers = [
{
"id": "google",
"name": "Google",
"type": "google",
"is_configured": "google" in configured_providers,
"is_active": configured_providers.get("google", {}).is_active if "google" in configured_providers else False,
"settings": {
"requires_domain": False,
"supports_refresh_tokens": True,
},
},
{
"id": "github",
"name": "GitHub",
"type": "github",
"is_configured": "github" in configured_providers,
"is_active": configured_providers.get("github", {}).is_active if "github" in configured_providers else False,
"settings": {
"requires_domain": False,
"supports_refresh_tokens": True,
},
},
{
"id": "microsoft",
"name": "Microsoft",
"type": "microsoft",
"is_configured": "microsoft" in configured_providers,
"is_active": configured_providers.get("microsoft", {}).is_active if "microsoft" in configured_providers else False,
"settings": {
"requires_domain": False,
"supports_refresh_tokens": True,
},
},
]
return api_response(
data={"providers": providers},
message="Providers retrieved successfully",
)
@api_v1_bp.route("/auth/external/providers/<provider>/config", methods=["GET"])
@login_required
def get_provider_config(provider: str):
"""
Get provider configuration (admin only).
Args:
provider: Provider type (google, github, microsoft)
Returns:
200: Provider configuration
401: Not authenticated
403: Not authorized (not admin)
404: Provider not configured
"""
from gatehouse_app.models import OrganizationMember
from gatehouse_app.utils.constants import OrganizationRole
from gatehouse_app.services.external_auth_service import ExternalProviderConfig
provider_type = get_provider_type(provider)
# Get user's primary organization
user_orgs = g.current_user.get_organizations()
if not user_orgs:
return api_response(
success=False,
message="No organizations found for user",
status=400,
error_type="BAD_REQUEST",
)
organization_id = user_orgs[0].id
# Check if user is admin
member = OrganizationMember.query.filter_by(
user_id=g.current_user.id,
organization_id=organization_id,
).first()
if not member or member.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]:
return api_response(
success=False,
message="Admin access required",
status=403,
error_type="FORBIDDEN",
)
# Get provider config
config = ExternalProviderConfig.query.filter_by(
organization_id=organization_id,
provider_type=provider_type.value,
).first()
if not config:
return api_response(
success=False,
message=f"{provider.title()} OAuth is not configured",
status=404,
error_type="NOT_FOUND",
)
return api_response(
data=config.to_dict(include_secrets=False),
message="Provider configuration retrieved successfully",
)
@api_v1_bp.route("/auth/external/providers/<provider>/config", methods=["POST"])
@login_required
def create_or_update_provider_config(provider: str):
"""
Create or update provider configuration (admin only).
Args:
provider: Provider type (google, github, microsoft)
Request body:
client_id: OAuth client ID
client_secret: OAuth client secret
scopes: List of OAuth scopes
redirect_uris: List of allowed redirect URIs
settings: Provider-specific settings
is_active: Whether the provider is active
Returns:
200: Provider configuration updated
201: Provider configuration created
400: Validation error
401: Not authenticated
403: Not authorized (not admin)
"""
from gatehouse_app.models import OrganizationMember
from gatehouse_app.utils.constants import OrganizationRole
from gatehouse_app.services.external_auth_service import ExternalProviderConfig
provider_type = get_provider_type(provider)
# Get user's primary organization
user_orgs = g.current_user.get_organizations()
if not user_orgs:
return api_response(
success=False,
message="No organizations found for user",
status=400,
error_type="BAD_REQUEST",
)
organization_id = user_orgs[0].id
# Check if user is admin
member = OrganizationMember.query.filter_by(
user_id=g.current_user.id,
organization_id=organization_id,
).first()
if not member or member.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]:
return api_response(
success=False,
message="Admin access required",
status=403,
error_type="FORBIDDEN",
)
# Validate request data
data = request.json or {}
client_id = data.get("client_id")
client_secret = data.get("client_secret")
if not client_id:
return api_response(
success=False,
message="client_id is required",
status=400,
error_type="VALIDATION_ERROR",
)
# Get or create config
config = ExternalProviderConfig.query.filter_by(
organization_id=organization_id,
provider_type=provider_type.value,
).first()
is_new = config is None
if config:
# Update existing
config.client_id = client_id
if client_secret:
config.set_client_secret(client_secret)
config.scopes = data.get("scopes", ["openid", "profile", "email"])
config.redirect_uris = data.get("redirect_uris", [])
config.settings = data.get("settings", {})
config.is_active = data.get("is_active", True)
config.save()
# Audit log - config update
AuditService.log_external_auth_config_update(
user_id=g.current_user.id,
organization_id=organization_id,
provider_type=provider_type.value,
config_id=config.id,
changes={
"client_id": "updated",
"client_secret": "updated" if client_secret else None,
"scopes": data.get("scopes"),
"redirect_uris": data.get("redirect_uris"),
"is_active": config.is_active,
},
)
else:
# Create new - get provider endpoints
auth_url, token_url, userinfo_url = _get_provider_endpoints(provider_type)
config = ExternalProviderConfig(
organization_id=organization_id,
provider_type=provider_type.value,
client_id=client_id,
client_secret_encrypted=None,
auth_url=auth_url,
token_url=token_url,
userinfo_url=userinfo_url,
scopes=data.get("scopes", ["openid", "profile", "email"]),
redirect_uris=data.get("redirect_uris", []),
settings=data.get("settings", {}),
is_active=data.get("is_active", True),
)
if client_secret:
config.set_client_secret(client_secret)
config.save()
# Audit log - config create
AuditService.log_external_auth_config_create(
user_id=g.current_user.id,
organization_id=organization_id,
provider_type=provider_type.value,
config_id=config.id,
)
return api_response(
data=config.to_dict(include_secrets=False),
message="Provider configuration saved successfully",
status=201 if is_new else 200,
)
@api_v1_bp.route("/auth/external/providers/<provider>/config", methods=["DELETE"])
@login_required
def delete_provider_config(provider: str):
"""
Delete provider configuration (admin only).
Args:
provider: Provider type (google, github, microsoft)
Returns:
200: Provider configuration deleted
401: Not authenticated
403: Not authorized (not admin)
404: Provider not configured
"""
from gatehouse_app.models import OrganizationMember
from gatehouse_app.utils.constants import OrganizationRole
from gatehouse_app.services.external_auth_service import ExternalProviderConfig
provider_type = get_provider_type(provider)
# Get user's primary organization
user_orgs = g.current_user.get_organizations()
if not user_orgs:
return api_response(
success=False,
message="No organizations found for user",
status=400,
error_type="BAD_REQUEST",
)
organization_id = user_orgs[0].id
# Check if user is admin
member = OrganizationMember.query.filter_by(
user_id=g.current_user.id,
organization_id=organization_id,
).first()
if not member or member.role not in [OrganizationRole.OWNER, OrganizationRole.ADMIN]:
return api_response(
success=False,
message="Admin access required",
status=403,
error_type="FORBIDDEN",
)
# Get and delete config
config = ExternalProviderConfig.query.filter_by(
organization_id=organization_id,
provider_type=provider_type.value,
).first()
if not config:
return api_response(
success=False,
message=f"{provider.title()} OAuth is not configured",
status=404,
error_type="NOT_FOUND",
)
config_id = config.id
config.delete()
# Audit log - config delete
AuditService.log_external_auth_config_delete(
user_id=g.current_user.id,
organization_id=organization_id,
provider_type=provider_type.value,
config_id=config_id,
)
return api_response(
message=f"{provider.title()} provider configuration deleted successfully",
)
# =============================================================================
# Account Linking Endpoints
# =============================================================================
@api_v1_bp.route("/auth/external/linked-accounts", methods=["GET"])
@login_required
def list_linked_accounts():
"""
List all linked external accounts for the current user.
Returns:
200: List of linked accounts
401: Not authenticated
"""
linked_accounts = ExternalAuthService.get_linked_accounts(g.current_user.id)
# Check if user has other auth methods (for unlink availability)
from gatehouse_app.models import AuthenticationMethod
other_methods = AuthenticationMethod.query.filter_by(
user_id=g.current_user.id,
).count()
return api_response(
data={
"linked_accounts": linked_accounts,
"unlink_available": other_methods > 1,
},
message="Linked accounts retrieved successfully",
)
@api_v1_bp.route("/auth/external/<provider>/link", methods=["POST"])
@login_required
def initiate_link_account(provider: str):
"""
Initiate OAuth flow to link an external account.
Args:
provider: Provider type (google, github, microsoft)
Request body:
redirect_uri: Optional redirect URI after linking
Returns:
302: Redirect to provider authorization page
400: Validation error or provider not configured
401: Not authenticated
"""
provider_type = get_provider_type(provider)
# Get user's organization
user_orgs = g.current_user.get_organizations()
organization_id = user_orgs[0].id if user_orgs else None
# Get optional redirect URI
data = request.json or {}
redirect_uri = data.get("redirect_uri")
try:
# Initiate link flow
auth_url, state = ExternalAuthService.initiate_link_flow(
user_id=g.current_user.id,
provider_type=provider_type,
organization_id=organization_id,
redirect_uri=redirect_uri,
)
return api_response(
data={
"authorization_url": auth_url,
"state": state,
},
message="Link flow initiated. Redirect to authorization URL.",
)
except ExternalAuthError as e:
return api_response(
success=False,
message=e.message,
status=e.status_code,
error_type=e.error_type,
)
@api_v1_bp.route("/auth/external/<provider>/unlink", methods=["DELETE"])
@login_required
def unlink_account(provider: str):
"""
Unlink an external account from the user's profile.
Args:
provider: Provider type (google, github, microsoft)
Returns:
200: Account unlinked successfully
400: Validation error or cannot unlink last method
401: Not authenticated
404: Provider not linked
"""
provider_type = get_provider_type(provider)
# Get user's organization
user_orgs = g.current_user.get_organizations()
organization_id = user_orgs[0].id if user_orgs else None
try:
ExternalAuthService.unlink_provider(
user_id=g.current_user.id,
provider_type=provider_type,
organization_id=organization_id,
)
return api_response(
message=f"{provider.title()} account unlinked successfully",
)
except ExternalAuthError as e:
return api_response(
success=False,
message=e.message,
status=e.status_code,
error_type=e.error_type,
)
# =============================================================================
# OAuth Flow Endpoints
# =============================================================================
@api_v1_bp.route("/auth/external/<provider>/authorize", methods=["GET"])
def initiate_oauth_authorize(provider: str):
"""
Initiate OAuth authentication or account registration flow.
Args:
provider: Provider type (google, github, microsoft)
Query parameters:
flow: 'login' or 'register'
redirect_uri: Optional redirect URI
organization_id: Optional organization context
Returns:
302: Redirect to provider authorization page
400: Validation error or provider not configured
"""
provider_type = get_provider_type(provider)
# Get query parameters
flow = request.args.get("flow", "login")
redirect_uri = request.args.get("redirect_uri")
organization_id = request.args.get("organization_id")
if flow not in ["login", "register"]:
return api_response(
success=False,
message="Invalid flow type. Must be 'login' or 'register'",
status=400,
error_type="VALIDATION_ERROR",
)
try:
if flow == "login":
auth_url, state = OAuthFlowService.initiate_login_flow(
provider_type=provider_type,
organization_id=organization_id,
redirect_uri=redirect_uri,
)
else:
auth_url, state = OAuthFlowService.initiate_register_flow(
provider_type=provider_type,
organization_id=organization_id,
redirect_uri=redirect_uri,
)
return api_response(
data={
"authorization_url": auth_url,
"state": state,
},
message=f"OAuth {flow} flow initiated",
)
except OAuthFlowError as e:
return api_response(
success=False,
message=e.message,
status=e.status_code,
error_type=e.error_type,
)
@api_v1_bp.route("/auth/external/<provider>/callback", methods=["GET"])
def handle_oauth_callback(provider: str):
"""
Handle OAuth callback from provider.
Args:
provider: Provider type (google, github, microsoft)
Query parameters:
code: Authorization code from provider
state: State parameter
error: Error code if auth failed
error_description: Human-readable error description
Returns:
200: OAuth flow completed successfully
302: Redirect with error
400: Validation error or OAuth error
"""
provider_type = get_provider_type(provider)
# Get callback parameters
authorization_code = request.args.get("code")
state = request.args.get("state")
error = request.args.get("error")
error_description = request.args.get("error_description")
# Get redirect URI from state if available
redirect_uri = request.args.get("redirect_uri")
try:
result = OAuthFlowService.handle_callback(
provider_type=provider_type,
authorization_code=authorization_code,
state=state,
redirect_uri=redirect_uri,
error=error,
error_description=error_description,
)
if result.get("success"):
if result.get("flow_type") == "login":
return api_response(
data={
"token": result["session"]["token"],
"expires_in": result["session"].get("expires_in", 86400),
"token_type": "Bearer",
"user": result["user"],
},
message="Login successful",
)
elif result.get("flow_type") == "register":
return api_response(
data={
"token": result["session"]["token"],
"expires_in": result["session"].get("expires_in", 86400),
"token_type": "Bearer",
"user": result["user"],
},
message="Registration successful",
)
elif result.get("flow_type") == "link":
return api_response(
data={
"linked_account": result["linked_account"],
},
message="Account linked successfully",
)
return api_response(
data=result,
message="OAuth flow completed",
)
except OAuthFlowError as e:
return api_response(
success=False,
message=e.message,
status=e.status_code,
error_type=e.error_type,
)
# =============================================================================
# Helper Functions
# =============================================================================
def _get_provider_endpoints(provider_type: AuthMethodType):
"""Get OAuth endpoints for a provider."""
if provider_type == AuthMethodType.GOOGLE:
return (
"https://accounts.google.com/o/oauth2/v2/auth",
"https://oauth2.googleapis.com/token",
"https://www.googleapis.com/oauth2/v3/userinfo",
)
elif provider_type == AuthMethodType.GITHUB:
return (
"https://github.com/login/oauth/authorize",
"https://github.com/login/oauth/access_token",
"https://api.github.com/user",
)
elif provider_type == AuthMethodType.MICROSOFT:
return (
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
"https://login.microsoftonline.com/common/oauth2/v2.0/token",
"https://graph.microsoft.com/oidc/userinfo",
)
else:
raise ExternalAuthError(
f"Unsupported provider: {provider_type}",
"UNSUPPORTED_PROVIDER",
400,
)
+15
View File
@@ -22,6 +22,21 @@ class BaseModel(db.Model):
) )
deleted_at = db.Column(db.DateTime, nullable=True) deleted_at = db.Column(db.DateTime, nullable=True)
@classmethod
def create(cls, **kwargs):
"""Create and save a new model instance.
Args:
**kwargs: Model field values
Returns:
The created model instance
"""
instance = cls(**kwargs)
db.session.add(instance)
db.session.commit()
return instance
def save(self): def save(self):
"""Save the model instance to database.""" """Save the model instance to database."""
db.session.add(self) db.session.add(self)
+3
View File
@@ -24,6 +24,9 @@ class Organization(BaseModel):
oidc_clients = db.relationship( oidc_clients = db.relationship(
"OIDCClient", back_populates="organization", cascade="all, delete-orphan" "OIDCClient", back_populates="organization", cascade="all, delete-orphan"
) )
external_provider_configs = db.relationship(
"ExternalProviderConfig", back_populates="organization", cascade="all, delete-orphan"
)
security_policy = db.relationship( security_policy = db.relationship(
"OrganizationSecurityPolicy", "OrganizationSecurityPolicy",
back_populates="organization", back_populates="organization",
+229
View File
@@ -105,3 +105,232 @@ class AuditService:
.limit(limit) .limit(limit)
.all() .all()
) )
# External Authentication Provider Audit Methods
@staticmethod
def log_external_auth_link_initiated(
user_id: str,
organization_id: str,
provider_type: str,
state_id: str = None,
):
"""Log external auth account linking initiated event."""
return AuditService.log_action(
action=AuditAction.EXTERNAL_AUTH_LINK_INITIATED,
user_id=user_id,
organization_id=organization_id,
resource_type="oauth_state",
resource_id=state_id,
metadata={
"provider_type": provider_type,
},
description=f"External auth link initiated for {provider_type}",
success=True,
)
@staticmethod
def log_external_auth_link_completed(
user_id: str,
organization_id: str,
provider_type: str,
provider_user_id: str,
auth_method_id: str = None,
):
"""Log external auth account linking completed event."""
return AuditService.log_action(
action=AuditAction.EXTERNAL_AUTH_LINK_COMPLETED,
user_id=user_id,
organization_id=organization_id,
resource_type="authentication_method",
resource_id=auth_method_id,
metadata={
"provider_type": provider_type,
"provider_user_id": provider_user_id,
},
description=f"External auth account linked: {provider_type} ({provider_user_id})",
success=True,
)
@staticmethod
def log_external_auth_link_failed(
user_id: str,
organization_id: str,
provider_type: str,
error_message: str,
failure_reason: str = None,
):
"""Log external auth account linking failed event."""
return AuditService.log_action(
action=AuditAction.EXTERNAL_AUTH_LINK_FAILED,
user_id=user_id,
organization_id=organization_id,
metadata={
"provider_type": provider_type,
"failure_reason": failure_reason,
},
description=f"External auth link failed for {provider_type}: {error_message}",
success=False,
error_message=error_message,
)
@staticmethod
def log_external_auth_unlink(
user_id: str,
organization_id: str,
provider_type: str,
provider_user_id: str,
auth_method_id: str = None,
):
"""Log external auth account unlinking event."""
return AuditService.log_action(
action=AuditAction.EXTERNAL_AUTH_UNLINK,
user_id=user_id,
organization_id=organization_id,
resource_type="authentication_method",
resource_id=auth_method_id,
metadata={
"provider_type": provider_type,
"provider_user_id": provider_user_id,
},
description=f"External auth account unlinked: {provider_type} ({provider_user_id})",
success=True,
)
@staticmethod
def log_external_auth_login(
user_id: str,
organization_id: str,
provider_type: str,
provider_user_id: str,
auth_method_id: str = None,
session_id: str = None,
mfa_used: bool = False,
):
"""Log external auth login event."""
return AuditService.log_action(
action=AuditAction.EXTERNAL_AUTH_LOGIN,
user_id=user_id,
organization_id=organization_id,
resource_type="session",
resource_id=session_id,
metadata={
"provider_type": provider_type,
"provider_user_id": provider_user_id,
"auth_method_id": auth_method_id,
"mfa_used": mfa_used,
},
description=f"User logged in with {provider_type}",
success=True,
)
@staticmethod
def log_external_auth_login_failed(
organization_id: str,
provider_type: str,
provider_user_id: str = None,
email: str = None,
failure_reason: str = None,
error_message: str = None,
):
"""Log external auth login failed event."""
return AuditService.log_action(
action=AuditAction.EXTERNAL_AUTH_LOGIN_FAILED,
user_id=None, # Unknown user
organization_id=organization_id,
metadata={
"provider_type": provider_type,
"provider_user_id": provider_user_id,
"email": email,
"failure_reason": failure_reason,
},
description=f"Failed login attempt with {provider_type}: {failure_reason or error_message}",
success=False,
error_message=error_message or failure_reason,
)
@staticmethod
def log_external_auth_token_refresh(
user_id: str,
organization_id: str,
provider_type: str,
auth_method_id: str = None,
):
"""Log external auth token refresh event."""
return AuditService.log_action(
action=AuditAction.EXTERNAL_AUTH_TOKEN_REFRESH,
user_id=user_id,
organization_id=organization_id,
resource_type="authentication_method",
resource_id=auth_method_id,
metadata={
"provider_type": provider_type,
},
description=f"External auth token refreshed for {provider_type}",
success=True,
)
@staticmethod
def log_external_auth_config_create(
user_id: str,
organization_id: str,
provider_type: str,
config_id: str = None,
):
"""Log external auth provider config creation event."""
return AuditService.log_action(
action=AuditAction.EXTERNAL_AUTH_CONFIG_CREATE,
user_id=user_id,
organization_id=organization_id,
resource_type="external_provider_config",
resource_id=config_id,
metadata={
"provider_type": provider_type,
},
description=f"External auth provider config created: {provider_type}",
success=True,
)
@staticmethod
def log_external_auth_config_update(
user_id: str,
organization_id: str,
provider_type: str,
config_id: str = None,
changes: dict = None,
):
"""Log external auth provider config update event."""
return AuditService.log_action(
action=AuditAction.EXTERNAL_AUTH_CONFIG_UPDATE,
user_id=user_id,
organization_id=organization_id,
resource_type="external_provider_config",
resource_id=config_id,
metadata={
"provider_type": provider_type,
"changes": changes,
},
description=f"External auth provider config updated: {provider_type}",
success=True,
)
@staticmethod
def log_external_auth_config_delete(
user_id: str,
organization_id: str,
provider_type: str,
config_id: str = None,
):
"""Log external auth provider config deletion event."""
return AuditService.log_action(
action=AuditAction.EXTERNAL_AUTH_CONFIG_DELETE,
user_id=user_id,
organization_id=organization_id,
resource_type="external_provider_config",
resource_id=config_id,
metadata={
"provider_type": provider_type,
},
description=f"External auth provider config deleted: {provider_type}",
success=True,
)
@@ -0,0 +1,761 @@
"""External authentication provider service."""
import logging
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional, Tuple
from flask import current_app
from gatehouse_app.extensions import db
from gatehouse_app.models import User, AuthenticationMethod
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import AuthMethodType
from gatehouse_app.services.audit_service import AuditService
logger = logging.getLogger(__name__)
class ExternalAuthError(Exception):
"""Base exception for external auth errors."""
def __init__(self, message: str, error_type: str, status_code: int = 400):
self.message = message
self.error_type = error_type
self.status_code = status_code
super().__init__(message)
class OAuthState(BaseModel):
"""Temporary OAuth state storage for secure flow management."""
__tablename__ = "oauth_states"
# State identifier (used in OAuth redirects)
state = db.Column(db.String(64), unique=True, nullable=False, index=True)
# Flow type
flow_type = db.Column(db.String(50), nullable=False) # 'link', 'login', 'register'
# User context
user_id = db.Column(db.String(36), db.ForeignKey("users.id"), nullable=True, index=True)
organization_id = db.Column(
db.String(36), db.ForeignKey("organizations.id"), nullable=True, index=True
)
# Provider information
provider_type = db.Column(db.String(50), nullable=False)
# OAuth parameters
nonce = db.Column(db.String(128), nullable=True)
code_verifier = db.Column(db.String(128), nullable=True)
code_challenge = db.Column(db.String(128), nullable=True)
redirect_uri = db.Column(db.String(2048), nullable=True)
# Additional state data
extra_data = db.Column(db.JSON, nullable=True)
# Expiration
expires_at = db.Column(db.DateTime, nullable=False, index=True)
# Status
used = db.Column(db.Boolean, default=False, nullable=False)
@classmethod
def create_state(
cls,
flow_type: str,
provider_type: AuthMethodType,
user_id: str = None,
organization_id: str = None,
redirect_uri: str = None,
nonce: str = None,
code_verifier: str = None,
code_challenge: str = None,
extra_data: dict = None,
lifetime_seconds: int = 600,
) -> "OAuthState":
"""Create a new OAuth state record."""
state = secrets.token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(seconds=lifetime_seconds)
return cls.create(
state=state,
flow_type=flow_type,
provider_type=provider_type.value if isinstance(provider_type, AuthMethodType) else provider_type,
user_id=user_id,
organization_id=organization_id,
redirect_uri=redirect_uri,
nonce=nonce or secrets.token_urlsafe(16),
code_verifier=code_verifier,
code_challenge=code_challenge,
extra_data=extra_data,
expires_at=expires_at,
)
def is_valid(self) -> bool:
"""Check if state is still valid."""
return (
not self.used
and self.expires_at
and self.expires_at.replace(tzinfo=timezone.utc) > datetime.now(timezone.utc)
)
def mark_used(self):
"""Mark state as used."""
self.used = True
self.save()
@classmethod
def cleanup_expired(cls):
"""Remove expired states."""
cls.query.filter(cls.expires_at < datetime.now(timezone.utc)).delete()
db.session.commit()
class ExternalProviderConfig(BaseModel):
"""OAuth provider configuration per organization."""
__tablename__ = "external_provider_configs"
# Organization reference
organization_id = db.Column(
db.String(36), db.ForeignKey("organizations.id"), nullable=False, index=True
)
# Provider type
provider_type = db.Column(db.String(50), nullable=False, index=True)
# OAuth credentials (client_secret is encrypted)
client_id = db.Column(db.String(255), nullable=False)
client_secret_encrypted = db.Column(db.String(512), nullable=True)
# Provider endpoints
auth_url = db.Column(db.String(2048), nullable=False)
token_url = db.Column(db.String(2048), nullable=False)
userinfo_url = db.Column(db.String(2048), nullable=True)
jwks_url = db.Column(db.String(2048), nullable=True)
# Configuration
scopes = db.Column(db.JSON, nullable=False, default=list)
redirect_uris = db.Column(db.JSON, nullable=False, default=list)
# Provider-specific settings
settings = db.Column(db.JSON, nullable=True)
# Status
is_active = db.Column(db.Boolean, default=True, nullable=False)
# Relationships
organization = db.relationship(
"Organization", back_populates="external_provider_configs"
)
# Indexes
__table_args__ = (
db.Index("idx_provider_config_org", "organization_id", "provider_type"),
db.UniqueConstraint(
"organization_id",
"provider_type",
name="uix_org_provider_type",
),
)
def get_client_secret(self) -> str:
"""Decrypt and return client secret."""
from gatehouse_app.utils.encryption import decrypt
if self.client_secret_encrypted:
return decrypt(self.client_secret_encrypted)
return None
def set_client_secret(self, secret: str):
"""Encrypt and store client secret."""
from gatehouse_app.utils.encryption import encrypt
self.client_secret_encrypted = encrypt(secret)
def is_redirect_uri_allowed(self, uri: str) -> bool:
"""Check if redirect URI is allowed."""
return uri in (self.redirect_uris or [])
def to_dict(self, include_secrets: bool = False) -> dict:
"""Convert to dictionary."""
data = {
"id": self.id,
"organization_id": self.organization_id,
"provider_type": self.provider_type,
"client_id": self.client_id,
"auth_url": self.auth_url,
"token_url": self.token_url,
"userinfo_url": self.userinfo_url,
"scopes": self.scopes,
"redirect_uris": self.redirect_uris,
"is_active": self.is_active,
"settings": self.settings,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
if include_secrets and self.client_secret_encrypted:
data["client_secret"] = self.get_client_secret()
return data
class ExternalAuthService:
"""Service for external authentication operations."""
@classmethod
def get_provider_config(
cls,
organization_id: str,
provider_type: AuthMethodType,
) -> ExternalProviderConfig:
"""Get provider configuration for organization."""
provider_type_str = provider_type.value if isinstance(provider_type, AuthMethodType) else provider_type
config = ExternalProviderConfig.query.filter_by(
organization_id=organization_id,
provider_type=provider_type_str,
is_active=True,
).first()
if not config:
raise ExternalAuthError(
f"{provider_type_str.title()} OAuth is not configured for this organization",
"PROVIDER_NOT_CONFIGURED",
400,
)
return config
@classmethod
def initiate_link_flow(
cls,
user_id: str,
provider_type: AuthMethodType,
organization_id: str,
redirect_uri: str = None,
) -> Tuple[str, str]:
"""
Initiate account linking flow.
Returns:
Tuple of (redirect_url, state)
"""
provider_type_str = provider_type.value if isinstance(provider_type, AuthMethodType) else provider_type
# Get provider config
config = cls.get_provider_config(organization_id, provider_type)
# Validate redirect URI
if redirect_uri and not config.is_redirect_uri_allowed(redirect_uri):
raise ExternalAuthError(
"Invalid redirect URI",
"INVALID_REDIRECT_URI",
400,
)
# Generate PKCE
code_verifier = secrets.token_urlsafe(32)
code_challenge = cls._compute_s256_challenge(code_verifier)
# Create OAuth state
state = OAuthState.create_state(
flow_type="link",
provider_type=provider_type,
user_id=user_id,
organization_id=organization_id,
redirect_uri=redirect_uri or config.redirect_uris[0],
code_verifier=code_verifier,
code_challenge=code_challenge,
lifetime_seconds=600,
)
# Build authorization URL (simplified - in production would use provider-specific implementation)
auth_url = cls._build_authorization_url(
config=config,
state=state,
)
# Audit log - link initiated
AuditService.log_external_auth_link_initiated(
user_id=user_id,
organization_id=organization_id,
provider_type=provider_type_str,
state_id=state.id,
)
return auth_url, state.state
@classmethod
def complete_link_flow(
cls,
provider_type: AuthMethodType,
authorization_code: str,
state: str,
redirect_uri: str,
) -> AuthenticationMethod:
"""Complete account linking flow."""
provider_type_str = provider_type.value if isinstance(provider_type, AuthMethodType) else provider_type
# Validate state
state_record = OAuthState.query.filter_by(state=state).first()
if not state_record or not state_record.is_valid():
AuditService.log_external_auth_link_failed(
user_id=None,
organization_id=None,
provider_type=provider_type_str,
error_message="Invalid or expired OAuth state",
failure_reason="invalid_state",
)
raise ExternalAuthError(
"Invalid or expired OAuth state",
"INVALID_STATE",
400,
)
if state_record.flow_type != "link":
AuditService.log_external_auth_link_failed(
user_id=state_record.user_id,
organization_id=state_record.organization_id,
provider_type=provider_type_str,
error_message="Invalid flow type for this operation",
failure_reason="invalid_flow_type",
)
raise ExternalAuthError(
"Invalid flow type for this operation",
"INVALID_FLOW_TYPE",
400,
)
if state_record.provider_type != provider_type_str:
AuditService.log_external_auth_link_failed(
user_id=state_record.user_id,
organization_id=state_record.organization_id,
provider_type=provider_type_str,
error_message="Provider mismatch",
failure_reason="provider_mismatch",
)
raise ExternalAuthError(
"Provider mismatch",
"PROVIDER_MISMATCH",
400,
)
# Get provider config
config = cls.get_provider_config(
state_record.organization_id, provider_type
)
# Exchange code for tokens (simplified - in production would use provider-specific implementation)
tokens = cls._exchange_code(
config=config,
code=authorization_code,
redirect_uri=redirect_uri,
code_verifier=state_record.code_verifier,
)
# Get user info
user_info = cls._get_user_info(
config=config,
access_token=tokens["access_token"],
)
# Get user
user = User.query.get(state_record.user_id)
if not user:
AuditService.log_external_auth_link_failed(
user_id=None,
organization_id=state_record.organization_id,
provider_type=provider_type_str,
error_message="User not found",
failure_reason="user_not_found",
)
raise ExternalAuthError(
"User not found",
"USER_NOT_FOUND",
400,
)
# Create or update authentication method
auth_method = AuthenticationMethod.query.filter_by(
user_id=user.id,
method_type=provider_type,
provider_user_id=user_info["provider_user_id"],
).first()
if auth_method:
# Update existing
auth_method.provider_data = cls._encrypt_provider_data(tokens, user_info)
auth_method.verified = user_info.get("email_verified", False)
auth_method.last_used_at = datetime.utcnow()
auth_method.save()
else:
# Create new
auth_method = AuthenticationMethod(
user_id=user.id,
method_type=provider_type,
provider_user_id=user_info["provider_user_id"],
provider_data=cls._encrypt_provider_data(tokens, user_info),
verified=user_info.get("email_verified", False),
is_primary=False,
last_used_at=datetime.utcnow(),
)
auth_method.save()
# Mark state as used
state_record.mark_used()
# Audit log - link completed
AuditService.log_external_auth_link_completed(
user_id=user.id,
organization_id=state_record.organization_id,
provider_type=provider_type_str,
provider_user_id=user_info["provider_user_id"],
auth_method_id=auth_method.id,
)
return auth_method
@classmethod
def authenticate_with_provider(
cls,
provider_type: AuthMethodType,
organization_id: str,
authorization_code: str,
state: str,
redirect_uri: str,
) -> Tuple[User, dict]:
"""Authenticate user with external provider and return tokens."""
provider_type_str = provider_type.value if isinstance(provider_type, AuthMethodType) else provider_type
# Validate state
state_record = OAuthState.query.filter_by(state=state).first()
if not state_record or not state_record.is_valid():
AuditService.log_external_auth_login_failed(
organization_id=organization_id,
provider_type=provider_type_str,
failure_reason="invalid_state",
error_message="Invalid or expired OAuth state",
)
raise ExternalAuthError(
"Invalid or expired OAuth state",
"INVALID_STATE",
400,
)
# Get provider config
config = cls.get_provider_config(organization_id, provider_type)
# Exchange code for tokens
tokens = cls._exchange_code(
config=config,
code=authorization_code,
redirect_uri=redirect_uri,
code_verifier=state_record.code_verifier,
)
# Get user info
user_info = cls._get_user_info(
config=config,
access_token=tokens["access_token"],
)
# Look up user by provider_user_id
auth_method = AuthenticationMethod.query.filter_by(
method_type=provider_type,
provider_user_id=user_info["provider_user_id"],
).first()
if not auth_method:
# Check if email matches existing user
existing_user = User.query.filter_by(
email=user_info["email"]
).first()
if existing_user:
AuditService.log_external_auth_login_failed(
organization_id=organization_id,
provider_type=provider_type_str,
provider_user_id=user_info["provider_user_id"],
email=user_info["email"],
failure_reason="email_exists",
error_message=f"An account with email {user_info['email']} already exists",
)
raise ExternalAuthError(
f"An account with email {user_info['email']} already exists. "
"Please log in with your password and link your Google account from settings.",
"EMAIL_EXISTS",
400,
)
AuditService.log_external_auth_login_failed(
organization_id=organization_id,
provider_type=provider_type_str,
provider_user_id=user_info["provider_user_id"],
email=user_info["email"],
failure_reason="account_not_found",
error_message="No Gatehouse account matches this external account",
)
raise ExternalAuthError(
"No Gatehouse account matches this external account. Please register first.",
"ACCOUNT_NOT_FOUND",
400,
)
user = auth_method.user
# Update tokens
auth_method.provider_data = cls._encrypt_provider_data(tokens, user_info)
auth_method.last_used_at = datetime.utcnow()
auth_method.save()
# Mark state as used
state_record.mark_used()
# Create session
from gatehouse_app.services.auth_service import AuthService
session = AuthService.create_session(
user=user,
organization_id=organization_id,
)
# Audit log - login success
AuditService.log_external_auth_login(
user_id=user.id,
organization_id=organization_id,
provider_type=provider_type_str,
provider_user_id=user_info["provider_user_id"],
auth_method_id=auth_method.id,
session_id=session.id,
)
return user, session.to_dict()
@classmethod
def unlink_provider(
cls,
user_id: str,
provider_type: AuthMethodType,
organization_id: str = None,
) -> bool:
"""Unlink external provider from user account."""
provider_type_str = provider_type.value if isinstance(provider_type, AuthMethodType) else provider_type
auth_method = AuthenticationMethod.query.filter_by(
user_id=user_id,
method_type=provider_type,
).first()
if not auth_method:
raise ExternalAuthError(
f"Provider not linked",
"PROVIDER_NOT_LINKED",
400,
)
# Check if this is the last auth method
other_methods = AuthenticationMethod.query.filter_by(
user_id=user_id,
).count()
if other_methods <= 1:
raise ExternalAuthError(
"Cannot unlink the last authentication method",
"CANNOT_UNLINK_LAST",
400,
)
provider_user_id = auth_method.provider_user_id
auth_method_id = auth_method.id
auth_method.delete()
# Audit log - unlink
AuditService.log_external_auth_unlink(
user_id=user_id,
organization_id=organization_id,
provider_type=provider_type_str,
provider_user_id=provider_user_id,
auth_method_id=auth_method_id,
)
return True
@classmethod
def get_linked_accounts(cls, user_id: str) -> list:
"""Get all linked external accounts for user."""
methods = AuthenticationMethod.query.filter_by(
user_id=user_id,
).all()
external_providers = [
AuthMethodType.GOOGLE,
AuthMethodType.GITHUB,
AuthMethodType.MICROSOFT,
]
return [
{
"id": m.id,
"provider_type": m.method_type.value if hasattr(m.method_type, 'value') else str(m.method_type),
"provider_user_id": m.provider_user_id,
"email": m.provider_data.get("email") if m.provider_data else None,
"name": m.provider_data.get("name") if m.provider_data else None,
"picture": m.provider_data.get("picture") if m.provider_data else None,
"verified": m.verified,
"linked_at": m.created_at.isoformat() if m.created_at else None,
"last_used_at": m.last_used_at.isoformat() if m.last_used_at else None,
}
for m in methods
if m.method_type in external_providers or str(m.method_type) in [p.value for p in external_providers]
]
@staticmethod
def _compute_s256_challenge(verifier: str) -> str:
"""Compute S256 code challenge from verifier."""
import hashlib
import base64
digest = hashlib.sha256(verifier.encode()).digest()
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
@staticmethod
def _build_authorization_url(config: ExternalProviderConfig, state: OAuthState) -> str:
"""Build authorization URL (simplified - provider-specific in production)."""
from urllib.parse import urlencode
params = {
"client_id": config.client_id,
"redirect_uri": state.redirect_uri,
"response_type": "code",
"scope": " ".join(config.scopes or ["openid", "profile", "email"]),
"state": state.state,
"access_type": config.settings.get("access_type", "offline") if config.settings else "offline",
"prompt": config.settings.get("prompt", "consent") if config.settings else "consent",
}
if state.nonce:
params["nonce"] = state.nonce
if state.code_challenge:
params["code_challenge"] = state.code_challenge
params["code_challenge_method"] = "S256"
return f"{config.auth_url}?{urlencode(params)}"
@staticmethod
def _exchange_code(config: ExternalProviderConfig, code: str, redirect_uri: str, code_verifier: str = None) -> dict:
"""Exchange authorization code for tokens (simplified - provider-specific in production)."""
import requests
data = {
"client_id": config.client_id,
"client_secret": config.get_client_secret(),
"code": code,
"grant_type": "authorization_code",
"redirect_uri": redirect_uri,
}
if code_verifier:
data["code_verifier"] = code_verifier
response = requests.post(config.token_url, data=data)
response.raise_for_status()
return response.json()
@staticmethod
def _get_user_info(config: ExternalProviderConfig, access_token: str) -> dict:
"""Get user info from provider (simplified - provider-specific in production)."""
import requests
headers = {"Authorization": f"Bearer {access_token}"}
response = requests.get(config.userinfo_url, headers=headers)
response.raise_for_status()
data = response.json()
# Standardize user info
return {
"provider_user_id": data.get("sub"),
"email": data.get("email"),
"email_verified": data.get("email_verified", False),
"name": data.get("name"),
"first_name": data.get("given_name"),
"last_name": data.get("family_name"),
"picture": data.get("picture"),
"raw_data": data,
}
@staticmethod
def _encrypt_provider_data(tokens: dict, user_info: dict) -> dict:
"""Encrypt and store provider tokens and user info."""
from gatehouse_app.utils.encryption import encrypt
result = {
"access_token": encrypt(tokens.get("access_token")) if tokens.get("access_token") else None,
"token_type": tokens.get("token_type", "Bearer"),
"expires_in": tokens.get("expires_in"),
"refresh_token": encrypt(tokens.get("refresh_token")) if tokens.get("refresh_token") else None,
"scope": tokens.get("scope", []),
"id_token": encrypt(tokens.get("id_token")) if tokens.get("id_token") else None,
"email": user_info.get("email"),
"name": user_info.get("name"),
"picture": user_info.get("picture"),
"raw_data": user_info.get("raw_data", {}),
}
return result
@staticmethod
def _decrypt_provider_data(provider_data: dict) -> dict:
"""
Decrypt provider tokens from stored data.
This method handles backward compatibility with existing data where
access_token may be stored in plain text (unencrypted).
"""
from gatehouse_app.utils.encryption import decrypt
if not provider_data:
return {}
result = {
"token_type": provider_data.get("token_type", "Bearer"),
"expires_in": provider_data.get("expires_in"),
"scope": provider_data.get("scope", []),
"email": provider_data.get("email"),
"name": provider_data.get("name"),
"picture": provider_data.get("picture"),
"raw_data": provider_data.get("raw_data", {}),
}
# Decrypt access_token with backward compatibility
access_token = provider_data.get("access_token")
if access_token:
# Try to decrypt - if it fails, assume it's plain text (old data)
try:
result["access_token"] = decrypt(access_token)
except Exception:
# Access token is plain text (pre-encryption data)
result["access_token"] = access_token
else:
result["access_token"] = None
# Decrypt refresh_token
refresh_token = provider_data.get("refresh_token")
if refresh_token:
try:
result["refresh_token"] = decrypt(refresh_token)
except Exception:
result["refresh_token"] = refresh_token
else:
result["refresh_token"] = None
# Decrypt id_token
id_token = provider_data.get("id_token")
if id_token:
try:
result["id_token"] = decrypt(id_token)
except Exception:
result["id_token"] = id_token
else:
result["id_token"] = None
return result
@@ -0,0 +1,524 @@
"""OAuth flow service for handling external authentication flows."""
import logging
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional, Tuple
from flask import current_app, request, g
from gatehouse_app.extensions import db
from gatehouse_app.models import User, AuthenticationMethod
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import AuthMethodType
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.services.external_auth_service import (
ExternalAuthService,
ExternalAuthError,
OAuthState,
ExternalProviderConfig,
)
logger = logging.getLogger(__name__)
class OAuthFlowError(Exception):
"""Exception for OAuth flow errors."""
def __init__(self, message: str, error_type: str, status_code: int = 400):
self.message = message
self.error_type = error_type
self.status_code = status_code
super().__init__(message)
class OAuthFlowService:
"""Service for managing OAuth authentication flows."""
@classmethod
def initiate_login_flow(
cls,
provider_type: AuthMethodType,
organization_id: str = None,
redirect_uri: str = None,
state_data: dict = None,
) -> Tuple[str, str]:
"""
Initiate OAuth login flow.
Args:
provider_type: The authentication provider type
organization_id: Optional organization context for SSO
redirect_uri: Optional custom redirect URI
state_data: Additional state data to include
Returns:
Tuple of (authorization_url, state)
"""
# Get request context for audit logging
try:
ip_address = request.remote_addr if request else None
user_agent = request.headers.get("User-Agent") if request else None
except RuntimeError:
ip_address = None
user_agent = None
provider_type_str = provider_type.value if isinstance(provider_type, AuthMethodType) else provider_type
try:
# Get provider config
config = ExternalAuthService.get_provider_config(organization_id, provider_type)
# Validate redirect URI
if redirect_uri and not config.is_redirect_uri_allowed(redirect_uri):
raise OAuthFlowError(
"Invalid redirect URI",
"INVALID_REDIRECT_URI",
400,
)
# Generate PKCE
code_verifier = secrets.token_urlsafe(32)
code_challenge = ExternalAuthService._compute_s256_challenge(code_verifier)
# Create OAuth state for login flow
state = OAuthState.create_state(
flow_type="login",
provider_type=provider_type,
organization_id=organization_id,
redirect_uri=redirect_uri or (config.redirect_uris[0] if config.redirect_uris else None),
code_verifier=code_verifier,
code_challenge=code_challenge,
extra_data=state_data,
lifetime_seconds=600,
)
# Build authorization URL
auth_url = ExternalAuthService._build_authorization_url(
config=config,
state=state,
)
logger.info(
f"OAuth login flow initiated for provider={provider_type_str}, "
f"org_id={organization_id}, state_id={state.id}"
)
return auth_url, state.state
except ExternalAuthError as e:
# Log failed initiation
AuditService.log_action(
action="external_auth.login.initiated",
organization_id=organization_id,
metadata={
"provider_type": provider_type_str,
"failure_reason": e.error_type,
"ip_address": ip_address,
},
description=f"OAuth login initiation failed: {e.message}",
success=False,
error_message=e.message,
)
raise
@classmethod
def initiate_register_flow(
cls,
provider_type: AuthMethodType,
organization_id: str = None,
redirect_uri: str = None,
) -> Tuple[str, str]:
"""
Initiate OAuth registration flow.
Args:
provider_type: The authentication provider type
organization_id: Optional organization context
redirect_uri: Optional custom redirect URI
Returns:
Tuple of (authorization_url, state)
"""
provider_type_str = provider_type.value if isinstance(provider_type, AuthMethodType) else provider_type
try:
# Get provider config
config = ExternalAuthService.get_provider_config(organization_id, provider_type)
# Validate redirect URI
if redirect_uri and not config.is_redirect_uri_allowed(redirect_uri):
raise OAuthFlowError(
"Invalid redirect URI",
"INVALID_REDIRECT_URI",
400,
)
# Generate PKCE
code_verifier = secrets.token_urlsafe(32)
code_challenge = ExternalAuthService._compute_s256_challenge(code_verifier)
# Create OAuth state for register flow
state = OAuthState.create_state(
flow_type="register",
provider_type=provider_type,
organization_id=organization_id,
redirect_uri=redirect_uri or (config.redirect_uris[0] if config.redirect_uris else None),
code_verifier=code_verifier,
code_challenge=code_challenge,
lifetime_seconds=600,
)
# Build authorization URL
auth_url = ExternalAuthService._build_authorization_url(
config=config,
state=state,
)
logger.info(
f"OAuth register flow initiated for provider={provider_type_str}, "
f"org_id={organization_id}, state_id={state.id}"
)
return auth_url, state.state
except ExternalAuthError as e:
AuditService.log_action(
action="external_auth.register.initiated",
organization_id=organization_id,
metadata={
"provider_type": provider_type_str,
"failure_reason": e.error_type,
},
description=f"OAuth registration initiation failed: {e.message}",
success=False,
error_message=e.message,
)
raise
@classmethod
def handle_callback(
cls,
provider_type: AuthMethodType,
authorization_code: str,
state: str,
redirect_uri: str = None,
error: str = None,
error_description: str = None,
) -> dict:
"""
Handle OAuth callback from provider.
Args:
provider_type: The authentication provider type
authorization_code: Authorization code from provider
state: State parameter from provider
redirect_uri: Redirect URI used in the flow
error: Error code if auth failed
error_description: Human-readable error description
Returns:
Dict with flow result
"""
provider_type_str = provider_type.value if isinstance(provider_type, AuthMethodType) else provider_type
# Get request context for audit logging
try:
ip_address = request.remote_addr if request else None
user_agent = request.headers.get("User-Agent") if request else None
except RuntimeError:
ip_address = None
user_agent = None
# Handle error response from provider
if error:
AuditService.log_external_auth_login_failed(
organization_id=None,
provider_type=provider_type_str,
failure_reason=error,
error_message=error_description or error,
)
raise OAuthFlowError(
error_description or f"OAuth error: {error}",
error.upper() if error else "OAUTH_ERROR",
400,
)
# Validate state
state_record = OAuthState.query.filter_by(state=state).first()
if not state_record or not state_record.is_valid():
AuditService.log_external_auth_login_failed(
organization_id=state_record.organization_id if state_record else None,
provider_type=provider_type_str,
failure_reason="invalid_state",
error_message="Invalid or expired OAuth state",
)
raise OAuthFlowError(
"Invalid or expired OAuth state",
"INVALID_STATE",
400,
)
# Route to appropriate handler based on flow type
if state_record.flow_type == "login":
return cls._handle_login_callback(
provider_type=provider_type,
state_record=state_record,
authorization_code=authorization_code,
redirect_uri=redirect_uri or state_record.redirect_uri,
ip_address=ip_address,
user_agent=user_agent,
)
elif state_record.flow_type == "link":
return cls._handle_link_callback(
provider_type=provider_type,
state_record=state_record,
authorization_code=authorization_code,
redirect_uri=redirect_uri or state_record.redirect_uri,
)
elif state_record.flow_type == "register":
return cls._handle_register_callback(
provider_type=provider_type,
state_record=state_record,
authorization_code=authorization_code,
redirect_uri=redirect_uri or state_record.redirect_uri,
)
else:
raise OAuthFlowError(
f"Unknown flow type: {state_record.flow_type}",
"INVALID_FLOW_TYPE",
400,
)
@classmethod
def _handle_login_callback(
cls,
provider_type: AuthMethodType,
state_record: OAuthState,
authorization_code: str,
redirect_uri: str,
ip_address: str = None,
user_agent: str = None,
) -> dict:
"""Handle login flow callback."""
provider_type_str = provider_type.value if isinstance(provider_type, AuthMethodType) else provider_type
try:
# Authenticate with provider
user, session_data = ExternalAuthService.authenticate_with_provider(
provider_type=provider_type,
organization_id=state_record.organization_id,
authorization_code=authorization_code,
state=state_record.state,
redirect_uri=redirect_uri,
)
logger.info(
f"OAuth login successful for user={user.id}, "
f"provider={provider_type_str}, org_id={state_record.organization_id}"
)
return {
"success": True,
"flow_type": "login",
"user": {
"id": user.id,
"email": user.email,
"full_name": user.full_name,
"organization_id": state_record.organization_id,
},
"session": session_data,
}
except ExternalAuthError as e:
logger.warning(
f"OAuth login failed for state={state_record.id}, "
f"provider={provider_type_str}, error={e.message}"
)
raise
@classmethod
def _handle_link_callback(
cls,
provider_type: AuthMethodType,
state_record: OAuthState,
authorization_code: str,
redirect_uri: str,
) -> dict:
"""Handle account linking flow callback."""
provider_type_str = provider_type.value if isinstance(provider_type, AuthMethodType) else provider_type
try:
# Complete link flow
auth_method = ExternalAuthService.complete_link_flow(
provider_type=provider_type,
authorization_code=authorization_code,
state=state_record.state,
redirect_uri=redirect_uri,
)
logger.info(
f"OAuth link successful for user={state_record.user_id}, "
f"provider={provider_type_str}, auth_method_id={auth_method.id}"
)
return {
"success": True,
"flow_type": "link",
"linked_account": {
"id": auth_method.id,
"provider_type": provider_type_str,
"provider_user_id": auth_method.provider_user_id,
"verified": auth_method.verified,
},
}
except ExternalAuthError as e:
logger.warning(
f"OAuth link failed for state={state_record.id}, "
f"provider={provider_type_str}, error={e.message}"
)
raise
@classmethod
def _handle_register_callback(
cls,
provider_type: AuthMethodType,
state_record: OAuthState,
authorization_code: str,
redirect_uri: str,
) -> dict:
"""Handle registration flow callback."""
provider_type_str = provider_type.value if isinstance(provider_type, AuthMethodType) else provider_type
try:
# Get provider config
config = ExternalAuthService.get_provider_config(
state_record.organization_id, provider_type
)
# Exchange code for tokens
tokens = ExternalAuthService._exchange_code(
config=config,
code=authorization_code,
redirect_uri=redirect_uri,
code_verifier=state_record.code_verifier,
)
# Get user info
user_info = ExternalAuthService._get_user_info(
config=config,
access_token=tokens["access_token"],
)
# Check if user already exists by email
existing_user = User.query.filter_by(
email=user_info["email"]
).first()
if existing_user:
# User exists - suggest linking
raise OAuthFlowError(
f"An account with email {user_info['email']} already exists. "
"Please log in with your password and link your Google account from settings.",
"EMAIL_EXISTS",
400,
)
# Create new user
user = User(
email=user_info["email"],
full_name=user_info.get("name", ""),
status="active",
)
user.save()
# Create authentication method
auth_method = AuthenticationMethod(
user_id=user.id,
method_type=provider_type,
provider_user_id=user_info["provider_user_id"],
provider_data=ExternalAuthService._encrypt_provider_data(tokens, user_info),
verified=user_info.get("email_verified", False),
is_primary=True,
)
auth_method.save()
# Mark state as used
state_record.mark_used()
# Audit log - registration success
AuditService.log_action(
action="user.register",
user_id=user.id,
organization_id=state_record.organization_id,
resource_type="user",
resource_id=user.id,
metadata={
"provider_type": provider_type_str,
"provider_user_id": user_info["provider_user_id"],
"auth_method_id": auth_method.id,
},
description=f"User registered via {provider_type_str}",
success=True,
)
AuditService.log_external_auth_link_completed(
user_id=user.id,
organization_id=state_record.organization_id,
provider_type=provider_type_str,
provider_user_id=user_info["provider_user_id"],
auth_method_id=auth_method.id,
)
logger.info(
f"OAuth registration successful for email={user_info['email']}, "
f"provider={provider_type_str}, user_id={user.id}"
)
# Create session
from gatehouse_app.services.auth_service import AuthService
session = AuthService.create_session(
user=user,
organization_id=state_record.organization_id,
)
return {
"success": True,
"flow_type": "register",
"user": {
"id": user.id,
"email": user.email,
"full_name": user.full_name,
"organization_id": state_record.organization_id,
},
"session": session.to_dict(),
}
except ExternalAuthError as e:
logger.warning(
f"OAuth registration failed for state={state_record.id}, "
f"provider={provider_type_str}, error={e.message}"
)
raise
@classmethod
def validate_state(cls, state: str) -> Optional[OAuthState]:
"""
Validate and return OAuth state.
Args:
state: The state parameter to validate
Returns:
OAuthState if valid, None otherwise
"""
state_record = OAuthState.query.filter_by(state=state).first()
if state_record and state_record.is_valid():
return state_record
return None
@classmethod
def cleanup_expired_states(cls):
"""Remove expired OAuth states."""
OAuthState.cleanup_expired()
logger.info("Expired OAuth states cleaned up")
+12
View File
@@ -93,6 +93,18 @@ class AuditAction(str, Enum):
MFA_POLICY_USER_SUSPENDED = "mfa.policy.user_suspended" MFA_POLICY_USER_SUSPENDED = "mfa.policy.user_suspended"
MFA_POLICY_USER_COMPLIANT = "mfa.policy.user_compliant" MFA_POLICY_USER_COMPLIANT = "mfa.policy.user_compliant"
# External authentication provider actions
EXTERNAL_AUTH_LINK_INITIATED = "external_auth.link.initiated"
EXTERNAL_AUTH_LINK_COMPLETED = "external_auth.link.completed"
EXTERNAL_AUTH_LINK_FAILED = "external_auth.link.failed"
EXTERNAL_AUTH_UNLINK = "external_auth.unlink"
EXTERNAL_AUTH_LOGIN = "external_auth.login"
EXTERNAL_AUTH_LOGIN_FAILED = "external_auth.login.failed"
EXTERNAL_AUTH_TOKEN_REFRESH = "external_auth.token_refresh"
EXTERNAL_AUTH_CONFIG_CREATE = "external_auth.config.create"
EXTERNAL_AUTH_CONFIG_UPDATE = "external_auth.config.update"
EXTERNAL_AUTH_CONFIG_DELETE = "external_auth.config.delete"
class OIDCGrantType(str, Enum): class OIDCGrantType(str, Enum):
"""OIDC grant types.""" """OIDC grant types."""
+112
View File
@@ -0,0 +1,112 @@
"""Encryption utilities for sensitive data."""
import base64
import os
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
# Encryption key derivation settings
SALT_LENGTH = 16
KEY_ITERATIONS = 480000
def _get_fernet_key(secret_key: str, salt: bytes = None) -> bytes:
"""
Derive a Fernet key from a secret key using PBKDF2.
Args:
secret_key: The master secret key
salt: Optional salt bytes (will be generated if not provided)
Returns:
32-byte key suitable for Fernet encryption
"""
if salt is None:
salt = os.urandom(SALT_LENGTH)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=KEY_ITERATIONS,
)
key = base64.urlsafe_b64encode(kdf.derive(secret_key.encode()))
return key
def encrypt(plaintext: str, secret_key: str = None) -> str:
"""
Encrypt a string using Fernet symmetric encryption.
Args:
plaintext: The string to encrypt
secret_key: The encryption key (uses app config if not provided)
Returns:
Base64-encoded encrypted string with salt prepended
"""
from flask import current_app
if not plaintext:
return ""
# Get secret key from app config or use provided key
if secret_key is None:
secret_key = current_app.config.get("ENCRYPTION_KEY", "")
if not secret_key:
raise ValueError("Encryption key not configured")
# Generate a random salt for this encryption
salt = os.urandom(SALT_LENGTH)
fernet_key = _get_fernet_key(secret_key, salt)
fernet = Fernet(fernet_key)
# Encrypt the plaintext
encrypted_bytes = fernet.encrypt(plaintext.encode())
# Combine salt + encrypted data and base64 encode
combined = salt + encrypted_bytes
return base64.urlsafe_b64encode(combined).decode()
def decrypt(encrypted_data: str, secret_key: str = None) -> str:
"""
Decrypt a string that was encrypted with the encrypt function.
Args:
encrypted_data: Base64-encoded encrypted string with salt prepended
secret_key: The encryption key (uses app config if not provided)
Returns:
The original plaintext string
"""
from flask import current_app
if not encrypted_data:
return ""
# Get secret key from app config or use provided key
if secret_key is None:
secret_key = current_app.config.get("ENCRYPTION_KEY", "")
if not secret_key:
raise ValueError("Encryption key not configured")
try:
# Decode from base64
combined = base64.urlsafe_b64decode(encrypted_data.encode())
# Extract salt and encrypted data
salt = combined[:SALT_LENGTH]
encrypted_bytes = combined[SALT_LENGTH:]
# Derive the key and decrypt
fernet_key = _get_fernet_key(secret_key, salt)
fernet = Fernet(fernet_key)
plaintext = fernet.decrypt(encrypted_bytes)
return plaintext.decode()
except (InvalidToken, ValueError):
raise ValueError("Failed to decrypt data - invalid key or corrupted data")
+5
View File
@@ -14,3 +14,8 @@ markers =
unit: Unit tests unit: Unit tests
integration: Integration tests integration: Integration tests
slow: Slow running tests slow: Slow running tests
external_auth: External authentication tests
oauth: OAuth flow tests
google: Google OAuth tests
github: GitHub OAuth tests
microsoft: Microsoft OAuth tests
+278 -2
View File
@@ -1,10 +1,14 @@
"""Pytest configuration and fixtures.""" """Pytest configuration and fixtures."""
import pytest import pytest
from unittest.mock import Mock, patch
from datetime import datetime, timedelta, timezone
from gatehouse_app import create_app from gatehouse_app import create_app
from gatehouse_app.extensions import db as _db from gatehouse_app.extensions import db as _db
from gatehouse_app.models import User, Organization, OrganizationMember from gatehouse_app.models import User, Organization, OrganizationMember, AuthenticationMethod
from gatehouse_app.services.auth_service import AuthService from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.utils.constants import OrganizationRole from gatehouse_app.utils.constants import OrganizationRole, AuthMethodType
from gatehouse_app.services.external_auth_service import ExternalProviderConfig, OAuthState
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@@ -97,3 +101,275 @@ def second_test_user(db):
user._test_password = password user._test_password = password
return user return user
# =============================================================================
# External Auth Testing Fixtures
# =============================================================================
@pytest.fixture(scope="function")
def google_provider_config(db, test_organization):
"""Create a Google OAuth provider configuration."""
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-google-client-id",
client_secret_encrypted="encrypted-google-secret",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=[
"http://localhost:3000/callback",
"http://localhost:5173/callback",
"https://myapp.example.com/callback",
],
is_active=True,
)
config.save()
return config
@pytest.fixture(scope="function")
def github_provider_config(db, test_organization):
"""Create a GitHub OAuth provider configuration."""
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GITHUB.value,
client_id="test-github-client-id",
client_secret_encrypted="encrypted-github-secret",
auth_url="https://github.com/login/oauth/authorize",
token_url="https://github.com/login/oauth/access_token",
userinfo_url="https://api.github.com/user",
scopes=["read:user", "user:email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
return config
@pytest.fixture(scope="function")
def microsoft_provider_config(db, test_organization):
"""Create a Microsoft OAuth provider configuration."""
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.MICROSOFT.value,
client_id="test-microsoft-client-id",
client_secret_encrypted="encrypted-microsoft-secret",
auth_url="https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
token_url="https://login.microsoftonline.com/common/oauth2/v2.0/token",
userinfo_url="https://graph.microsoft.com/oidc/userinfo",
scopes=["openid", "profile", "email", "User.Read"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
return config
@pytest.fixture(scope="function")
def user_with_google_link(db, test_user):
"""Create a test user with a linked Google account."""
auth_method = AuthenticationMethod(
user_id=test_user.id,
method_type=AuthMethodType.GOOGLE,
provider_user_id="google-123456789",
provider_data={
"email": test_user.email,
"name": "Test User",
"picture": "https://example.com/avatar.jpg",
},
verified=True,
is_primary=False,
)
auth_method.save()
return test_user
@pytest.fixture(scope="function")
def user_with_multiple_providers(db, test_user):
"""Create a test user with multiple linked external accounts."""
# Google account
google_method = AuthenticationMethod(
user_id=test_user.id,
method_type=AuthMethodType.GOOGLE,
provider_user_id="google-123",
provider_data={
"email": test_user.email,
"name": "Test User",
},
verified=True,
)
google_method.save()
# GitHub account
github_method = AuthenticationMethod(
user_id=test_user.id,
method_type=AuthMethodType.GITHUB,
provider_user_id="github-456",
provider_data={
"email": "user@github.com",
"name": "Test User",
},
verified=True,
)
github_method.save()
return test_user
@pytest.fixture
def mock_google_oauth_token_response():
"""Mock Google OAuth token response."""
return {
"access_token": "ya29.mock-access-token",
"refresh_token": "1//mock-refresh-token",
"id_token": "eyJ.mock-id-token",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile email",
}
@pytest.fixture
def mock_google_oauth_user_info():
"""Mock Google OAuth user info response."""
return {
"sub": "google-123456789",
"name": "Test User",
"given_name": "Test",
"family_name": "User",
"picture": "https://example.com/avatar.jpg",
"email": "testuser@gmail.com",
"email_verified": True,
}
@pytest.fixture
def mock_github_oauth_token_response():
"""Mock GitHub OAuth token response."""
return {
"access_token": "gho_mock-access-token",
"token_type": "bearer",
"scope": "read:user,user:email",
}
@pytest.fixture
def mock_github_oauth_user_info():
"""Mock GitHub OAuth user info response."""
return {
"id": 123456789,
"login": "testuser",
"name": "Test User",
"email": "testuser@github.com",
"avatar_url": "https://example.com/avatar.jpg",
"type": "User",
}
@pytest.fixture
def oauth_login_state(db, test_organization):
"""Create an OAuth state for login flow."""
state = OAuthState.create_state(
flow_type="login",
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
redirect_uri="http://localhost:3000/callback",
nonce="mock-nonce",
code_verifier="mock-code-verifier",
code_challenge="mock-code-challenge",
lifetime_seconds=600,
)
return state
@pytest.fixture
def oauth_register_state(db, test_organization):
"""Create an OAuth state for register flow."""
state = OAuthState.create_state(
flow_type="register",
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
redirect_uri="http://localhost:3000/callback",
lifetime_seconds=600,
)
return state
@pytest.fixture
def oauth_link_state(db, test_user, test_organization):
"""Create an OAuth state for link flow."""
state = OAuthState.create_state(
flow_type="link",
provider_type=AuthMethodType.GOOGLE,
user_id=test_user.id,
organization_id=test_organization.id,
redirect_uri="http://localhost:3000/callback",
lifetime_seconds=600,
)
return state
@pytest.fixture
def expired_oauth_state(db, test_organization):
"""Create an expired OAuth state."""
state = OAuthState.create_state(
flow_type="login",
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
redirect_uri="http://localhost:3000/callback",
lifetime_seconds=-1, # Already expired
)
return state
@pytest.fixture
def used_oauth_state(db, test_organization):
"""Create a used OAuth state."""
state = OAuthState.create_state(
flow_type="login",
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
redirect_uri="http://localhost:3000/callback",
lifetime_seconds=600,
)
state.mark_used()
return state
@pytest.fixture
def mock_oauth_flow_mocks():
"""Common mocks for OAuth flow tests."""
with patch.object(
ExternalProviderConfig, 'get_client_secret', return_value='mock-secret'
) as mock_get_secret, patch(
'requests.post'
) as mock_post, patch(
'requests.get'
) as mock_get:
# Mock token exchange response
mock_post.return_value.json.return_value = {
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"id_token": "mock-id-token",
"expires_in": 3600,
}
mock_post.return_value.raise_for_status = Mock()
# Mock user info response
mock_get.return_value.json.return_value = {
"sub": "google-123",
"email": "testuser@gmail.com",
"email_verified": True,
"name": "Test User",
"picture": "https://example.com/avatar.jpg",
}
mock_get.return_value.raise_for_status = Mock()
yield {
'get_secret': mock_get_secret,
'post': mock_post,
'get': mock_get,
}
@@ -0,0 +1,696 @@
"""Integration tests for external authentication API flows."""
import pytest
import json
from unittest.mock import patch, Mock
from gatehouse_app.services.external_auth_service import (
ExternalAuthService,
ExternalProviderConfig,
OAuthState,
)
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.utils.constants import AuthMethodType, OrganizationRole
from gatehouse_app.models import User, AuthenticationMethod, OrganizationMember
@pytest.mark.integration
class TestExternalAuthApiFlows:
"""Integration tests for external auth API flows."""
def test_complete_account_linking_flow(
self, app, db, client, test_user, test_organization
):
"""Test complete account linking flow: initiate → callback → complete."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
client_secret_encrypted="encrypted-secret",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Create organization membership
member = OrganizationMember(
user_id=test_user.id,
organization_id=test_organization.id,
role=OrganizationRole.MEMBER,
)
member.save()
# Login to get token
login_response = client.post(
"/api/v1/auth/login",
json={
"email": test_user.email,
"password": test_user._test_password,
},
)
assert login_response.status_code == 200
token = login_response.get_json()["data"]["token"]
with patch.object(
ExternalAuthService, '_exchange_code'
) as mock_exchange, patch.object(
ExternalAuthService, '_get_user_info'
) as mock_get_user_info:
# Mock external provider responses
mock_exchange.return_value = {
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"id_token": "mock-id-token",
"expires_in": 3600,
}
mock_get_user_info.return_value = {
"provider_user_id": "google-123",
"email": "user@gmail.com",
"email_verified": True,
"name": "Test User",
"picture": "https://example.com/avatar.jpg",
"raw_data": {},
}
# Step 1: Initiate link flow
initiate_response = client.post(
"/api/v1/auth/external/google/link",
json={},
headers={"Authorization": f"Bearer {token}"},
)
assert initiate_response.status_code == 200
initiate_data = initiate_response.get_json()
assert "authorization_url" in initiate_data["data"]
assert "state" in initiate_data["data"]
state = initiate_data["data"]["state"]
# Step 2: Simulate callback (complete link flow)
with patch.object(AuditService, 'log_external_auth_link_completed'):
complete_response = client.get(
f"/api/v1/auth/external/google/callback",
query_string={
"code": "mock-auth-code",
"state": state,
},
)
# The callback returns 200 on success
assert complete_response.status_code == 200
# Verify account is linked
auth_method = AuthenticationMethod.query.filter_by(
user_id=test_user.id,
method_type=AuthMethodType.GOOGLE,
provider_user_id="google-123",
).first()
assert auth_method is not None
def test_complete_login_flow(
self, app, db, client, test_user, test_organization
):
"""Test complete login flow: initiate → callback → authenticate."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
client_secret_encrypted="encrypted-secret",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Create authentication method for user
auth_method = AuthenticationMethod(
user_id=test_user.id,
method_type=AuthMethodType.GOOGLE,
provider_user_id="google-123",
provider_data={"email": test_user.email},
verified=True,
)
auth_method.save()
with patch.object(
ExternalAuthService, '_exchange_code'
) as mock_exchange, patch.object(
ExternalAuthService, '_get_user_info'
) as mock_get_user_info:
# Mock external provider responses
mock_exchange.return_value = {
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"id_token": "mock-id-token",
"expires_in": 3600,
}
mock_get_user_info.return_value = {
"provider_user_id": "google-123",
"email": test_user.email,
"email_verified": True,
"name": "Test User",
"picture": "https://example.com/avatar.jpg",
"raw_data": {},
}
# Initiate login flow
login_init_response = client.get(
"/api/v1/auth/external/google/authorize",
query_string={"flow": "login"},
)
assert login_init_response.status_code == 200
login_init_data = login_init_response.get_json()
assert "authorization_url" in login_init_data["data"]
state = login_init_data["data"]["state"]
# Simulate callback
callback_response = client.get(
f"/api/v1/auth/external/google/callback",
query_string={
"code": "mock-auth-code",
"state": state,
},
)
assert callback_response.status_code == 200
callback_data = callback_response.get_json()
assert callback_data["success"] is True
assert callback_data["flow_type"] == "login"
assert "token" in callback_data["data"]
assert callback_data["data"]["user"]["id"] == test_user.id
def test_account_unlinking_flow(
self, app, db, client, test_user, test_organization
):
"""Test account unlinking flow."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Create organization membership
member = OrganizationMember(
user_id=test_user.id,
organization_id=test_organization.id,
role=OrganizationRole.MEMBER,
)
member.save()
# Create password auth method
password_method = AuthenticationMethod(
user_id=test_user.id,
method_type=AuthMethodType.PASSWORD,
provider_user_id=test_user.id,
)
password_method.save()
# Create Google auth method
google_method = AuthenticationMethod(
user_id=test_user.id,
method_type=AuthMethodType.GOOGLE,
provider_user_id="google-123",
provider_data={"email": test_user.email},
verified=True,
)
google_method.save()
# Login to get token
login_response = client.post(
"/api/v1/auth/login",
json={
"email": test_user.email,
"password": test_user._test_password,
},
)
token = login_response.get_json()["data"]["token"]
# Unlink Google account
with patch.object(AuditService, 'log_external_auth_unlink'):
unlink_response = client.delete(
"/api/v1/auth/external/google/unlink",
headers={"Authorization": f"Bearer {token}"},
)
assert unlink_response.status_code == 200
unlink_data = unlink_response.get_json()
assert "success" in unlink_data or "message" in unlink_data
# Verify account is unlinked
auth_method = AuthenticationMethod.query.filter_by(
user_id=test_user.id,
method_type=AuthMethodType.GOOGLE,
).first()
assert auth_method is None
def test_provider_configuration_crud(
self, app, db, client, test_user, test_organization
):
"""Test provider configuration CRUD operations."""
with app.app_context():
# Create organization membership as admin
member = OrganizationMember(
user_id=test_user.id,
organization_id=test_organization.id,
role=OrganizationRole.ADMIN,
)
member.save()
# Login to get token
login_response = client.post(
"/api/v1/auth/login",
json={
"email": test_user.email,
"password": test_user._test_password,
},
)
token = login_response.get_json()["data"]["token"]
# Step 1: Create provider config
with patch.object(AuditService, 'log_external_auth_config_create'):
create_response = client.post(
"/api/v1/auth/external/google/config",
json={
"client_id": "new-client-id",
"client_secret": "new-client-secret",
"scopes": ["openid", "profile", "email"],
"redirect_uris": ["http://localhost:3000/callback"],
},
headers={"Authorization": f"Bearer {token}"},
)
assert create_response.status_code == 201
create_data = create_response.get_json()
assert create_data["data"]["provider_type"] == "google"
assert create_data["data"]["client_id"] == "new-client-id"
config_id = create_data["data"]["id"]
# Step 2: List providers
list_response = client.get(
"/api/v1/auth/external/providers",
headers={"Authorization": f"Bearer {token}"},
)
assert list_response.status_code == 200
list_data = list_response.get_json()
google_provider = next(
p for p in list_data["data"]["providers"] if p["id"] == "google"
)
assert google_provider["is_configured"] is True
# Step 3: Get provider config
get_response = client.get(
"/api/v1/auth/external/google/config",
headers={"Authorization": f"Bearer {token}"},
)
assert get_response.status_code == 200
get_data = get_response.get_json()
assert get_data["data"]["client_id"] == "new-client-id"
# Step 4: Update provider config
with patch.object(AuditService, 'log_external_auth_config_update'):
update_response = client.post(
"/api/v1/auth/external/google/config",
json={
"client_id": "updated-client-id",
"client_secret": "updated-client-secret",
},
headers={"Authorization": f"Bearer {token}"},
)
assert update_response.status_code == 200
update_data = update_response.get_json()
assert update_data["data"]["client_id"] == "updated-client-id"
# Step 5: Delete provider config
with patch.object(AuditService, 'log_external_auth_config_delete'):
delete_response = client.delete(
"/api/v1/auth/external/google/config",
headers={"Authorization": f"Bearer {token}"},
)
assert delete_response.status_code == 200
# Verify deletion
get_deleted_response = client.get(
"/api/v1/auth/external/google/config",
headers={"Authorization": f"Bearer {token}"},
)
assert get_deleted_response.status_code == 404
def test_invalid_state_error(self, app, db, client, test_user, test_organization):
"""Test error handling for invalid OAuth state."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Try callback with invalid state
callback_response = client.get(
"/api/v1/auth/external/google/callback",
query_string={
"code": "mock-auth-code",
"state": "invalid-state",
},
)
assert callback_response.status_code == 400
callback_data = callback_response.get_json()
assert callback_data["error_type"] == "INVALID_STATE"
def test_expired_state_error(self, app, db, client, test_user, test_organization):
"""Test error handling for expired OAuth state."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Create expired state
state = OAuthState.create_state(
flow_type="login",
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
lifetime_seconds=-1, # Already expired
)
# Try callback with expired state
callback_response = client.get(
"/api/v1/auth/external/google/callback",
query_string={
"code": "mock-auth-code",
"state": state.state,
},
)
assert callback_response.status_code == 400
callback_data = callback_response.get_json()
assert callback_data["error_type"] == "INVALID_STATE"
def test_provider_not_configured_error(
self, app, db, client, test_user, test_organization
):
"""Test error handling when provider is not configured."""
with app.app_context():
# Create organization membership
member = OrganizationMember(
user_id=test_user.id,
organization_id=test_organization.id,
role=OrganizationRole.MEMBER,
)
member.save()
# Login to get token
login_response = client.post(
"/api/v1/auth/login",
json={
"email": test_user.email,
"password": test_user._test_password,
},
)
token = login_response.get_json()["data"]["token"]
# Try to link with unconfigured provider
link_response = client.post(
"/api/v1/auth/external/google/link",
json={},
headers={"Authorization": f"Bearer {token}"},
)
assert link_response.status_code == 400
link_data = link_response.get_json()
assert link_data["error_type"] == "PROVIDER_NOT_CONFIGURED"
def test_linked_accounts_list(self, app, db, client, test_user, test_organization):
"""Test listing linked accounts."""
with app.app_context():
# Create organization membership
member = OrganizationMember(
user_id=test_user.id,
organization_id=test_organization.id,
role=OrganizationRole.MEMBER,
)
member.save()
# Create authentication methods
google_method = AuthenticationMethod(
user_id=test_user.id,
method_type=AuthMethodType.GOOGLE,
provider_user_id="google-123",
provider_data={
"email": test_user.email,
"name": "Test User",
"picture": "https://example.com/avatar.jpg",
},
verified=True,
)
google_method.save()
github_method = AuthenticationMethod(
user_id=test_user.id,
method_type=AuthMethodType.GITHUB,
provider_user_id="github-456",
provider_data={
"email": "user@github.com",
"name": "Test User",
},
verified=True,
)
github_method.save()
# Login to get token
login_response = client.post(
"/api/v1/auth/login",
json={
"email": test_user.email,
"password": test_user._test_password,
},
)
token = login_response.get_json()["data"]["token"]
# List linked accounts
list_response = client.get(
"/api/v1/auth/external/linked-accounts",
headers={"Authorization": f"Bearer {token}"},
)
assert list_response.status_code == 200
list_data = list_response.get_json()
assert len(list_data["data"]["linked_accounts"]) == 2
assert list_data["data"]["unlink_available"] is True
def test_non_admin_cannot_manage_providers(
self, app, db, client, test_user, test_organization
):
"""Test that non-admin users cannot manage provider configurations."""
with app.app_context():
# Create organization membership as regular member
member = OrganizationMember(
user_id=test_user.id,
organization_id=test_organization.id,
role=OrganizationRole.MEMBER,
)
member.save()
# Login to get token
login_response = client.post(
"/api/v1/auth/login",
json={
"email": test_user.email,
"password": test_user._test_password,
},
)
token = login_response.get_json()["data"]["token"]
# Try to create provider config (should fail)
create_response = client.post(
"/api/v1/auth/external/google/config",
json={
"client_id": "client-id",
"client_secret": "client-secret",
},
headers={"Authorization": f"Bearer {token}"},
)
assert create_response.status_code == 403
assert create_response.get_json()["error_type"] == "FORBIDDEN"
def test_unsupported_provider_error(
self, app, db, client, test_user, test_organization
):
"""Test error handling for unsupported provider."""
with app.app_context():
# Create organization membership
member = OrganizationMember(
user_id=test_user.id,
organization_id=test_organization.id,
role=OrganizationRole.MEMBER,
)
member.save()
# Login to get token
login_response = client.post(
"/api/v1/auth/login",
json={
"email": test_user.email,
"password": test_user._test_password,
},
)
token = login_response.get_json()["data"]["token"]
# Try to link with unsupported provider
link_response = client.post(
"/api/v1/auth/external/unsupported/link",
json={},
headers={"Authorization": f"Bearer {token}"},
)
assert link_response.status_code == 400
link_data = link_response.get_json()
assert link_data["error_type"] == "UNSUPPORTED_PROVIDER"
@pytest.mark.integration
class TestExternalAuthAuditLogging:
"""Integration tests for audit logging in external auth flows."""
@patch('gatehouse_app.services.audit_service.AuditService')
def test_audit_log_on_link_initiated(
self, mock_audit, app, db, client, test_user, test_organization
):
"""Test audit log is created when link flow is initiated."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Create organization membership
member = OrganizationMember(
user_id=test_user.id,
organization_id=test_organization.id,
role=OrganizationRole.MEMBER,
)
member.save()
# Login to get token
login_response = client.post(
"/api/v1/auth/login",
json={
"email": test_user.email,
"password": test_user._test_password,
},
)
token = login_response.get_json()["data"]["token"]
# Initiate link flow
link_response = client.post(
"/api/v1/auth/external/google/link",
json={},
headers={"Authorization": f"Bearer {token}"},
)
# Verify audit log was called
mock_audit.log_external_auth_link_initiated.assert_called_once()
@patch('gatehouse_app.services.audit_service.AuditService')
def test_audit_log_on_unlink(
self, mock_audit, app, db, client, test_user, test_organization
):
"""Test audit log is created when account is unlinked."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Create organization membership
member = OrganizationMember(
user_id=test_user.id,
organization_id=test_organization.id,
role=OrganizationRole.MEMBER,
)
member.save()
# Create password auth method
password_method = AuthenticationMethod(
user_id=test_user.id,
method_type=AuthMethodType.PASSWORD,
provider_user_id=test_user.id,
)
password_method.save()
# Create Google auth method
google_method = AuthenticationMethod(
user_id=test_user.id,
method_type=AuthMethodType.GOOGLE,
provider_user_id="google-123",
provider_data={"email": test_user.email},
verified=True,
)
google_method.save()
# Login to get token
login_response = client.post(
"/api/v1/auth/login",
json={
"email": test_user.email,
"password": test_user._test_password,
},
)
token = login_response.get_json()["data"]["token"]
# Unlink Google account
unlink_response = client.delete(
"/api/v1/auth/external/google/unlink",
headers={"Authorization": f"Bearer {token}"},
)
# Verify audit log was called
mock_audit.log_external_auth_unlink.assert_called_once()
@@ -0,0 +1,698 @@
"""Unit tests for ExternalAuthService."""
import pytest
from unittest.mock import Mock, patch, MagicMock
from datetime import datetime, timedelta, timezone
from gatehouse_app.services.external_auth_service import (
ExternalAuthService,
ExternalAuthError,
OAuthState,
ExternalProviderConfig,
)
from gatehouse_app.utils.constants import AuthMethodType
from gatehouse_app.models import User, AuthenticationMethod
@pytest.mark.unit
class TestExternalAuthService:
"""Tests for ExternalAuthService."""
def test_get_provider_config_success(self, app, db, test_organization):
"""Test getting provider configuration successfully."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
client_secret_encrypted="encrypted-secret",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Get config
result = ExternalAuthService.get_provider_config(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE,
)
assert result.id == config.id
assert result.client_id == "test-client-id"
assert result.is_active is True
def test_get_provider_config_not_configured(self, app, db, test_organization):
"""Test getting provider configuration when not configured."""
with app.app_context():
with pytest.raises(ExternalAuthError) as exc_info:
ExternalAuthService.get_provider_config(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE,
)
assert exc_info.value.error_type == "PROVIDER_NOT_CONFIGURED"
assert exc_info.value.status_code == 400
def test_get_provider_config_inactive(self, app, db, test_organization):
"""Test getting provider configuration when inactive."""
with app.app_context():
# Create inactive provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=False,
)
config.save()
with pytest.raises(ExternalAuthError) as exc_info:
ExternalAuthService.get_provider_config(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE,
)
assert exc_info.value.error_type == "PROVIDER_NOT_CONFIGURED"
@patch('gatehouse_app.services.external_auth_service.AuditService')
def test_initiate_link_flow_success(self, mock_audit, app, db, test_user, test_organization):
"""Test initiating account linking flow successfully."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Initiate link flow
auth_url, state = ExternalAuthService.initiate_link_flow(
user_id=test_user.id,
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
)
assert auth_url is not None
assert state is not None
assert len(state) == 43 # Base64 URL-safe token length
# Verify state was created
state_record = OAuthState.query.filter_by(state=state).first()
assert state_record is not None
assert state_record.flow_type == "link"
assert state_record.user_id == test_user.id
assert state_record.provider_type == AuthMethodType.GOOGLE.value
# Verify audit log
mock_audit.log_external_auth_link_initiated.assert_called_once()
def test_initiate_link_flow_invalid_redirect_uri(self, app, db, test_user, test_organization):
"""Test initiating link flow with invalid redirect URI."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
with pytest.raises(ExternalAuthError) as exc_info:
ExternalAuthService.initiate_link_flow(
user_id=test_user.id,
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
redirect_uri="http://malicious-site.com/callback",
)
assert exc_info.value.error_type == "INVALID_REDIRECT_URI"
@patch('gatehouse_app.services.external_auth_service.ExternalAuthService._exchange_code')
@patch('gatehouse_app.services.external_auth_service.ExternalAuthService._get_user_info')
@patch('gatehouse_app.services.external_auth_service.AuditService')
def test_complete_link_flow_success(
self, mock_audit, mock_get_user_info, mock_exchange_code,
app, db, test_user, test_organization
):
"""Test completing account linking flow successfully."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Create OAuth state
state = OAuthState.create_state(
flow_type="link",
provider_type=AuthMethodType.GOOGLE,
user_id=test_user.id,
organization_id=test_organization.id,
redirect_uri="http://localhost:3000/callback",
)
# Mock external provider responses
mock_exchange_code.return_value = {
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"id_token": "mock-id-token",
"expires_in": 3600,
}
mock_get_user_info.return_value = {
"provider_user_id": "google-123",
"email": "user@gmail.com",
"email_verified": True,
"name": "Test User",
"picture": "https://example.com/avatar.jpg",
"raw_data": {},
}
# Complete link flow
auth_method = ExternalAuthService.complete_link_flow(
provider_type=AuthMethodType.GOOGLE,
authorization_code="mock-auth-code",
state=state.state,
redirect_uri="http://localhost:3000/callback",
)
assert auth_method is not None
assert auth_method.user_id == test_user.id
assert auth_method.method_type == AuthMethodType.GOOGLE
assert auth_method.provider_user_id == "google-123"
# Verify state is marked as used
state_record = OAuthState.query.get(state.id)
assert state_record.used is True
# Verify audit log
mock_audit.log_external_auth_link_completed.assert_called_once()
def test_complete_link_flow_invalid_state(self, app, db):
"""Test completing link flow with invalid state."""
with app.app_context():
with pytest.raises(ExternalAuthError) as exc_info:
ExternalAuthService.complete_link_flow(
provider_type=AuthMethodType.GOOGLE,
authorization_code="mock-auth-code",
state="invalid-state",
redirect_uri="http://localhost:3000/callback",
)
assert exc_info.value.error_type == "INVALID_STATE"
def test_complete_link_flow_wrong_flow_type(self, app, db, test_organization):
"""Test completing link flow with wrong flow type state."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Create login flow state instead of link
state = OAuthState.create_state(
flow_type="login",
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
redirect_uri="http://localhost:3000/callback",
)
with pytest.raises(ExternalAuthError) as exc_info:
ExternalAuthService.complete_link_flow(
provider_type=AuthMethodType.GOOGLE,
authorization_code="mock-auth-code",
state=state.state,
redirect_uri="http://localhost:3000/callback",
)
assert exc_info.value.error_type == "INVALID_FLOW_TYPE"
def test_complete_link_flow_provider_mismatch(self, app, db, test_organization):
"""Test completing link flow with provider mismatch."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Create state with different provider
state = OAuthState.create_state(
flow_type="link",
provider_type=AuthMethodType.GITHUB,
organization_id=test_organization.id,
redirect_uri="http://localhost:3000/callback",
)
with pytest.raises(ExternalAuthError) as exc_info:
ExternalAuthService.complete_link_flow(
provider_type=AuthMethodType.GOOGLE,
authorization_code="mock-auth-code",
state=state.state,
redirect_uri="http://localhost:3000/callback",
)
assert exc_info.value.error_type == "PROVIDER_MISMATCH"
@patch('gatehouse_app.services.external_auth_service.ExternalAuthService._exchange_code')
@patch('gatehouse_app.services.external_auth_service.ExternalAuthService._get_user_info')
@patch('gatehouse_app.services.external_auth_service.AuditService')
def test_authenticate_with_provider_success(
self, mock_audit, mock_get_user_info, mock_exchange_code,
app, db, test_user, test_organization
):
"""Test authenticating with provider successfully."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Create authentication method for user
auth_method = AuthenticationMethod(
user_id=test_user.id,
method_type=AuthMethodType.GOOGLE,
provider_user_id="google-123",
provider_data={"email": test_user.email},
verified=True,
)
auth_method.save()
# Create OAuth state
state = OAuthState.create_state(
flow_type="login",
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
redirect_uri="http://localhost:3000/callback",
)
# Mock external provider responses
mock_exchange_code.return_value = {
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"id_token": "mock-id-token",
"expires_in": 3600,
}
mock_get_user_info.return_value = {
"provider_user_id": "google-123",
"email": test_user.email,
"email_verified": True,
"name": "Test User",
"picture": "https://example.com/avatar.jpg",
"raw_data": {},
}
# Authenticate
user, session_data = ExternalAuthService.authenticate_with_provider(
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
authorization_code="mock-auth-code",
state=state.state,
redirect_uri="http://localhost:3000/callback",
)
assert user.id == test_user.id
assert session_data is not None
assert "token" in session_data
@patch('gatehouse_app.services.external_auth_service.ExternalAuthService._exchange_code')
@patch('gatehouse_app.services.external_auth_service.ExternalAuthService._get_user_info')
@patch('gatehouse_app.services.external_auth_service.AuditService')
def test_authenticate_with_provider_account_not_found(
self, mock_audit, mock_get_user_info, mock_exchange_code,
app, db, test_organization
):
"""Test authenticating with provider when account not found."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Create OAuth state
state = OAuthState.create_state(
flow_type="login",
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
redirect_uri="http://localhost:3000/callback",
)
# Mock external provider responses
mock_exchange_code.return_value = {
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"id_token": "mock-id-token",
"expires_in": 3600,
}
mock_get_user_info.return_value = {
"provider_user_id": "google-456",
"email": "newuser@gmail.com",
"email_verified": True,
"name": "New User",
"picture": "https://example.com/avatar.jpg",
"raw_data": {},
}
with pytest.raises(ExternalAuthError) as exc_info:
ExternalAuthService.authenticate_with_provider(
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
authorization_code="mock-auth-code",
state=state.state,
redirect_uri="http://localhost:3000/callback",
)
assert exc_info.value.error_type == "ACCOUNT_NOT_FOUND"
@patch('gatehouse_app.services.external_auth_service.AuditService')
def test_unlink_provider_success(self, mock_audit, app, db, test_user):
"""Test unlinking provider successfully."""
with app.app_context():
# Create password auth method first (so user has other methods)
password_method = AuthenticationMethod(
user_id=test_user.id,
method_type=AuthMethodType.PASSWORD,
provider_user_id=test_user.id,
)
password_method.save()
# Create Google auth method
google_method = AuthenticationMethod(
user_id=test_user.id,
method_type=AuthMethodType.GOOGLE,
provider_user_id="google-123",
provider_data={"email": test_user.email},
verified=True,
)
google_method.save()
# Unlink Google
result = ExternalAuthService.unlink_provider(
user_id=test_user.id,
provider_type=AuthMethodType.GOOGLE,
)
assert result is True
# Verify auth method is deleted
method = AuthenticationMethod.query.filter_by(
user_id=test_user.id,
method_type=AuthMethodType.GOOGLE,
).first()
assert method is None
# Verify audit log
mock_audit.log_external_auth_unlink.assert_called_once()
def test_unlink_provider_not_linked(self, app, db, test_user):
"""Test unlinking provider that is not linked."""
with app.app_context():
with pytest.raises(ExternalAuthError) as exc_info:
ExternalAuthService.unlink_provider(
user_id=test_user.id,
provider_type=AuthMethodType.GOOGLE,
)
assert exc_info.value.error_type == "PROVIDER_NOT_LINKED"
def test_unlink_provider_last_method(self, app, db, test_user):
"""Test unlinking last authentication method."""
with app.app_context():
# Create only Google auth method
google_method = AuthenticationMethod(
user_id=test_user.id,
method_type=AuthMethodType.GOOGLE,
provider_user_id="google-123",
provider_data={"email": test_user.email},
verified=True,
)
google_method.save()
with pytest.raises(ExternalAuthError) as exc_info:
ExternalAuthService.unlink_provider(
user_id=test_user.id,
provider_type=AuthMethodType.GOOGLE,
)
assert exc_info.value.error_type == "CANNOT_UNLINK_LAST"
def test_get_linked_accounts(self, app, db, test_user):
"""Test getting linked accounts for user."""
with app.app_context():
# Create Google auth method
google_method = AuthenticationMethod(
user_id=test_user.id,
method_type=AuthMethodType.GOOGLE,
provider_user_id="google-123",
provider_data={
"email": test_user.email,
"name": "Test User",
"picture": "https://example.com/avatar.jpg",
},
verified=True,
)
google_method.save()
# Create GitHub auth method
github_method = AuthenticationMethod(
user_id=test_user.id,
method_type=AuthMethodType.GITHUB,
provider_user_id="github-456",
provider_data={
"email": "user@github.com",
"name": "Test User",
},
verified=True,
)
github_method.save()
# Get linked accounts
accounts = ExternalAuthService.get_linked_accounts(test_user.id)
assert len(accounts) == 2
google_account = next(a for a in accounts if a["provider_type"] == "google")
assert google_account["provider_user_id"] == "google-123"
assert google_account["email"] == test_user.email
github_account = next(a for a in accounts if a["provider_type"] == "github")
assert github_account["provider_user_id"] == "github-456"
@pytest.mark.unit
class TestOAuthState:
"""Tests for OAuthState model."""
def test_create_state(self, app, db):
"""Test creating OAuth state."""
with app.app_context():
state = OAuthState.create_state(
flow_type="login",
provider_type=AuthMethodType.GOOGLE,
user_id="user-123",
organization_id="org-456",
redirect_uri="http://localhost:3000/callback",
)
assert state.state is not None
assert len(state.state) == 43
assert state.flow_type == "login"
assert state.provider_type == AuthMethodType.GOOGLE.value
assert state.user_id == "user-123"
assert state.organization_id == "org-456"
assert state.redirect_uri == "http://localhost:3000/callback"
assert state.used is False
assert state.expires_at > datetime.now(timezone.utc)
def test_is_valid(self, app, db):
"""Test OAuth state validity check."""
with app.app_context():
# Create valid state
state = OAuthState.create_state(
flow_type="login",
provider_type=AuthMethodType.GOOGLE,
)
assert state.is_valid() is True
# Mark as used
state.mark_used()
assert state.is_valid() is False
def test_is_valid_expired(self, app, db):
"""Test OAuth state validity with expiration."""
with app.app_context():
# Create expired state
state = OAuthState.create_state(
flow_type="login",
provider_type=AuthMethodType.GOOGLE,
lifetime_seconds=-1, # Already expired
)
assert state.is_valid() is False
@pytest.mark.unit
class TestExternalProviderConfig:
"""Tests for ExternalProviderConfig model."""
def test_is_redirect_uri_allowed(self, app, db, test_organization):
"""Test redirect URI validation."""
with app.app_context():
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=[
"http://localhost:3000/callback",
"https://myapp.com/callback",
],
is_active=True,
)
config.save()
assert config.is_redirect_uri_allowed("http://localhost:3000/callback") is True
assert config.is_redirect_uri_allowed("https://myapp.com/callback") is True
assert config.is_redirect_uri_allowed("http://malicious.com/callback") is False
def test_to_dict(self, app, db, test_organization):
"""Test converting config to dictionary."""
with app.app_context():
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
result = config.to_dict()
assert result["organization_id"] == test_organization.id
assert result["provider_type"] == AuthMethodType.GOOGLE.value
assert result["client_id"] == "test-client-id"
assert "client_secret" not in result
assert result["is_active"] is True
def test_to_dict_include_secrets(self, app, db, test_organization):
"""Test converting config to dictionary with secrets."""
with app.app_context():
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
client_secret_encrypted="encrypted-secret",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
result = config.to_dict(include_secrets=True)
assert "client_secret" in result
@pytest.mark.unit
class TestExternalAuthError:
"""Tests for ExternalAuthError exception."""
def test_error_creation(self):
"""Test creating ExternalAuthError."""
error = ExternalAuthError(
message="Test error message",
error_type="TEST_ERROR",
status_code=400,
)
assert error.message == "Test error message"
assert error.error_type == "TEST_ERROR"
assert error.status_code == 400
def test_error_default_status_code(self):
"""Test ExternalAuthError with default status code."""
error = ExternalAuthError(
message="Test error message",
error_type="TEST_ERROR",
)
assert error.status_code == 400
@@ -0,0 +1,533 @@
"""Unit tests for OAuthFlowService."""
import pytest
from unittest.mock import Mock, patch, MagicMock
from datetime import datetime, timedelta, timezone
from gatehouse_app.services.oauth_flow_service import (
OAuthFlowService,
OAuthFlowError,
)
from gatehouse_app.services.external_auth_service import OAuthState, ExternalProviderConfig
from gatehouse_app.utils.constants import AuthMethodType
from gatehouse_app.models import User, AuthenticationMethod
@pytest.mark.unit
class TestOAuthFlowService:
"""Tests for OAuthFlowService."""
@patch('gatehouse_app.services.oauth_flow_service.AuditService')
def test_initiate_login_flow_success(self, mock_audit, app, db, test_organization):
"""Test initiating login flow successfully."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
with app.test_request_context():
auth_url, state = OAuthFlowService.initiate_login_flow(
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
)
assert auth_url is not None
assert state is not None
assert len(state) == 43
# Verify state was created with correct flow type
state_record = OAuthState.query.filter_by(state=state).first()
assert state_record is not None
assert state_record.flow_type == "login"
assert state_record.organization_id == test_organization.id
def test_initiate_login_flow_invalid_redirect_uri(self, app, db, test_organization):
"""Test initiating login flow with invalid redirect URI."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
with app.test_request_context():
with pytest.raises(OAuthFlowError) as exc_info:
OAuthFlowService.initiate_login_flow(
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
redirect_uri="http://malicious.com/callback",
)
assert exc_info.value.error_type == "INVALID_REDIRECT_URI"
@patch('gatehouse_app.services.oauth_flow_service.AuditService')
def test_initiate_register_flow_success(self, mock_audit, app, db, test_organization):
"""Test initiating register flow successfully."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
with app.test_request_context():
auth_url, state = OAuthFlowService.initiate_register_flow(
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
)
assert auth_url is not None
assert state is not None
# Verify state was created with correct flow type
state_record = OAuthState.query.filter_by(state=state).first()
assert state_record is not None
assert state_record.flow_type == "register"
@patch('gatehouse_app.services.oauth_flow_service.ExternalAuthService.authenticate_with_provider')
@patch('gatehouse_app.services.oauth_flow_service.AuditService')
def test_handle_callback_login_flow(
self, mock_audit, mock_authenticate,
app, db, test_user, test_organization
):
"""Test handling callback for login flow."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Create authentication method
auth_method = AuthenticationMethod(
user_id=test_user.id,
method_type=AuthMethodType.GOOGLE,
provider_user_id="google-123",
provider_data={"email": test_user.email},
verified=True,
)
auth_method.save()
# Create login state
state = OAuthState.create_state(
flow_type="login",
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
redirect_uri="http://localhost:3000/callback",
)
# Mock authentication
mock_authenticate.return_value = (test_user, {"token": "session-token", "expires_in": 86400})
with app.test_request_context():
result = OAuthFlowService.handle_callback(
provider_type=AuthMethodType.GOOGLE,
authorization_code="mock-auth-code",
state=state.state,
redirect_uri="http://localhost:3000/callback",
)
assert result["success"] is True
assert result["flow_type"] == "login"
assert result["user"]["id"] == test_user.id
assert result["session"]["token"] == "session-token"
@patch('gatehouse_app.services.oauth_flow_service.ExternalAuthService.complete_link_flow')
@patch('gatehouse_app.services.oauth_flow_service.AuditService')
def test_handle_callback_link_flow(
self, mock_audit, mock_complete_link,
app, db, test_user, test_organization
):
"""Test handling callback for link flow."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Create link state
state = OAuthState.create_state(
flow_type="link",
provider_type=AuthMethodType.GOOGLE,
user_id=test_user.id,
organization_id=test_organization.id,
redirect_uri="http://localhost:3000/callback",
)
# Mock complete link
mock_auth_method = Mock()
mock_auth_method.id = "auth-method-123"
mock_auth_method.provider_user_id = "google-123"
mock_auth_method.verified = True
mock_complete_link.return_value = mock_auth_method
with app.test_request_context():
result = OAuthFlowService.handle_callback(
provider_type=AuthMethodType.GOOGLE,
authorization_code="mock-auth-code",
state=state.state,
redirect_uri="http://localhost:3000/callback",
)
assert result["success"] is True
assert result["flow_type"] == "link"
assert result["linked_account"]["id"] == "auth-method-123"
@patch('gatehouse_app.services.oauth_flow_service.ExternalAuthService._exchange_code')
@patch('gatehouse_app.services.oauth_flow_service.ExternalAuthService._get_user_info')
@patch('gatehouse_app.services.oauth_flow_service.ExternalAuthService._encrypt_provider_data')
@patch('gatehouse_app.services.oauth_flow_service.AuditService')
@patch('gatehouse_app.services.auth_service.AuthService.create_session')
def test_handle_callback_register_flow(
self, mock_create_session, mock_audit, mock_encrypt,
mock_get_user_info, mock_exchange_code,
app, db, test_organization
):
"""Test handling callback for register flow."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Create register state
state = OAuthState.create_state(
flow_type="register",
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
redirect_uri="http://localhost:3000/callback",
)
# Mock external provider responses
mock_exchange_code.return_value = {
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"id_token": "mock-id-token",
"expires_in": 3600,
}
mock_get_user_info.return_value = {
"provider_user_id": "google-new-123",
"email": "newuser@gmail.com",
"email_verified": True,
"name": "New User",
"picture": "https://example.com/avatar.jpg",
"raw_data": {},
}
mock_encrypt.return_value = {
"access_token": "mock-access-token",
"email": "newuser@gmail.com",
"name": "New User",
}
mock_session = Mock()
mock_session.to_dict.return_value = {"token": "session-token", "expires_in": 86400}
mock_create_session.return_value = mock_session
with app.test_request_context():
result = OAuthFlowService.handle_callback(
provider_type=AuthMethodType.GOOGLE,
authorization_code="mock-auth-code",
state=state.state,
redirect_uri="http://localhost:3000/callback",
)
assert result["success"] is True
assert result["flow_type"] == "register"
assert result["user"]["email"] == "newuser@gmail.com"
assert result["session"]["token"] == "session-token"
@patch('gatehouse_app.services.oauth_flow_service.ExternalAuthService._exchange_code')
@patch('gatehouse_app.services.oauth_flow_service.ExternalAuthService._get_user_info')
@patch('gatehouse_app.services.oauth_flow_service.AuditService')
def test_handle_callback_register_flow_email_exists(
self, mock_audit, mock_get_user_info, mock_exchange_code,
app, db, test_user, test_organization
):
"""Test handling callback for register flow when email already exists."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Create register state
state = OAuthState.create_state(
flow_type="register",
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
redirect_uri="http://localhost:3000/callback",
)
# Mock external provider responses
mock_exchange_code.return_value = {
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"id_token": "mock-id-token",
"expires_in": 3600,
}
# Return email that matches existing user
mock_get_user_info.return_value = {
"provider_user_id": "google-new-123",
"email": test_user.email, # Existing email
"email_verified": True,
"name": "Test User",
"picture": "https://example.com/avatar.jpg",
"raw_data": {},
}
with app.test_request_context():
with pytest.raises(OAuthFlowError) as exc_info:
OAuthFlowService.handle_callback(
provider_type=AuthMethodType.GOOGLE,
authorization_code="mock-auth-code",
state=state.state,
redirect_uri="http://localhost:3000/callback",
)
assert exc_info.value.error_type == "EMAIL_EXISTS"
def test_handle_callback_invalid_state(self, app, db):
"""Test handling callback with invalid state."""
with app.app_context():
with app.test_request_context():
with pytest.raises(OAuthFlowError) as exc_info:
OAuthFlowService.handle_callback(
provider_type=AuthMethodType.GOOGLE,
authorization_code="mock-auth-code",
state="invalid-state",
)
assert exc_info.value.error_type == "INVALID_STATE"
def test_handle_callback_provider_error(self, app, db):
"""Test handling callback with provider error."""
with app.app_context():
with app.test_request_context():
with pytest.raises(OAuthFlowError) as exc_info:
OAuthFlowService.handle_callback(
provider_type=AuthMethodType.GOOGLE,
authorization_code=None,
state=None,
error="access_denied",
error_description="User denied access",
)
assert exc_info.value.error_type == "ACCESS_DENIED"
def test_handle_callback_unknown_flow_type(self, app, db, test_organization):
"""Test handling callback with unknown flow type."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Create state with unknown flow type
state = OAuthState.create_state(
flow_type="unknown",
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
redirect_uri="http://localhost:3000/callback",
)
with app.test_request_context():
with pytest.raises(OAuthFlowError) as exc_info:
OAuthFlowService.handle_callback(
provider_type=AuthMethodType.GOOGLE,
authorization_code="mock-auth-code",
state=state.state,
)
assert exc_info.value.error_type == "INVALID_FLOW_TYPE"
def test_validate_state_valid(self, app, db, test_organization):
"""Test validating a valid state."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Create state
state = OAuthState.create_state(
flow_type="login",
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
)
result = OAuthFlowService.validate_state(state.state)
assert result is not None
assert result.id == state.id
def test_validate_state_invalid(self, app, db):
"""Test validating an invalid state."""
with app.app_context():
result = OAuthFlowService.validate_state("nonexistent-state")
assert result is None
def test_validate_state_expired(self, app, db, test_organization):
"""Test validating an expired state."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Create expired state
state = OAuthState.create_state(
flow_type="login",
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
lifetime_seconds=-1,
)
result = OAuthFlowService.validate_state(state.state)
assert result is None
def test_validate_state_used(self, app, db, test_organization):
"""Test validating a used state."""
with app.app_context():
# Create provider config
config = ExternalProviderConfig(
organization_id=test_organization.id,
provider_type=AuthMethodType.GOOGLE.value,
client_id="test-client-id",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://www.googleapis.com/oauth2/v3/userinfo",
scopes=["openid", "profile", "email"],
redirect_uris=["http://localhost:3000/callback"],
is_active=True,
)
config.save()
# Create and mark state as used
state = OAuthState.create_state(
flow_type="login",
provider_type=AuthMethodType.GOOGLE,
organization_id=test_organization.id,
)
state.mark_used()
result = OAuthFlowService.validate_state(state.state)
assert result is None
@pytest.mark.unit
class TestOAuthFlowError:
"""Tests for OAuthFlowError exception."""
def test_error_creation(self):
"""Test creating OAuthFlowError."""
error = OAuthFlowError(
message="Test error message",
error_type="TEST_ERROR",
status_code=400,
)
assert error.message == "Test error message"
assert error.error_type == "TEST_ERROR"
assert error.status_code == 400
def test_error_default_status_code(self):
"""Test OAuthFlowError with default status code."""
error = OAuthFlowError(
message="Test error message",
error_type="TEST_ERROR",
)
assert error.status_code == 400