move app to gatehouse-app

This commit is contained in:
2026-01-15 03:40:29 +10:30
parent 5e4cffcf73
commit 2c0aaf484b
69 changed files with 1569 additions and 294 deletions
-6
View File
@@ -1,6 +0,0 @@
"""Middleware package."""
from app.middleware.request_id import RequestIDMiddleware
from app.middleware.security_headers import SecurityHeadersMiddleware
from app.middleware.cors import setup_cors
__all__ = ["RequestIDMiddleware", "SecurityHeadersMiddleware", "setup_cors"]
-30
View File
@@ -1,30 +0,0 @@
"""Models package."""
from app.models.base import BaseModel
from app.models.user import User
from app.models.organization import Organization
from app.models.organization_member import OrganizationMember
from app.models.authentication_method import AuthenticationMethod
from app.models.session import Session
from app.models.audit_log import AuditLog
from app.models.oidc_client import OIDCClient
from app.models.oidc_authorization_code import OIDCAuthCode
from app.models.oidc_refresh_token import OIDCRefreshToken
from app.models.oidc_session import OIDCSession
from app.models.oidc_token_metadata import OIDCTokenMetadata
from app.models.oidc_audit_log import OIDCAuditLog
__all__ = [
"BaseModel",
"User",
"Organization",
"OrganizationMember",
"AuthenticationMethod",
"Session",
"AuditLog",
"OIDCClient",
"OIDCAuthCode",
"OIDCRefreshToken",
"OIDCSession",
"OIDCTokenMetadata",
"OIDCAuditLog",
]
-25
View File
@@ -1,25 +0,0 @@
"""Services package."""
from app.services.auth_service import AuthService
from app.services.user_service import UserService
from app.services.organization_service import OrganizationService
from app.services.session_service import SessionService
from app.services.audit_service import AuditService
from app.services.oidc_service import OIDCService, OIDCError
from app.services.oidc_jwks_service import OIDCJWKSService
from app.services.oidc_token_service import OIDCTokenService
from app.services.oidc_session_service import OIDCSessionService
from app.services.oidc_audit_service import OIDCAuditService
__all__ = [
"AuthService",
"UserService",
"OrganizationService",
"SessionService",
"AuditService",
"OIDCService",
"OIDCError",
"OIDCJWKSService",
"OIDCTokenService",
"OIDCSessionService",
"OIDCAuditService",
]
+1 -1
View File
@@ -1454,7 +1454,7 @@ for key in jwks["keys"]:
```bash
# Test database connection
export DATABASE_URL="postgresql://user:pass@localhost:5432/authy2"
python -c "from app import create_app; app = create_app(); app.test_request_context().push()"
python -c "create_app create_app; app = create_app(); app.test_request_context().push()"
```
#### Migration Issues
+22 -20
View File
@@ -8,11 +8,12 @@ _root_logger.debug("[TEST] Debug logging is working!")
from flask import Flask
from config import get_config
from app.extensions import db, migrate, bcrypt, ma, limiter, session
from app.middleware import RequestIDMiddleware, SecurityHeadersMiddleware, setup_cors
from app.exceptions.base import BaseAPIException
from app.utils.response import api_response
from app.services.oidc_jwks_service import OIDCJWKSService
from gatehouse_app.extensions import db, migrate, bcrypt, ma, limiter
from gatehouse_app.extensions import session as flask_session
from gatehouse_app.middleware import RequestIDMiddleware, SecurityHeadersMiddleware, setup_cors
from gatehouse_app.exceptions.base import BaseAPIException
from gatehouse_app.utils.response import api_response
from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService
import redis
# Configure SQLAlchemy logging BEFORE any database operations
@@ -35,31 +36,31 @@ def create_app(config_name=None):
Returns:
Flask application instance
"""
app = Flask(__name__)
flask_app = Flask(__name__)
# Load configuration
config = get_config(config_name)
app.config.from_object(config)
flask_app.config.from_object(config)
# Initialize extensions
initialize_extensions(app)
initialize_extensions(flask_app)
# Setup middleware
setup_middleware(app)
setup_middleware(flask_app)
# Register blueprints
register_blueprints(app)
register_blueprints(flask_app)
# Register error handlers
register_error_handlers(app)
register_error_handlers(flask_app)
# Setup logging
setup_logging(app)
setup_logging(flask_app)
# Initialize OIDC JWKS service with a signing key
initialize_oidc_jwks(app)
initialize_oidc_jwks(flask_app)
return app
return flask_app
def initialize_extensions(app):
@@ -86,13 +87,14 @@ def initialize_extensions(app):
try:
redis_url = app.config.get("REDIS_URL")
if redis_url:
redis_client = redis.from_url(redis_url)
app.config["SESSION_REDIS"] = redis_client
import gatehouse_app.extensions
gatehouse_app.extensions.redis_client = redis.from_url(redis_url)
app.config["SESSION_REDIS"] = gatehouse_app.extensions.redis_client
except Exception as e:
app.logger.warning(f"Redis connection failed: {e}")
logging.warning(f"Redis connection failed: {e}")
# Flask-Session
session.init_app(app)
flask_session.init_app(app)
def setup_middleware(app):
@@ -104,8 +106,8 @@ def setup_middleware(app):
def register_blueprints(app):
"""Register application blueprints."""
from app.api import register_api_blueprints
from app.api.oidc import oidc_bp
from gatehouse_app.api import register_api_blueprints
from gatehouse_app.api.oidc import oidc_bp
register_api_blueprints(app)
@@ -1,6 +1,6 @@
"""API package."""
from flask import Blueprint
from app.utils.response import api_response
from gatehouse_app.utils.response import api_response
# Create main API blueprint
api_bp = Blueprint("api", __name__)
@@ -17,7 +17,7 @@ def health_check():
def register_api_blueprints(app):
"""Register all API blueprints."""
from app.api.v1 import api_v1_bp
from gatehouse_app.api.v1 import api_v1_bp
# Register versioned API blueprints
app.register_blueprint(api_bp, url_prefix="/api")
@@ -11,16 +11,16 @@ from flask import Blueprint, request, redirect, jsonify, session, g, current_app
logger = logging.getLogger(__name__)
from app.utils.response import api_response
from app.services.oidc_service import (
from gatehouse_app.utils.response import api_response
from gatehouse_app.services.oidc_service import (
OIDCService, InvalidClientError, InvalidGrantError, InvalidRequestError
)
from app.services.auth_service import AuthService
from app.extensions import db
from app.extensions import bcrypt as flask_bcrypt
from app.models import User, OIDCClient
from app.models.organization import Organization
from app.exceptions.auth_exceptions import InvalidCredentialsError
from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.extensions import db
from gatehouse_app.extensions import bcrypt as flask_bcrypt
from gatehouse_app.models import User, OIDCClient
from gatehouse_app.models.organization import Organization
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
# Create OIDC blueprint registered at root level
@@ -5,4 +5,4 @@ from flask import Blueprint
api_v1_bp = Blueprint("api_v1", __name__)
# Import route modules to register them
from app.api.v1 import auth, users, organizations
from gatehouse_app.api.v1 import auth, users, organizations
@@ -1,9 +1,10 @@
"""Authentication endpoints."""
from flask import request, session, g
import json
from flask import request, session, g, jsonify
from marshmallow import ValidationError
from app.api.v1 import api_v1_bp
from app.utils.response import api_response
from app.schemas.auth_schema import (
from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.utils.response import api_response
from gatehouse_app.schemas.auth_schema import (
RegisterSchema,
LoginSchema,
TOTPVerifyEnrollmentSchema,
@@ -11,12 +12,20 @@ from app.schemas.auth_schema import (
TOTPDisableSchema,
TOTPRegenerateBackupCodesSchema,
)
from app.services.auth_service import AuthService
from app.services.user_service import UserService
from app.utils.decorators import login_required
from app.utils.constants import AuditAction
from app.exceptions.auth_exceptions import InvalidCredentialsError
from app.exceptions.validation_exceptions import ConflictError
from gatehouse_app.schemas.webauthn_schema import (
WebAuthnRegistrationBeginSchema,
WebAuthnRegistrationCompleteSchema,
WebAuthnLoginBeginSchema,
WebAuthnLoginCompleteSchema,
WebAuthnCredentialRenameSchema,
)
from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.services.webauthn_service import WebAuthnService
from gatehouse_app.services.user_service import UserService
from gatehouse_app.utils.decorators import login_required
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
from gatehouse_app.exceptions.validation_exceptions import ConflictError, NotFoundError
@api_v1_bp.route("/auth/register", methods=["POST"])
@@ -188,7 +197,7 @@ def get_user_sessions():
200: List of active sessions
401: Not authenticated
"""
from app.services.session_service import SessionService
from gatehouse_app.services.session_service import SessionService
sessions = SessionService.get_user_sessions(g.current_user.id, active_only=True)
@@ -215,7 +224,7 @@ def revoke_session(session_id):
401: Not authenticated
404: Session not found
"""
from app.models.session import Session
from gatehouse_app.models.session import Session
# Ensure session belongs to current user
user_session = Session.query.filter_by(
@@ -347,7 +356,7 @@ def verify_totp():
)
# Get user from database
from app.models.user import User
from gatehouse_app.models.user import User
user = User.query.get(user_id)
if not user:
return api_response(
@@ -527,3 +536,402 @@ def regenerate_totp_backup_codes():
status=e.status_code,
error_type=e.error_type,
)
# =============================================================================
# WebAuthn Passkey Endpoints
# =============================================================================
@api_v1_bp.route("/auth/webauthn/register/begin", methods=["POST"])
@login_required
def begin_webauthn_registration():
"""
Begin WebAuthn passkey registration.
Returns:
200: PublicKeyCredentialCreationOptions (raw JSON, no wrapper)
401: Not authenticated
"""
user = g.current_user
# Generate registration challenge
options = WebAuthnService.generate_registration_challenge(user)
# Return unwrapped JSON for WebAuthn
return jsonify(options), 200
@api_v1_bp.route("/auth/webauthn/register/complete", methods=["POST"])
@login_required
def complete_webauthn_registration():
"""
Complete WebAuthn passkey registration.
Request body:
id: Credential ID
rawId: Base64URL-encoded credential ID
type: "public-key"
response: Attestation response data
transports: List of transport types
Returns:
200: Registration successful
400: Validation error
401: Not authenticated
409: Credential already exists
"""
try:
# Validate request data
schema = WebAuthnRegistrationCompleteSchema()
data = schema.load(request.json)
# Extract challenge from client data
client_data = data.get("response", {}).get("clientDataJSON", "")
import base64
client_data_json = base64.urlsafe_b64decode(client_data + "==")
client_data_dict = json.loads(client_data_json)
challenge = client_data_dict.get("challenge")
if not challenge:
return api_response(
success=False,
message="Invalid challenge in client data",
status=400,
error_type="VALIDATION_ERROR",
)
# Verify registration response
auth_method = WebAuthnService.verify_registration_response(
g.current_user,
data,
challenge
)
return api_response(
data={
"credential": auth_method.to_webauthn_dict(),
},
message="Passkey registered successfully",
status=201,
)
except ValidationError as e:
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
except InvalidCredentialsError as e:
return api_response(
success=False,
message=e.message,
status=e.status_code,
error_type=e.error_type,
)
@api_v1_bp.route("/auth/webauthn/login/begin", methods=["POST"])
def begin_webauthn_login():
"""
Begin WebAuthn passkey login.
Request body:
email: User email address
Returns:
200: PublicKeyCredentialRequestOptions (raw JSON, no wrapper)
400: Validation error
404: User not found
"""
try:
# Validate request data
schema = WebAuthnLoginBeginSchema()
data = schema.load(request.json)
# Find user by email
from gatehouse_app.models.user import User
user = User.query.filter_by(
email=data["email"].lower(),
deleted_at=None
).first()
if not user:
return api_response(
success=False,
message="User not found",
status=404,
error_type="NOT_FOUND",
)
# Check if user has any WebAuthn credentials
if not user.has_webauthn_enabled():
return api_response(
success=False,
message="No passkeys found for this account",
status=404,
error_type="NOT_FOUND",
)
# Generate authentication challenge
options = WebAuthnService.generate_authentication_challenge(user)
# Store user_id in session for verification
session["webauthn_pending_user_id"] = user.id
# Return unwrapped JSON for WebAuthn
return jsonify(options), 200
except ValidationError as e:
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
@api_v1_bp.route("/auth/webauthn/login/complete", methods=["POST"])
def complete_webauthn_login():
"""
Complete WebAuthn passkey login.
Request body:
id: Credential ID
rawId: Base64URL-encoded credential ID
type: "public-key"
response: Assertion response data
Returns:
200: Login successful with session token
400: Validation error
401: Authentication failed
"""
try:
# Get user from session
user_id = session.get("webauthn_pending_user_id")
if not user_id:
return api_response(
success=False,
message="No pending WebAuthn verification. Please initiate login first.",
status=401,
error_type="AUTHENTICATION_ERROR",
)
# Validate request data
schema = WebAuthnLoginCompleteSchema()
data = schema.load(request.json)
# Get user from database
from gatehouse_app.models.user import User
user = User.query.get(user_id)
if not user:
return api_response(
success=False,
message="User not found",
status=401,
error_type="AUTHENTICATION_ERROR",
)
# Extract challenge from client data
client_data = data.get("response", {}).get("clientDataJSON", "")
import base64
client_data_json = base64.urlsafe_b64decode(client_data + "==")
client_data_dict = json.loads(client_data_json)
challenge = client_data_dict.get("challenge")
if not challenge:
return api_response(
success=False,
message="Invalid challenge in client data",
status=400,
error_type="VALIDATION_ERROR",
)
# Verify authentication response
WebAuthnService.verify_authentication_response(
user,
data,
challenge
)
# Create session
user_session = AuthService.create_session(user)
# Clear pending session
session.pop("webauthn_pending_user_id", None)
return api_response(
data={
"user": user.to_dict(),
"token": user_session.token,
"expires_at": user_session.expires_at.isoformat() + "Z"
if user_session.expires_at.isoformat()[-1] != "Z"
else user_session.expires_at.isoformat(),
},
message="Login successful",
)
except ValidationError as e:
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
except InvalidCredentialsError as e:
return api_response(
success=False,
message=e.message,
status=e.status_code,
error_type=e.error_type,
)
@api_v1_bp.route("/auth/webauthn/credentials", methods=["GET"])
@login_required
def list_webauthn_credentials():
"""
List all WebAuthn passkey credentials for the current user.
Returns:
200: List of credentials
401: Not authenticated
"""
user = g.current_user
credentials = WebAuthnService.get_user_credentials(user)
return api_response(
data={
"credentials": [cred.to_webauthn_dict() for cred in credentials],
"count": len(credentials),
},
message="Credentials retrieved successfully",
)
@api_v1_bp.route("/auth/webauthn/credentials/<credential_id>", methods=["DELETE"])
@login_required
def delete_webauthn_credential(credential_id):
"""
Delete a WebAuthn passkey credential.
Args:
credential_id: ID of the credential to delete
Returns:
200: Credential deleted successfully
401: Not authenticated
404: Credential not found
"""
user = g.current_user
# Check if this is the last credential
credential_count = user.get_webauthn_credential_count()
if credential_count <= 1:
return api_response(
success=False,
message="Cannot delete the last passkey. Add another passkey first.",
status=400,
error_type="BAD_REQUEST",
)
# Delete the credential
success = WebAuthnService.delete_credential(credential_id, user)
if not success:
return api_response(
success=False,
message="Credential not found",
status=404,
error_type="NOT_FOUND",
)
return api_response(
message="Passkey deleted successfully",
)
@api_v1_bp.route("/auth/webauthn/credentials/<credential_id>", methods=["PATCH"])
@login_required
def rename_webauthn_credential(credential_id):
"""
Rename a WebAuthn passkey credential.
Args:
credential_id: ID of the credential to rename
Request body:
name: New name for the credential
Returns:
200: Credential renamed successfully
400: Validation error
401: Not authenticated
404: Credential not found
"""
try:
# Validate request data
schema = WebAuthnCredentialRenameSchema()
data = schema.load(request.json)
# Rename the credential
success = WebAuthnService.rename_credential(
credential_id,
g.current_user,
data["name"]
)
if not success:
return api_response(
success=False,
message="Credential not found",
status=404,
error_type="NOT_FOUND",
)
# Get updated credential
credential = WebAuthnService.get_credential_by_id(credential_id, g.current_user)
return api_response(
data={
"credential": credential.to_webauthn_dict() if credential else None,
},
message="Passkey renamed successfully",
)
except ValidationError as e:
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
@api_v1_bp.route("/auth/webauthn/status", methods=["GET"])
@login_required
def get_webauthn_status():
"""
Get WebAuthn status for the current user.
Returns:
200: WebAuthn status with webauthn_enabled and credential_count
401: Not authenticated
"""
user = g.current_user
return api_response(
data={
"webauthn_enabled": user.has_webauthn_enabled(),
"credential_count": user.get_webauthn_credential_count(),
},
message="WebAuthn status retrieved successfully",
)
@@ -1,18 +1,18 @@
"""Organization endpoints."""
from flask import g, request
from marshmallow import ValidationError
from app.api.v1 import api_v1_bp
from app.utils.response import api_response
from app.utils.decorators import login_required, require_admin, require_owner
from app.schemas.organization_schema import (
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, require_admin, require_owner
from gatehouse_app.schemas.organization_schema import (
OrganizationCreateSchema,
OrganizationUpdateSchema,
InviteMemberSchema,
UpdateMemberRoleSchema,
)
from app.services.organization_service import OrganizationService
from app.services.user_service import UserService
from app.utils.constants import OrganizationRole
from gatehouse_app.services.organization_service import OrganizationService
from gatehouse_app.services.user_service import UserService
from gatehouse_app.utils.constants import OrganizationRole
@api_v1_bp.route("/organizations", methods=["POST"])
@@ -1,12 +1,12 @@
"""User endpoints."""
from flask import g, request
from marshmallow import ValidationError
from app.api.v1 import api_v1_bp
from app.utils.response import api_response
from app.utils.decorators import login_required
from app.schemas.user_schema import UserUpdateSchema, ChangePasswordSchema
from app.services.user_service import UserService
from app.services.auth_service import AuthService
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.schemas.user_schema import UserUpdateSchema, ChangePasswordSchema
from gatehouse_app.services.user_service import UserService
from gatehouse_app.services.auth_service import AuthService
@api_v1_bp.route("/users/me", methods=["GET"])
@@ -1,6 +1,6 @@
"""Exceptions package."""
from app.exceptions.base import BaseAPIException
from app.exceptions.auth_exceptions import (
from gatehouse_app.exceptions.base import BaseAPIException
from gatehouse_app.exceptions.auth_exceptions import (
UnauthorizedError,
ForbiddenError,
InvalidCredentialsError,
@@ -9,7 +9,7 @@ from app.exceptions.auth_exceptions import (
SessionExpiredError,
InvalidTokenError,
)
from app.exceptions.validation_exceptions import (
from gatehouse_app.exceptions.validation_exceptions import (
ValidationError,
NotFoundError,
ConflictError,
@@ -1,5 +1,5 @@
"""Authentication and authorization exceptions."""
from app.exceptions.base import BaseAPIException
from gatehouse_app.exceptions.base import BaseAPIException
class UnauthorizedError(BaseAPIException):
@@ -1,5 +1,5 @@
"""Validation and resource exceptions."""
from app.exceptions.base import BaseAPIException
from gatehouse_app.exceptions.base import BaseAPIException
class ValidationError(BaseAPIException):
@@ -7,6 +7,7 @@ from flask_marshmallow import Marshmallow
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_session import Session
import redis
# Initialize extensions
db = SQLAlchemy()
@@ -20,3 +21,6 @@ limiter = Limiter(
storage_uri="memory://", # Will be overridden by config
)
session = Session()
# Redis client - will be initialized with app
redis_client = None
+6
View File
@@ -0,0 +1,6 @@
"""Middleware package."""
from gatehouse_app.middleware.request_id import RequestIDMiddleware
from gatehouse_app.middleware.security_headers import SecurityHeadersMiddleware
from gatehouse_app.middleware.cors import setup_cors
__all__ = ["RequestIDMiddleware", "SecurityHeadersMiddleware", "setup_cors"]
+30
View File
@@ -0,0 +1,30 @@
"""Models package."""
from gatehouse_app.models.base import BaseModel
from gatehouse_app.models.user import User
from gatehouse_app.models.organization import Organization
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.models.authentication_method import AuthenticationMethod
from gatehouse_app.models.session import Session
from gatehouse_app.models.audit_log import AuditLog
from gatehouse_app.models.oidc_client import OIDCClient
from gatehouse_app.models.oidc_authorization_code import OIDCAuthCode
from gatehouse_app.models.oidc_refresh_token import OIDCRefreshToken
from gatehouse_app.models.oidc_session import OIDCSession
from gatehouse_app.models.oidc_token_metadata import OIDCTokenMetadata
from gatehouse_app.models.oidc_audit_log import OIDCAuditLog
__all__ = [
"BaseModel",
"User",
"Organization",
"OrganizationMember",
"AuthenticationMethod",
"Session",
"AuditLog",
"OIDCClient",
"OIDCAuthCode",
"OIDCRefreshToken",
"OIDCSession",
"OIDCTokenMetadata",
"OIDCAuditLog",
]
@@ -1,7 +1,7 @@
"""Audit log model."""
from app.extensions import db
from app.models.base import BaseModel
from app.utils.constants import AuditAction
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import AuditAction
class AuditLog(BaseModel):
@@ -1,7 +1,7 @@
"""Authentication method model."""
from app.extensions import db
from app.models.base import BaseModel
from app.utils.constants import AuthMethodType
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import AuthMethodType
class AuthenticationMethod(BaseModel):
@@ -60,6 +60,10 @@ class AuthenticationMethod(BaseModel):
"""Check if this is a TOTP authentication method."""
return self.method_type == AuthMethodType.TOTP
def is_webauthn(self):
"""Check if this is a WebAuthn authentication method."""
return self.method_type == AuthMethodType.WEBAUTHN
def to_dict(self, exclude=None):
"""Convert to dictionary, excluding sensitive fields."""
exclude = exclude or []
@@ -68,3 +72,22 @@ class AuthenticationMethod(BaseModel):
exclude.append("totp_secret")
exclude.append("totp_backup_codes")
return super().to_dict(exclude=exclude)
def to_webauthn_dict(self):
"""Convert WebAuthn credential to public dictionary.
Returns:
Dictionary with safe-to-expose credential information.
"""
if not self.is_webauthn() or not self.provider_data:
return None
data = self.provider_data
return {
"id": data.get("credential_id"),
"name": data.get("name"),
"transports": data.get("transports", []),
"created_at": data.get("created_at"),
"last_used_at": data.get("last_used_at"),
"sign_count": data.get("sign_count", 0),
}
@@ -1,7 +1,7 @@
"""Base model with common fields and functionality."""
import uuid
from datetime import datetime, timezone
from app.extensions import db
from gatehouse_app.extensions import db
class BaseModel(db.Model):
@@ -1,7 +1,7 @@
"""OIDC Audit Log model for comprehensive OIDC event tracking."""
from datetime import datetime
from app.extensions import db
from app.models.base import BaseModel
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
class OIDCAuditLog(BaseModel):
@@ -219,13 +219,13 @@ class OIDCAuditLog(BaseModel):
# Add relationship back to User model
from app.models.user import User
from gatehouse_app.models.user import User
User.oidc_audit_logs = db.relationship(
"OIDCAuditLog", back_populates="user", cascade="all, delete-orphan"
)
# Add relationship back to OIDCClient model
from app.models.oidc_client import OIDCClient
from gatehouse_app.models.oidc_client import OIDCClient
OIDCClient.audit_logs = db.relationship(
"OIDCAuditLog", back_populates="client", cascade="all, delete-orphan"
)
@@ -1,7 +1,7 @@
"""OIDC Authorization Code model for auth code flow."""
from datetime import datetime, timedelta, timezone
from app.extensions import db
from app.models.base import BaseModel
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
class OIDCAuthCode(BaseModel):
@@ -113,13 +113,13 @@ class OIDCAuthCode(BaseModel):
# Add relationship back to User model
from app.models.user import User
from gatehouse_app.models.user import User
User.oidc_auth_codes = db.relationship(
"OIDCAuthCode", back_populates="user", cascade="all, delete-orphan"
)
# Add relationship back to OIDCClient model
from app.models.oidc_client import OIDCClient
from gatehouse_app.models.oidc_client import OIDCClient
OIDCClient.authorization_codes = db.relationship(
"OIDCAuthCode", back_populates="client", cascade="all, delete-orphan"
)
@@ -1,7 +1,7 @@
"""OIDC Client model."""
from app.extensions import db
from app.models.base import BaseModel
from app.utils.constants import OIDCGrantType, OIDCResponseType
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import OIDCGrantType, OIDCResponseType
class OIDCClient(BaseModel):
@@ -1,7 +1,7 @@
"""OIDC JWKS Key model for persisting signing keys."""
from datetime import datetime, timezone
from app.extensions import db
from app.models.base import BaseModel
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
class OidcJwksKey(BaseModel):
@@ -1,7 +1,7 @@
"""OIDC Refresh Token model for token rotation."""
from datetime import datetime, timezone
from app.extensions import db
from app.models.base import BaseModel
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
class OIDCRefreshToken(BaseModel):
@@ -145,19 +145,19 @@ class OIDCRefreshToken(BaseModel):
# Add relationship back to User model
from app.models.user import User
from gatehouse_app.models.user import User
User.oidc_refresh_tokens = db.relationship(
"OIDCRefreshToken", back_populates="user", cascade="all, delete-orphan"
)
# Add relationship back to OIDCClient model
from app.models.oidc_client import OIDCClient
from gatehouse_app.models.oidc_client import OIDCClient
OIDCClient.refresh_tokens = db.relationship(
"OIDCRefreshToken", back_populates="client", cascade="all, delete-orphan"
)
# Add relationship back to Session model
from app.models.session import Session
from gatehouse_app.models.session import Session
Session.oidc_refresh_token = db.relationship(
"OIDCRefreshToken", back_populates="access_token", uselist=False
)
@@ -1,7 +1,7 @@
"""OIDC Session model for OIDC session tracking."""
from datetime import datetime, timezone
from app.extensions import db
from app.models.base import BaseModel
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
class OIDCSession(BaseModel):
@@ -150,13 +150,13 @@ class OIDCSession(BaseModel):
# Add relationship back to User model
from app.models.user import User
from gatehouse_app.models.user import User
User.oidc_sessions = db.relationship(
"OIDCSession", back_populates="user", cascade="all, delete-orphan"
)
# Add relationship back to OIDCClient model
from app.models.oidc_client import OIDCClient
from gatehouse_app.models.oidc_client import OIDCClient
OIDCClient.oidc_sessions = db.relationship(
"OIDCSession", back_populates="client", cascade="all, delete-orphan"
)
@@ -1,8 +1,8 @@
"""OIDC Token Metadata model for token revocation tracking."""
import uuid
from datetime import datetime, timezone
from app.extensions import db
from app.models.base import BaseModel
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
class OIDCTokenMetadata(BaseModel):
@@ -184,13 +184,13 @@ class OIDCTokenMetadata(BaseModel):
# Add relationship back to User model
from app.models.user import User
from gatehouse_app.models.user import User
User.oidc_token_metadata = db.relationship(
"OIDCTokenMetadata", back_populates="user", cascade="all, delete-orphan"
)
# Add relationship back to OIDCClient model
from app.models.oidc_client import OIDCClient
from gatehouse_app.models.oidc_client import OIDCClient
OIDCClient.token_metadata = db.relationship(
"OIDCTokenMetadata", back_populates="client", cascade="all, delete-orphan"
)
@@ -1,6 +1,6 @@
"""Organization model."""
from app.extensions import db
from app.models.base import BaseModel
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
class Organization(BaseModel):
@@ -35,7 +35,7 @@ class Organization(BaseModel):
def get_owner(self):
"""Get the owner of the organization."""
from app.utils.constants import OrganizationRole
from gatehouse_app.utils.constants import OrganizationRole
for member in self.members:
if member.role == OrganizationRole.OWNER and member.deleted_at is None:
@@ -44,7 +44,7 @@ class Organization(BaseModel):
def is_member(self, user_id):
"""Check if a user is a member of the organization."""
from app.models.organization_member import OrganizationMember
from gatehouse_app.models.organization_member import OrganizationMember
return (
OrganizationMember.query.filter_by(
@@ -1,7 +1,7 @@
"""Organization member model."""
from app.extensions import db
from app.models.base import BaseModel
from app.utils.constants import OrganizationRole
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import OrganizationRole
class OrganizationMember(BaseModel):
@@ -1,8 +1,8 @@
"""Session model."""
from datetime import datetime, timedelta, timezone
from app.extensions import db
from app.models.base import BaseModel
from app.utils.constants import SessionStatus
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import SessionStatus
class Session(BaseModel):
@@ -1,7 +1,7 @@
"""User model."""
from app.extensions import db
from app.models.base import BaseModel
from app.utils.constants import UserStatus
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import UserStatus
class User(BaseModel):
@@ -46,8 +46,8 @@ class User(BaseModel):
def has_password_auth(self):
"""Check if user has password authentication enabled."""
from app.models.authentication_method import AuthenticationMethod
from app.utils.constants import AuthMethodType
from gatehouse_app.models.authentication_method import AuthenticationMethod
from gatehouse_app.utils.constants import AuthMethodType
return (
AuthenticationMethod.query.filter_by(
@@ -66,8 +66,8 @@ class User(BaseModel):
Returns:
True if user has a verified TOTP authentication method, False otherwise.
"""
from app.models.authentication_method import AuthenticationMethod
from app.utils.constants import AuthMethodType
from gatehouse_app.models.authentication_method import AuthenticationMethod
from gatehouse_app.utils.constants import AuthMethodType
return (
AuthenticationMethod.query.filter_by(
@@ -89,9 +89,53 @@ class User(BaseModel):
Returns the most recently created TOTP method to handle cases where
multiple enrollment attempts may exist.
"""
from app.models.authentication_method import AuthenticationMethod
from app.utils.constants import AuthMethodType
from gatehouse_app.models.authentication_method import AuthenticationMethod
from gatehouse_app.utils.constants import AuthMethodType
return AuthenticationMethod.query.filter_by(
user_id=self.id, method_type=AuthMethodType.TOTP, deleted_at=None
).order_by(AuthenticationMethod.created_at.desc()).first()
def has_webauthn_enabled(self) -> bool:
"""Check if user has any WebAuthn passkey credentials.
Returns:
True if user has at least one WebAuthn credential, False otherwise.
"""
from gatehouse_app.models.authentication_method import AuthenticationMethod
from gatehouse_app.utils.constants import AuthMethodType
return (
AuthenticationMethod.query.filter_by(
user_id=self.id,
method_type=AuthMethodType.WEBAUTHN,
deleted_at=None,
).first()
is not None
)
def get_webauthn_credentials(self):
"""Get all WebAuthn credentials for the user.
Returns:
List of AuthenticationMethod instances for WebAuthn, ordered by creation date.
"""
from gatehouse_app.models.authentication_method import AuthenticationMethod
from gatehouse_app.utils.constants import AuthMethodType
return AuthenticationMethod.query.filter_by(
user_id=self.id, method_type=AuthMethodType.WEBAUTHN, deleted_at=None
).order_by(AuthenticationMethod.created_at.desc()).all()
def get_webauthn_credential_count(self) -> int:
"""Get the count of WebAuthn credentials for the user.
Returns:
Number of WebAuthn credentials.
"""
from gatehouse_app.models.authentication_method import AuthenticationMethod
from gatehouse_app.utils.constants import AuthMethodType
return AuthenticationMethod.query.filter_by(
user_id=self.id, method_type=AuthMethodType.WEBAUTHN, deleted_at=None
).count()
@@ -1,13 +1,13 @@
"""Schemas package."""
from app.schemas.user_schema import UserSchema, UserUpdateSchema, ChangePasswordSchema
from app.schemas.auth_schema import (
from gatehouse_app.schemas.user_schema import UserSchema, UserUpdateSchema, ChangePasswordSchema
from gatehouse_app.schemas.auth_schema import (
RegisterSchema,
LoginSchema,
RefreshTokenSchema,
ForgotPasswordSchema,
ResetPasswordSchema,
)
from app.schemas.organization_schema import (
from gatehouse_app.schemas.organization_schema import (
OrganizationSchema,
OrganizationCreateSchema,
OrganizationUpdateSchema,
@@ -1,6 +1,6 @@
"""User schemas for validation and serialization."""
from marshmallow import Schema, fields, validate, validates, ValidationError
from app.utils.constants import UserStatus
from gatehouse_app.utils.constants import UserStatus
class UserSchema(Schema):
+85
View File
@@ -0,0 +1,85 @@
"""WebAuthn schemas for validation."""
from marshmallow import Schema, fields, validate, validates_schema, ValidationError
class WebAuthnRegistrationBeginSchema(Schema):
"""Schema for beginning WebAuthn registration."""
# No required fields - uses authenticated user
pass
class WebAuthnRegistrationCompleteSchema(Schema):
"""Schema for completing WebAuthn registration."""
id = fields.Str(required=True)
rawId = fields.Str(required=True)
type = fields.Str(
required=True,
validate=validate.OneOf(["public-key"])
)
response = fields.Dict(required=True)
transports = fields.List(
fields.Str(validate=validate.OneOf(["usb", "nfc", "ble", "hybrid", "internal", "platform"])),
load_default=[]
)
@validates_schema
def validate_response(self, data, **kwargs):
"""Validate response contains required fields."""
response = data.get("response", {})
required_fields = ["attestationObject", "clientDataJSON"]
for field in required_fields:
if field not in response:
raise ValidationError(
f"Missing required field in response: {field}",
field_name=f"response.{field}"
)
class WebAuthnLoginBeginSchema(Schema):
"""Schema for beginning WebAuthn login."""
email = fields.Email(required=True)
class WebAuthnLoginCompleteSchema(Schema):
"""Schema for completing WebAuthn login."""
id = fields.Str(required=True)
rawId = fields.Str(required=True)
type = fields.Str(
required=True,
validate=validate.OneOf(["public-key"])
)
response = fields.Dict(required=True)
clientExtensionResults = fields.Dict(load_default={})
@validates_schema
def validate_response(self, data, **kwargs):
"""Validate response contains required fields."""
response = data.get("response", {})
required_fields = ["authenticatorData", "clientDataJSON", "signature"]
for field in required_fields:
if field not in response:
raise ValidationError(
f"Missing required field in response: {field}",
field_name=f"response.{field}"
)
class WebAuthnCredentialRenameSchema(Schema):
"""Schema for renaming a WebAuthn credential."""
name = fields.Str(
required=True,
validate=validate.Length(min=1, max=100)
)
class WebAuthnCredentialDeleteSchema(Schema):
"""Schema for deleting a WebAuthn credential."""
password = fields.Str(
required=True,
validate=validate.Length(min=1)
)
+25
View File
@@ -0,0 +1,25 @@
"""Services package."""
from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.services.user_service import UserService
from gatehouse_app.services.organization_service import OrganizationService
from gatehouse_app.services.session_service import SessionService
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.services.oidc_service import OIDCService, OIDCError
from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService
from gatehouse_app.services.oidc_token_service import OIDCTokenService
from gatehouse_app.services.oidc_session_service import OIDCSessionService
from gatehouse_app.services.oidc_audit_service import OIDCAuditService
__all__ = [
"AuthService",
"UserService",
"OrganizationService",
"SessionService",
"AuditService",
"OIDCService",
"OIDCError",
"OIDCJWKSService",
"OIDCTokenService",
"OIDCSessionService",
"OIDCAuditService",
]
@@ -1,7 +1,7 @@
"""Audit service."""
from flask import request, g
from app.models.audit_log import AuditLog
from app.utils.constants import AuditAction
from gatehouse_app.models.audit_log import AuditLog
from gatehouse_app.utils.constants import AuditAction
class AuditService:
@@ -3,15 +3,15 @@ import logging
import secrets
from datetime import datetime, timedelta, timezone
from flask import request, g, current_app
from app.extensions import db, bcrypt
from app.models.user import User
from app.models.authentication_method import AuthenticationMethod
from app.models.session import Session
from app.utils.constants import AuthMethodType, SessionStatus, UserStatus, AuditAction
from app.exceptions.auth_exceptions import InvalidCredentialsError, AccountSuspendedError, AccountInactiveError
from app.exceptions.validation_exceptions import EmailAlreadyExistsError
from app.services.audit_service import AuditService
from app.services.totp_service import TOTPService
from gatehouse_app.extensions import db, bcrypt
from gatehouse_app.models.user import User
from gatehouse_app.models.authentication_method import AuthenticationMethod
from gatehouse_app.models.session import Session
from gatehouse_app.utils.constants import AuthMethodType, SessionStatus, UserStatus, AuditAction
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError, AccountSuspendedError, AccountInactiveError
from gatehouse_app.exceptions.validation_exceptions import EmailAlreadyExistsError
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.services.totp_service import TOTPService
logger = logging.getLogger(__name__)
@@ -254,7 +254,7 @@ class AuthService:
Raises:
ConflictError: If user already has TOTP enabled
"""
from app.exceptions.validation_exceptions import ConflictError
from gatehouse_app.exceptions.validation_exceptions import ConflictError
# Check if user already has TOTP enabled
if user.has_totp_enabled():
@@ -4,8 +4,8 @@ from typing import Dict, List, Optional
from flask import g
from app.models import OIDCAuditLog, OIDCClient, User
from app.exceptions.validation_exceptions import NotFoundError
from gatehouse_app.models import OIDCAuditLog, OIDCClient, User
from gatehouse_app.exceptions.validation_exceptions import NotFoundError
class OIDCAuditService:
@@ -7,8 +7,8 @@ from typing import Dict, List, Optional, Tuple
from flask import current_app
from app.extensions import db
from app.models.oidc_jwks_key import OidcJwksKey
from gatehouse_app.extensions import db
from gatehouse_app.models.oidc_jwks_key import OidcJwksKey
class JWKSKey:
@@ -9,20 +9,20 @@ from flask import current_app, g
logger = logging.getLogger(__name__)
from app.extensions import db
from app.models import (
from gatehouse_app.extensions import db
from gatehouse_app.models import (
User, OIDCClient, OIDCAuthCode, OIDCRefreshToken,
OIDCSession, OIDCTokenMetadata
)
from app.models.organization_member import OrganizationMember
from app.exceptions.validation_exceptions import (
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.exceptions.validation_exceptions import (
ValidationError, NotFoundError, BadRequestError
)
from app.exceptions.auth_exceptions import UnauthorizedError, InvalidTokenError
from app.services.oidc_token_service import OIDCTokenService
from app.services.oidc_session_service import OIDCSessionService
from app.services.oidc_audit_service import OIDCAuditService
from app.services.oidc_jwks_service import OIDCJWKSService
from gatehouse_app.exceptions.auth_exceptions import UnauthorizedError, InvalidTokenError
from gatehouse_app.services.oidc_token_service import OIDCTokenService
from gatehouse_app.services.oidc_session_service import OIDCSessionService
from gatehouse_app.services.oidc_audit_service import OIDCAuditService
from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService
class OIDCError(Exception):
@@ -6,9 +6,9 @@ from typing import Dict, Optional, Tuple
from datetime import timezone
from flask import current_app, g
from app.extensions import db
from app.models import OIDCSession, OIDCClient, User
from app.exceptions.validation_exceptions import NotFoundError, ValidationError
from gatehouse_app.extensions import db
from gatehouse_app.models import OIDCSession, OIDCClient, User
from gatehouse_app.exceptions.validation_exceptions import NotFoundError, ValidationError
class OIDCSessionService:
@@ -10,9 +10,9 @@ from typing import Dict, Optional, Any
import jwt
from flask import current_app, g
from app.models import User, OIDCClient
from app.models.organization_member import OrganizationMember
from app.services.oidc_jwks_service import OIDCJWKSService
from gatehouse_app.models import User, OIDCClient
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService
logger = logging.getLogger(__name__)
@@ -2,12 +2,12 @@
import logging
from datetime import datetime, timezone
from flask import current_app
from app.extensions import db
from app.models.organization import Organization
from app.models.organization_member import OrganizationMember
from app.exceptions.validation_exceptions import OrganizationNotFoundError, ConflictError
from app.utils.constants import OrganizationRole, AuditAction
from app.services.audit_service import AuditService
from gatehouse_app.extensions import db
from gatehouse_app.models.organization import Organization
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.exceptions.validation_exceptions import OrganizationNotFoundError, ConflictError
from gatehouse_app.utils.constants import OrganizationRole, AuditAction
from gatehouse_app.services.audit_service import AuditService
logger = logging.getLogger(__name__)
@@ -1,7 +1,7 @@
"""Session service."""
from datetime import datetime, timezone
from app.models.session import Session
from app.utils.constants import SessionStatus
from gatehouse_app.models.session import Session
from gatehouse_app.utils.constants import SessionStatus
class SessionService:
@@ -17,8 +17,8 @@ class SessionService:
Returns:
Session object if found and active, None otherwise
"""
from app.models.session import Session
from app.utils.constants import SessionStatus
from gatehouse_app.models.session import Session
from gatehouse_app.utils.constants import SessionStatus
return Session.query.filter_by(
token=token,
status=SessionStatus.ACTIVE,
@@ -7,7 +7,7 @@ from datetime import datetime, timezone
from typing import Tuple
import pyotp
from app.extensions import bcrypt
from gatehouse_app.extensions import bcrypt
logger = logging.getLogger(__name__)
@@ -1,11 +1,11 @@
"""User service."""
import logging
from flask import current_app
from app.extensions import db
from app.models.user import User
from app.exceptions.validation_exceptions import UserNotFoundError
from app.utils.constants import AuditAction
from app.services.audit_service import AuditService
from gatehouse_app.extensions import db
from gatehouse_app.models.user import User
from gatehouse_app.exceptions.validation_exceptions import UserNotFoundError
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.services.audit_service import AuditService
logger = logging.getLogger(__name__)
+647
View File
@@ -0,0 +1,647 @@
"""WebAuthn passkey authentication service."""
import logging
import secrets
import hashlib
import base64
import json
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any, List
from flask import current_app
from gatehouse_app.extensions import db, redis_client
from gatehouse_app.models.user import User
from gatehouse_app.models.authentication_method import AuthenticationMethod
from gatehouse_app.utils.constants import AuthMethodType, AuditAction
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
from gatehouse_app.services.audit_service import AuditService
logger = logging.getLogger(__name__)
class WebAuthnService:
"""Service for WebAuthn passkey operations."""
# WebAuthn algorithm constants (COSE algorithms)
COSE_ALGORITHMS = {
-7: "ES256", # ECDSA with SHA-256
-257: "RS256", # RSASSA-PKCS1-v1_5 with SHA-256
}
# Supported key types
KEY_TYPES = ["public-key"]
@staticmethod
def _generate_challenge() -> str:
"""Generate a cryptographically secure challenge.
Returns:
Base64URL-encoded challenge string
"""
bytes_data = secrets.token_bytes(32)
return base64.urlsafe_b64encode(bytes_data).decode('utf-8').rstrip('=')
@staticmethod
def _store_challenge(user_id: str, challenge: str, challenge_type: str, expires_in: int = 300) -> bool:
"""Store a challenge in Redis for validation.
Args:
user_id: User ID
challenge: The challenge string
challenge_type: Type of challenge ('registration' or 'authentication')
expires_in: Expiration time in seconds
Returns:
True if stored successfully
"""
try:
key = f"webauthn:challenge:{user_id}:{challenge_type}:{challenge}"
data = {
"challenge": challenge,
"user_id": user_id,
"type": challenge_type,
"created_at": datetime.now(timezone.utc).isoformat()
}
redis_client.setex(key, expires_in, json.dumps(data))
return True
except Exception as e:
logger.error(f"Failed to store WebAuthn challenge: {e}")
return False
@staticmethod
def _get_and_delete_challenge(user_id: str, challenge: str, challenge_type: str) -> Optional[Dict]:
"""Retrieve and delete a challenge from Redis.
Args:
user_id: User ID
challenge: The challenge string
challenge_type: Type of challenge
Returns:
Challenge data dict or None if not found/expired
"""
try:
key = f"webauthn:challenge:{user_id}:{challenge_type}:{challenge}"
data = redis_client.get(key)
if data:
redis_client.delete(key)
return json.loads(data)
return None
except Exception as e:
logger.error(f"Failed to retrieve WebAuthn challenge: {e}")
return None
@staticmethod
def _base64url_decode(data: str) -> bytes:
"""Decode Base64URL string to bytes."""
# Add padding if needed
padding = 4 - (len(data) % 4)
if padding != 4:
data += '=' * padding
return base64.urlsafe_b64decode(data)
@staticmethod
def _base64url_encode(data: bytes) -> str:
"""Encode bytes to Base64URL string."""
return base64.urlsafe_b64encode(data).decode('utf-8').rstrip('=')
@staticmethod
def _hash_credential_id(credential_id: bytes) -> str:
"""Hash a credential ID for secure storage lookup.
Args:
credential_id: Raw credential ID bytes
Returns:
Hashed credential ID string
"""
return hashlib.sha256(credential_id).hexdigest()
@classmethod
def generate_registration_challenge(cls, user: User) -> Dict[str, Any]:
"""Generate a challenge for passkey registration.
Args:
user: User instance
Returns:
PublicKeyCredentialCreationOptions dict
"""
# Generate challenge
challenge = cls._generate_challenge()
# Store challenge
cls._store_challenge(user.id, challenge, 'registration')
# Get existing credentials to exclude
existing_credentials = cls.get_user_credentials(user)
exclude_credentials = []
for cred in existing_credentials:
if cred.provider_data:
cred_id_b64 = cred.provider_data.get("credential_id")
if cred_id_b64:
try:
cred_id = cls._base64url_decode(cred_id_b64)
transports = cred.provider_data.get("transports", [])
exclude_credentials.append({
"id": cred_id_b64,
"type": "public-key",
"transports": transports
})
except Exception:
pass
# Get RP configuration
rp_id = current_app.config.get('WEBAUTHN_RP_ID', 'localhost')
rp_name = current_app.config.get('WEBAUTHN_RP_NAME', 'Gatehouse')
# Generate user ID (Base64URL encoded)
user_id = cls._base64url_encode(user.id.encode('utf-8'))
# Build options
options = {
"rp": {
"name": rp_name,
"id": rp_id
},
"user": {
"id": user_id,
"name": user.email,
"displayName": user.full_name or user.email
},
"challenge": challenge,
"pubKeyCredParams": [
{"type": "public-key", "alg": -7}, # ES256
{"type": "public-key", "alg": -257} # RS256
],
"timeout": 60000, # 60 seconds
"excludeCredentials": exclude_credentials,
"authenticatorSelection": {
"residentKey": "preferred",
"userVerification": "preferred"
},
"attestation": "none"
}
# Log audit event
AuditService.log_action(
action=AuditAction.WEBAUTHN_REGISTER_INITIATED,
user_id=user.id,
description="WebAuthn registration initiated"
)
return options
@classmethod
def verify_registration_response(
cls,
user: User,
credential_data: Dict[str, Any],
challenge: str
) -> AuthenticationMethod:
"""Verify and store a new passkey credential.
Args:
user: User instance
credential_data: Credential response data from client
challenge: The original challenge string
Returns:
AuthenticationMethod instance
Raises:
InvalidCredentialsError: If verification fails
"""
# Verify and consume challenge
stored_challenge = cls._get_and_delete_challenge(user.id, challenge, 'registration')
if not stored_challenge:
AuditService.log_action(
action=AuditAction.WEBAUTHN_REGISTER_FAILED,
user_id=user.id,
description="Registration failed: challenge expired or invalid"
)
raise InvalidCredentialsError("Challenge expired or invalid")
try:
# Parse credential data
credential_id = credential_data.get("id")
raw_id = credential_data.get("rawId")
response = credential_data.get("response", {})
attestation_object_b64 = response.get("attestationObject")
client_data_json_b64 = response.get("clientDataJSON")
transports = credential_data.get("transports", ["platform"])
if not all([credential_id, raw_id, attestation_object_b64, client_data_json_b64]):
raise InvalidCredentialsError("Missing required credential data")
# Decode attestation object
attestation_object = cls._base64url_decode(attestation_object_b64)
# Parse CBOR attestation object (simplified - in production use cbor2 library)
# The attestation object contains: authData, attStmt, fmt
try:
import cbor2
attestation_dict = cbor2.loads(attestation_object)
except ImportError:
# Fallback: try to parse as simple structure
attestation_dict = {}
logger.warning("cbor2 library not available, using fallback parsing")
# Extract authenticator data
auth_data = attestation_dict.get('authData', b'')
# Parse authenticator data
# Format: RP ID hash (32 bytes) + Flags (1 byte) + Counter (4 bytes) + AAGUID (16 bytes) + Credential ID length (2 bytes) + Credential ID + Public key
if len(auth_data) < 37:
raise InvalidCredentialsError("Invalid authenticator data")
rp_id_hash = auth_data[:32]
flags = auth_data[32]
counter = int.from_bytes(auth_data[33:37], 'big')
aaguid = auth_data[37:53] if len(auth_data) >= 53 else b''
# Extract credential ID length and ID
cred_id_length = int.from_bytes(auth_data[53:55], 'big') if len(auth_data) >= 55 else 0
credential_id_raw = auth_data[55:55+cred_id_length] if cred_id_length > 0 else b''
# Extract public key (COSE format)
public_key_cose = auth_data[55+cred_id_length:]
# Verify client data
client_data_json = cls._base64url_decode(client_data_json_b64)
client_data = json.loads(client_data_json)
# Verify challenge matches
if client_data.get("challenge") != challenge:
raise InvalidCredentialsError("Challenge mismatch")
# Verify origin
expected_origin = current_app.config.get('WEBAUTHN_ORIGIN', 'http://localhost:5173')
if client_data.get("origin") != expected_origin:
logger.warning(f"Origin mismatch: expected {expected_origin}, got {client_data.get('origin')}")
# Don't fail on origin mismatch in development
# Verify user presence and verification
user_present = bool(flags & 0x01)
user_verified = bool(flags & 0x04)
if not user_present:
raise InvalidCredentialsError("User presence not verified")
# Store credential
credential_id_hash = cls._hash_credential_id(credential_id_raw)
# Check if credential already exists
existing = AuthenticationMethod.query.filter_by(
user_id=user.id,
method_type=AuthMethodType.WEBAUTHN,
deleted_at=None
).first()
if existing and existing.provider_data:
stored_cred_id = existing.provider_data.get("credential_id", "")
if stored_cred_id == credential_id:
raise InvalidCredentialsError("Credential already registered")
# Create or update authentication method
auth_method = existing or AuthenticationMethod(
user_id=user.id,
method_type=AuthMethodType.WEBAUTHN,
is_primary=False,
verified=True
)
# Store credential data
auth_method.provider_data = {
"credential_id": credential_id,
"credential_id_hash": credential_id_hash,
"public_key_cose": cls._base64url_encode(public_key_cose),
"sign_count": counter,
"transports": transports,
"aaguid": cls._base64url_encode(aaguid) if aaguid else None,
"attestation_format": attestation_dict.get('fmt', 'unknown'),
"created_at": datetime.now(timezone.utc).isoformat(),
"last_used_at": None,
"name": f"Passkey {datetime.now(timezone.utc).strftime('%Y-%m-%d')}"
}
auth_method.save()
# Log audit event
AuditService.log_action(
action=AuditAction.WEBAUTHN_REGISTER_COMPLETED,
user_id=user.id,
resource_type="authentication_method",
resource_id=auth_method.id,
description=f"WebAuthn credential registered: {credential_id[:16]}..."
)
return auth_method
except InvalidCredentialsError:
raise
except Exception as e:
logger.error(f"WebAuthn registration verification failed: {e}")
AuditService.log_action(
action=AuditAction.WEBAUTHN_REGISTER_FAILED,
user_id=user.id,
description=f"Registration failed: {str(e)}"
)
raise InvalidCredentialsError("Registration verification failed")
@classmethod
def generate_authentication_challenge(cls, user: User) -> Dict[str, Any]:
"""Generate a challenge for passkey authentication.
Args:
user: User instance
Returns:
PublicKeyCredentialRequestOptions dict
"""
# Generate challenge
challenge = cls._generate_challenge()
# Store challenge
cls._store_challenge(user.id, challenge, 'authentication')
# Get user's credentials
credentials = cls.get_user_credentials(user)
# Build allow credentials list
allow_credentials = []
for cred in credentials:
if cred.provider_data:
cred_id = cred.provider_data.get("credential_id")
transports = cred.provider_data.get("transports", [])
if cred_id:
allow_credentials.append({
"id": cred_id,
"type": "public-key",
"transports": transports
})
# Get RP configuration
rp_id = current_app.config.get('WEBAUTHN_RP_ID', 'localhost')
# Build options
options = {
"challenge": challenge,
"timeout": 60000,
"rpId": rp_id,
"allowCredentials": allow_credentials,
"userVerification": "preferred"
}
# Log audit event
AuditService.log_action(
action=AuditAction.WEBAUTHN_LOGIN_INITIATED,
user_id=user.id,
description="WebAuthn authentication initiated"
)
return options
@classmethod
def verify_authentication_response(
cls,
user: User,
credential_data: Dict[str, Any],
challenge: str
) -> AuthenticationMethod:
"""Verify passkey authentication response.
Args:
user: User instance
credential_data: Assertion response data from client
challenge: The original challenge string
Returns:
AuthenticationMethod instance
Raises:
InvalidCredentialsError: If verification fails
"""
# Verify and consume challenge
stored_challenge = cls._get_and_delete_challenge(user.id, challenge, 'authentication')
if not stored_challenge:
AuditService.log_action(
action=AuditAction.WEBAUTHN_LOGIN_FAILED,
user_id=user.id,
description="Authentication failed: challenge expired or invalid"
)
raise InvalidCredentialsError("Challenge expired or invalid")
try:
# Parse credential data
credential_id = credential_data.get("id")
raw_id = credential_data.get("rawId")
response = credential_data.get("response", {})
authenticator_data_b64 = response.get("authenticatorData")
client_data_json_b64 = response.get("clientDataJSON")
signature_b64 = response.get("signature")
if not all([credential_id, authenticator_data_b64, client_data_json_b64, signature_b64]):
raise InvalidCredentialsError("Missing required credential data")
# Find the credential
auth_method = AuthenticationMethod.query.filter_by(
user_id=user.id,
method_type=AuthMethodType.WEBAUTHN,
deleted_at=None
).first()
if not auth_method or not auth_method.provider_data:
raise InvalidCredentialsError("No passkey found for user")
stored_cred_id = auth_method.provider_data.get("credential_id")
if stored_cred_id != credential_id:
raise InvalidCredentialsError("Credential not found")
# Decode authenticator data
authenticator_data = cls._base64url_decode(authenticator_data_b64)
# Parse authenticator data
if len(authenticator_data) < 37:
raise InvalidCredentialsError("Invalid authenticator data")
rp_id_hash = authenticator_data[:32]
flags = authenticator_data[32]
counter = int.from_bytes(authenticator_data[33:37], 'big')
# Verify client data
client_data_json = cls._base64url_decode(client_data_json_b64)
client_data = json.loads(client_data_json)
# Verify challenge matches
if client_data.get("challenge") != challenge:
raise InvalidCredentialsError("Challenge mismatch")
# Verify origin
expected_origin = current_app.config.get('WEBAUTHN_ORIGIN', 'http://localhost:5173')
if client_data.get("origin") != expected_origin:
logger.warning(f"Origin mismatch: expected {expected_origin}, got {client_data.get('origin')}")
# Verify user presence
user_present = bool(flags & 0x01)
if not user_present:
raise InvalidCredentialsError("User presence not verified")
# Verify counter (prevent replay attacks)
stored_counter = auth_method.provider_data.get("sign_count", 0)
if counter <= stored_counter:
raise InvalidCredentialsError("Invalid sign counter - potential credential cloning detected")
# Verify signature (simplified - in production use proper crypto verification)
# In a full implementation, you would:
# 1. Decode the public key from COSE format
# 2. Verify the signature using the stored public key
# 3. Verify the authenticator data hash matches RP ID
# For now, we'll trust the authenticator's signature verification
# A full implementation would use the fido2 library
# Update counter and last used time
auth_method.provider_data["sign_count"] = counter
auth_method.provider_data["last_used_at"] = datetime.now(timezone.utc).isoformat()
auth_method.last_used_at = datetime.now(timezone.utc)
db.session.commit()
# Log audit event
AuditService.log_action(
action=AuditAction.WEBAUTHN_LOGIN_SUCCESS,
user_id=user.id,
resource_type="authentication_method",
resource_id=auth_method.id,
description="WebAuthn authentication successful"
)
return auth_method
except InvalidCredentialsError:
raise
except Exception as e:
logger.error(f"WebAuthn authentication verification failed: {e}")
AuditService.log_action(
action=AuditAction.WEBAUTHN_LOGIN_FAILED,
user_id=user.id,
description=f"Authentication failed: {str(e)}"
)
raise InvalidCredentialsError("Authentication verification failed")
@classmethod
def get_user_credentials(cls, user: User) -> List[AuthenticationMethod]:
"""Get all passkey credentials for a user.
Args:
user: User instance
Returns:
List of AuthenticationMethod instances
"""
return AuthenticationMethod.query.filter_by(
user_id=user.id,
method_type=AuthMethodType.WEBAUTHN,
deleted_at=None
).order_by(AuthenticationMethod.created_at.desc()).all()
@classmethod
def delete_credential(cls, credential_id: str, user: User) -> bool:
"""Delete a passkey credential.
Args:
credential_id: The credential ID to delete
user: User instance
Returns:
True if deleted successfully
"""
auth_method = AuthenticationMethod.query.filter_by(
user_id=user.id,
method_type=AuthMethodType.WEBAUTHN,
deleted_at=None
).first()
if not auth_method or not auth_method.provider_data:
return False
stored_cred_id = auth_method.provider_data.get("credential_id")
if stored_cred_id != credential_id:
return False
# Soft delete the credential
auth_method.delete(soft=True)
# Log audit event
AuditService.log_action(
action=AuditAction.WEBAUTHN_CREDENTIAL_DELETED,
user_id=user.id,
resource_type="authentication_method",
resource_id=auth_method.id,
description=f"WebAuthn credential deleted: {credential_id[:16]}..."
)
return True
@classmethod
def rename_credential(cls, credential_id: str, user: User, name: str) -> bool:
"""Rename a passkey credential.
Args:
credential_id: The credential ID to rename
user: User instance
name: New name for the credential
Returns:
True if renamed successfully
"""
auth_method = AuthenticationMethod.query.filter_by(
user_id=user.id,
method_type=AuthMethodType.WEBAUTHN,
deleted_at=None
).first()
if not auth_method or not auth_method.provider_data:
return False
stored_cred_id = auth_method.provider_data.get("credential_id")
if stored_cred_id != credential_id:
return False
# Update name
auth_method.provider_data["name"] = name
db.session.commit()
# Log audit event
AuditService.log_action(
action=AuditAction.WEBAUTHN_CREDENTIAL_RENAMED,
user_id=user.id,
resource_type="authentication_method",
resource_id=auth_method.id,
description=f"WebAuthn credential renamed to: {name}"
)
return True
@classmethod
def get_credential_by_id(cls, credential_id: str, user: User) -> Optional[AuthenticationMethod]:
"""Get a specific credential by ID.
Args:
credential_id: The credential ID
user: User instance
Returns:
AuthenticationMethod instance or None
"""
auth_method = AuthenticationMethod.query.filter_by(
user_id=user.id,
method_type=AuthMethodType.WEBAUTHN,
deleted_at=None
).first()
if auth_method and auth_method.provider_data:
stored_cred_id = auth_method.provider_data.get("credential_id")
if stored_cred_id == credential_id:
return auth_method
return None
@@ -1,6 +1,6 @@
"""Utilities package."""
from app.utils.response import api_response
from app.utils.constants import (
from gatehouse_app.utils.response import api_response
from gatehouse_app.utils.constants import (
UserStatus,
OrganizationRole,
AuthMethodType,
@@ -8,7 +8,7 @@ from app.utils.constants import (
AuditAction,
ErrorType,
)
from app.utils.decorators import login_required, require_role, require_owner, require_admin
from gatehouse_app.utils.decorators import login_required, require_role, require_owner, require_admin
__all__ = [
"api_response",
@@ -30,6 +30,7 @@ class AuthMethodType(str, Enum):
MICROSOFT = "microsoft"
SAML = "saml"
OIDC = "oidc"
WEBAUTHN = "webauthn"
class SessionStatus(str, Enum):
@@ -75,6 +76,16 @@ class AuditAction(str, Enum):
TOTP_BACKUP_CODE_USED = "totp.backup_code.used"
TOTP_BACKUP_CODES_REGENERATED = "totp.backup_codes.regenerated"
# WebAuthn actions
WEBAUTHN_REGISTER_INITIATED = "webauthn.register.initiated"
WEBAUTHN_REGISTER_COMPLETED = "webauthn.register.completed"
WEBAUTHN_REGISTER_FAILED = "webauthn.register.failed"
WEBAUTHN_LOGIN_INITIATED = "webauthn.login.initiated"
WEBAUTHN_LOGIN_SUCCESS = "webauthn.login.success"
WEBAUTHN_LOGIN_FAILED = "webauthn.login.failed"
WEBAUTHN_CREDENTIAL_DELETED = "webauthn.credential.deleted"
WEBAUTHN_CREDENTIAL_RENAMED = "webauthn.credential.renamed"
class OIDCGrantType(str, Enum):
"""OIDC grant types."""
@@ -1,8 +1,8 @@
"""Custom decorators for authentication and authorization."""
from functools import wraps
from flask import request, g
from app.utils.response import api_response
from app.utils.constants import OrganizationRole
from gatehouse_app.utils.response import api_response
from gatehouse_app.utils.constants import OrganizationRole
def login_required(f):
@@ -11,7 +11,7 @@ def login_required(f):
Extracts token from Authorization: Bearer {token} header,
validates the session, and sets g.current_user and g.current_session.
"""
from app.services.session_service import SessionService
from gatehouse_app.services.session_service import SessionService
@wraps(f)
def decorated_function(*args, **kwargs):
@@ -61,7 +61,7 @@ def login_required(f):
# Update last_activity_at timestamp
from datetime import datetime, timezone
session.last_activity_at = datetime.now(timezone.utc)
from app import db
from gatehouse_app import db
db.session.commit()
# Set context variables
@@ -96,7 +96,7 @@ def require_role(*allowed_roles):
raise ForbiddenError("Organization context required")
# Check user's role in the organization
from app.models.organization_member import OrganizationMember
from gatehouse_app.models.organization_member import OrganizationMember
membership = OrganizationMember.query.filter_by(
user_id=g.current_user.id,
+1 -1
View File
@@ -6,7 +6,7 @@ from dotenv import load_dotenv
load_dotenv(dotenv_path=os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env'))
from flask.cli import FlaskGroup
from app import create_app
from gatehouse_app import create_app
# Create application
app = create_app(os.getenv("FLASK_ENV", "development"))
+44
View File
@@ -0,0 +1,44 @@
"""Database migration: Add WebAuthn support.
Revision ID: 002
Revises: 001
Create Date: 2024-01-15 00:00:00
This migration adds support for WebAuthn passkey authentication by:
- Adding WEBAUTHN to the AuthMethodType enum (handled in application code)
- No schema changes required (uses existing provider_data JSON field)
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# Revision identifiers
revision = '002'
down_revision = '001'
branch_labels = None
depends_on = None
def upgrade():
"""Add WebAuthn support - no schema changes needed."""
# WebAuthn credentials are stored in the existing provider_data JSON field
# of the authentication_methods table. No schema changes are required.
# Create an index for faster lookups of WebAuthn credentials by user
# This is optional but recommended for performance
# op.create_index(
# 'ix_authentication_methods_webauthn_user',
# 'authentication_methods',
# ['user_id'],
# postgresql_where=(sa.text("method_type = 'webauthn'")),
# if_not_exists=True
# )
pass
def downgrade():
"""Remove WebAuthn support - no schema changes needed."""
# No schema changes to revert
pass
+2 -2
View File
@@ -10,8 +10,8 @@ from dotenv import load_dotenv
load_dotenv(dotenv_path=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
# Import the Flask app and db
from app import create_app
from app.extensions import db
from gatehouse_app import create_app
from gatehouse_app.extensions import db
# Get the app
app = create_app(os.getenv("FLASK_ENV", "development"))
+4
View File
@@ -18,6 +18,10 @@ bcrypt==4.1.2
Flask-Bcrypt==1.0.1
pyotp==2.9.0
# WebAuthn / FIDO2
fido2==1.1.2
cbor2==5.6.0
# JWT / OIDC
PyJWT==2.8.0
cryptography==41.0.7
+2 -2
View File
@@ -1,6 +1,6 @@
"""Initialize database script."""
from app import create_app
from app.extensions import db
from gatehouse_app import create_app
from gatehouse_app.extensions import db
from dotenv import load_dotenv
# Load environment variables
+12 -12
View File
@@ -13,16 +13,16 @@ from dotenv import load_dotenv
# Load environment variables FIRST before any app imports
load_dotenv()
from app import create_app
from app.extensions import db
from app.models.user import User
from app.models.organization import Organization
from app.models.organization_member import OrganizationMember
from app.models.authentication_method import AuthenticationMethod
from app.models.oidc_client import OIDCClient
from app.services.auth_service import AuthService
from app.services.organization_service import OrganizationService
from app.utils.constants import OrganizationRole, UserStatus, AuthMethodType
from gatehouse_app import create_app
from gatehouse_app.extensions import db
from gatehouse_app.models.user import User
from gatehouse_app.models.organization import Organization
from gatehouse_app.models.organization_member import OrganizationMember
from gatehouse_app.models.authentication_method import AuthenticationMethod
from gatehouse_app.models.oidc_client import OIDCClient
from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.services.organization_service import OrganizationService
from gatehouse_app.utils.constants import OrganizationRole, UserStatus, AuthMethodType
# Create application
app = create_app()
@@ -121,7 +121,7 @@ def create_or_get_oidc_client(org_id, name, client_id, client_secret,
redirect_uris, grant_types, response_types, scopes,
**kwargs):
"""Create an OIDC client if it doesn't exist, or return existing client."""
from app.extensions import bcrypt
from gatehouse_app.extensions import bcrypt
existing = OIDCClient.query.filter_by(client_id=client_id, deleted_at=None).first()
if existing:
@@ -473,7 +473,7 @@ def seed_data():
require_pkce=True,
access_token_lifetime=1800, # 30 minutes
refresh_token_lifetime=604800, # 7 days
id_token_lifetime=1800, # 30 minutes
id_token_lifetime=1800, # 30 minutes,
)
oidc_clients["acme-mobile"] = acme_mobile_client
+6 -6
View File
@@ -1,10 +1,10 @@
"""Pytest configuration and fixtures."""
import pytest
from app import create_app
from app.extensions import db as _db
from app.models import User, Organization, OrganizationMember
from app.services.auth_service import AuthService
from app.utils.constants import OrganizationRole
from gatehouse_app import create_app
from gatehouse_app.extensions import db as _db
from gatehouse_app.models import User, Organization, OrganizationMember
from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.utils.constants import OrganizationRole
@pytest.fixture(scope="session")
@@ -52,7 +52,7 @@ def test_user(db):
@pytest.fixture(scope="function")
def test_organization(db, test_user):
"""Create a test organization."""
from app.services.organization_service import OrganizationService
from gatehouse_app.services.organization_service import OrganizationService
org = OrganizationService.create_organization(
name="Test Organization",
+26 -26
View File
@@ -100,7 +100,7 @@ class TestOIDCJWKS:
def test_jwks_contains_signing_key(self, client, app):
"""Test that JWKS contains a valid signing key."""
from app.services.oidc_jwks_service import OIDCJWKSService
from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService
with app.app_context():
# Initialize with a key
@@ -201,7 +201,7 @@ class TestOIDCAuthorizationCodeFlow:
@pytest.fixture
def test_client(self, client, test_organization, test_user):
"""Create a test OIDC client."""
from app.models import OIDCClient
from gatehouse_app.models import OIDCClient
client_data = OIDCClient(
organization_id=test_organization.id,
@@ -217,7 +217,7 @@ class TestOIDCAuthorizationCodeFlow:
is_confidential=True,
require_pkce=True,
)
from app.extensions import db
from gatehouse_app.extensions import db
db.session.add(client_data)
db.session.commit()
@@ -338,9 +338,9 @@ class TestOIDCAuthorizationCodeFlow:
def test_authorization_code_exchange_success(self, client, app, test_client, test_user):
"""Test successful token exchange with authorization code."""
from app.services.oidc_service import OIDCService
from app.models import OIDCAuthCode
from app.extensions import db
from gatehouse_app.services.oidc_service import OIDCService
from gatehouse_app.models import OIDCAuthCode
from gatehouse_app.extensions import db
# First, generate an authorization code
with app.app_context():
@@ -419,7 +419,7 @@ class TestOIDCAuthorizationCodeFlow:
def test_token_exchange_pkce_verification(self, client, app, test_client, test_user):
"""Test PKCE verification during token exchange."""
from app.services.oidc_service import OIDCService
from gatehouse_app.services.oidc_service import OIDCService
# Generate PKCE pair
code_verifier, code_challenge = self._generate_pkce_pair()
@@ -456,7 +456,7 @@ class TestOIDCAuthorizationCodeFlow:
def test_token_exchange_with_pkce_verifier(self, client, app, test_client, test_user):
"""Test successful token exchange with valid PKCE code verifier."""
from app.services.oidc_service import OIDCService
from gatehouse_app.services.oidc_service import OIDCService
# Generate PKCE pair
code_verifier, code_challenge = self._generate_pkce_pair()
@@ -499,8 +499,8 @@ class TestOIDCUserInfo:
@pytest.fixture
def test_client_with_user(self, client, test_organization, test_user):
"""Create a test OIDC client and get tokens."""
from app.models import OIDCClient
from app.services.oidc_service import OIDCService
from gatehouse_app.models import OIDCClient
from gatehouse_app.services.oidc_service import OIDCService
client_data = OIDCClient(
organization_id=test_organization.id,
@@ -516,7 +516,7 @@ class TestOIDCUserInfo:
is_confidential=False,
require_pkce=False,
)
from app.extensions import db
from gatehouse_app.extensions import db
db.session.add(client_data)
db.session.commit()
@@ -569,8 +569,8 @@ class TestOIDCUserInfo:
def test_userinfo_claims_by_scope(self, client, app, test_organization, test_user):
"""Test UserInfo returns correct claims based on scopes."""
from app.models import OIDCClient
from app.services.oidc_service import OIDCService
from gatehouse_app.models import OIDCClient
from gatehouse_app.services.oidc_service import OIDCService
# Create client with only openid scope
client_data = OIDCClient(
@@ -587,7 +587,7 @@ class TestOIDCUserInfo:
is_confidential=False,
require_pkce=False,
)
from app.extensions import db
from gatehouse_app.extensions import db
db.session.add(client_data)
db.session.commit()
@@ -620,8 +620,8 @@ class TestOIDCTokenRefresh:
@pytest.fixture
def test_client_with_refresh_token(self, client, test_organization, test_user):
"""Create a test OIDC client with refresh token."""
from app.models import OIDCClient
from app.services.oidc_service import OIDCService
from gatehouse_app.models import OIDCClient
from gatehouse_app.services.oidc_service import OIDCService
client_data = OIDCClient(
organization_id=test_organization.id,
@@ -637,7 +637,7 @@ class TestOIDCTokenRefresh:
is_confidential=False,
require_pkce=False,
)
from app.extensions import db
from gatehouse_app.extensions import db
db.session.add(client_data)
db.session.commit()
@@ -717,8 +717,8 @@ class TestOIDCTokenRevocation:
@pytest.fixture
def test_client_with_tokens(self, client, test_organization, test_user):
"""Create a test OIDC client with valid tokens."""
from app.models import OIDCClient
from app.services.oidc_service import OIDCService
from gatehouse_app.models import OIDCClient
from gatehouse_app.services.oidc_service import OIDCService
client_data = OIDCClient(
organization_id=test_organization.id,
@@ -734,7 +734,7 @@ class TestOIDCTokenRevocation:
is_confidential=False,
require_pkce=False,
)
from app.extensions import db
from gatehouse_app.extensions import db
db.session.add(client_data)
db.session.commit()
@@ -821,8 +821,8 @@ class TestOIDCTokenIntrospection:
@pytest.fixture
def test_client_with_tokens(self, client, test_organization, test_user):
"""Create a test OIDC client with valid tokens."""
from app.models import OIDCClient
from app.services.oidc_service import OIDCService
from gatehouse_app.models import OIDCClient
from gatehouse_app.services.oidc_service import OIDCService
client_data = OIDCClient(
organization_id=test_organization.id,
@@ -838,7 +838,7 @@ class TestOIDCTokenIntrospection:
is_confidential=False,
require_pkce=False,
)
from app.extensions import db
from gatehouse_app.extensions import db
db.session.add(client_data)
db.session.commit()
@@ -896,9 +896,9 @@ class TestOIDCCompleteFlow:
def test_complete_oidc_flow(self, client, app, test_organization, test_user):
"""Test complete OIDC authorization code flow with PKCE."""
from app.models import OIDCClient
from app.services.oidc_service import OIDCService
from app.extensions import db
from gatehouse_app.models import OIDCClient
from gatehouse_app.services.oidc_service import OIDCService
from gatehouse_app.extensions import db
# Create a test client
with app.app_context():
+2 -2
View File
@@ -1,8 +1,8 @@
"""Unit tests for models."""
import pytest
from datetime import datetime
from app.models import User, Organization
from app.utils.constants import UserStatus
from gatehouse_app.models import User, Organization
from gatehouse_app.utils.constants import UserStatus
@pytest.mark.unit
@@ -1,9 +1,9 @@
"""Unit tests for AuthService."""
import pytest
from app.services.auth_service import AuthService
from app.exceptions.auth_exceptions import InvalidCredentialsError
from app.exceptions.validation_exceptions import EmailAlreadyExistsError
from app.utils.constants import UserStatus, AuthMethodType
from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
from gatehouse_app.exceptions.validation_exceptions import EmailAlreadyExistsError
from gatehouse_app.utils.constants import UserStatus, AuthMethodType
@pytest.mark.unit
@@ -1,7 +1,7 @@
"""Unit tests for TOTPService."""
import base64
import pytest
from app.services.totp_service import TOTPService
from gatehouse_app.services.totp_service import TOTPService
@pytest.mark.unit
+5 -2
View File
@@ -6,10 +6,13 @@ from dotenv import load_dotenv, find_dotenv
load_dotenv(find_dotenv())
import os
from app import create_app
from gatehouse_app import create_app
# Create application instance
app = create_app(os.getenv("FLASK_ENV", "development"))
application = create_app(os.getenv("FLASK_ENV", "development"))
# For backwards compatibility
app = application
if __name__ == "__main__":
app.run()