From 5e060f267d217ea7c6f35e323717b389447bb2aa Mon Sep 17 00:00:00 2001 From: Cory Hawkvelt Date: Thu, 8 Jan 2026 15:59:53 +1030 Subject: [PATCH] major checkpoint --- .env.example | 5 +- README.md | 10 + SEED_DATA_OIDC_EXTENSION.md | 135 +++ app/__init__.py | 34 +- app/api/oidc.py | 964 ++++++++++++++++ app/extensions.py | 8 +- app/middleware/cors.py | 49 +- app/models/__init__.py | 10 + app/models/oidc_audit_log.py | 231 ++++ app/models/oidc_authorization_code.py | 120 ++ app/models/oidc_refresh_token.py | 159 +++ app/models/oidc_session.py | 162 +++ app/models/oidc_token_metadata.py | 192 ++++ app/services/__init__.py | 11 + app/services/oidc_audit_service.py | 408 +++++++ app/services/oidc_jwks_service.py | 300 +++++ app/services/oidc_service.py | 745 ++++++++++++ app/services/oidc_session_service.py | 288 +++++ app/services/oidc_token_service.py | 439 +++++++ config/__init__.py | 9 +- config/base.py | 34 +- config/development.py | 5 +- config/testing.py | 8 + docs/OIDC.md | 1516 +++++++++++++++++++++++++ docs/OIDC_TESTING.md | 572 ++++++++++ docs/oauth2-proxy-config.yaml | 251 ++++ manage.py | 9 +- migrations/001_create_oidc_tables.py | 150 +++ migrations/env.py | 74 ++ migrations/script.py.mako | 24 + requirements/base.txt | 6 +- scripts/seed_data.py | 175 ++- tests/integration/test_oidc_flow.py | 1028 +++++++++++++++++ 33 files changed, 8088 insertions(+), 43 deletions(-) create mode 100644 SEED_DATA_OIDC_EXTENSION.md create mode 100644 app/api/oidc.py create mode 100644 app/models/oidc_audit_log.py create mode 100644 app/models/oidc_authorization_code.py create mode 100644 app/models/oidc_refresh_token.py create mode 100644 app/models/oidc_session.py create mode 100644 app/models/oidc_token_metadata.py create mode 100644 app/services/oidc_audit_service.py create mode 100644 app/services/oidc_jwks_service.py create mode 100644 app/services/oidc_service.py create mode 100644 app/services/oidc_session_service.py create mode 100644 app/services/oidc_token_service.py create mode 100644 docs/OIDC.md create mode 100644 docs/OIDC_TESTING.md create mode 100644 docs/oauth2-proxy-config.yaml create mode 100644 migrations/001_create_oidc_tables.py create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 tests/integration/test_oidc_flow.py diff --git a/.env.example b/.env.example index c0bce3d..1a9b4e4 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ SECRET_KEY=your-secret-key-here-change-in-production # Database DATABASE_URL=postgresql://user:password@localhost:5432/authy2_dev SQLALCHEMY_ECHO=False +SQLALCHEMY_LOG_LEVEL=WARNING # Security BCRYPT_LOG_ROUNDS=12 @@ -15,7 +16,9 @@ SESSION_COOKIE_SAMESITE=Lax MAX_SESSION_DURATION=86400 # CORS -CORS_ORIGINS=http://localhost:3000,http://localhost:5173 +#CORS_ORIGINS=http://localhost:3000,http://localhost:5173,https://oidc-playpen.lovable.app/,http://localhost:8080/ +CORS_ORIGINS=* + # JWT (if using JWT instead of sessions) JWT_SECRET_KEY=your-jwt-secret-key-here diff --git a/README.md b/README.md index 182af5e..600d553 100644 --- a/README.md +++ b/README.md @@ -282,3 +282,13 @@ MIT For issues and questions: - GitHub Issues: [repository-url]/issues - Documentation: See `docs/` directory + + +# Boostrap db +python manage.py db upgrade +python manage.py db migrate + + + +## running seed +python -m scripts.seed_data diff --git a/SEED_DATA_OIDC_EXTENSION.md b/SEED_DATA_OIDC_EXTENSION.md new file mode 100644 index 0000000..d4b8fe9 --- /dev/null +++ b/SEED_DATA_OIDC_EXTENSION.md @@ -0,0 +1,135 @@ +# OIDC Extension to Seed Data Script + +## Summary + +Extended [`scripts/seed_data.py`](scripts/seed_data.py) to include OIDC client seeding functionality. + +## Changes Made + +### 1. Added Imports +- `import secrets` - For generating secure random values +- `import hashlib` - For hashing client secrets +- `from app.models.oidc_client import OIDCClient` - OIDC client model + +### 2. New Helper Function: `create_or_get_oidc_client()` +Creates OIDC clients with proper configuration or returns existing ones. Features: +- Checks for existing clients by `client_id` +- Hashes client secrets using SHA256 +- Supports all OIDC client configuration options +- Proper error handling and logging + +### 3. New Seed Step: Step 5 - Create OIDC Clients + +Added 4 OIDC clients across the 3 seeded organizations: + +#### Acme Corporation (2 clients) +1. **Acme Internal Portal** (`acme-portal-001`) + - Confidential client + - Grant types: authorization_code, refresh_token + - Scopes: openid, profile, email, offline_access + - PKCE required + - Redirect URIs for production and localhost + +2. **Acme Mobile App** (`acme-mobile-001`) + - Public client (mobile app) + - Shorter token lifetimes for security + - PKCE required + - Custom URL scheme for mobile redirect + +#### Tech Startup Inc (1 client) +3. **Tech Startup Dashboard** (`tech-dashboard-001`) + - Confidential client + - Standard OIDC configuration + - PKCE required + +#### Data Systems Inc (1 client) +4. **Data Systems API Client** (`data-api-001`) + - Confidential server-to-server client + - Additional grant type: client_credentials + - Custom scopes: api:read, api:write + - PKCE not required (server-to-server) + +## OIDC Client Test Credentials + +All clients are configured with test credentials for development: + +| Client | Client ID | Client Secret | +|--------|-----------|---------------| +| Acme Portal | `acme-portal-001` | `acme_secret_portal_2024` | +| Acme Mobile | `acme-mobile-001` | `acme_secret_mobile_2024` | +| Tech Dashboard | `tech-dashboard-001` | `tech_secret_dashboard_2024` | +| Data API | `data-api-001` | `data_secret_api_2024` | + +## Enhanced Summary Output + +The seed script now displays: +- Total count of OIDC clients created +- Detailed information for each client including: + - Client name and ID + - Organization + - Configured grant types + - Configured scopes + - Number of redirect URIs +- Complete test credentials table + +## Example Output + +``` +[Step 5] Creating OIDC Clients... + + Acme Corporation OIDC Clients: + → Created OIDC client: Acme Internal Portal + → Created OIDC client: Acme Mobile App + + Tech Startup OIDC Clients: + → Created OIDC client: Tech Startup Dashboard + + Data Systems OIDC Clients: + → Created OIDC client: Data Systems API Client + + Created 4 OIDC clients + +============================================================ +Seed Complete! +============================================================ + +šŸ“Š Summary: + Organizations: 3 + Admin Users: 2 + Regular Users: 9 + OIDC Clients: 4 + +šŸ” OIDC Clients: + Acme Internal Portal + Client ID: acme-portal-001 + Organization: Acme Corporation + Grant Types: authorization_code, refresh_token + Scopes: openid, profile, email, offline_access + Redirect URIs: 2 configured + ... +``` + +## Features + +- **Idempotent**: Running the script multiple times won't create duplicate clients +- **Comprehensive**: Creates diverse client types (confidential, public, server-to-server) +- **Production-ready**: Includes proper secret hashing and security configurations +- **Developer-friendly**: Includes localhost URLs and clear test credentials +- **Well-documented**: Clear console output showing what was created + +## Usage + +Run the seed script as usual: + +```bash +python scripts/seed_data.py +``` + +The OIDC clients will be automatically created along with users and organizations. + +## Security Notes + +- Client secrets are hashed using SHA256 before storage +- Test credentials are clearly marked and should **not** be used in production +- PKCE is enabled by default for web and mobile clients +- Token lifetimes are configured appropriately for each client type diff --git a/app/__init__.py b/app/__init__.py index 1c77f93..91cd09a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -3,12 +3,21 @@ import os import logging from flask import Flask from config import get_config -from app.extensions import db, migrate, bcrypt, cors, ma, limiter, session +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 import redis +# Configure SQLAlchemy logging BEFORE any database operations +# This must be done before db.init_app() to prevent verbose logging +_log_level_env = os.getenv("SQLALCHEMY_LOG_LEVEL", "WARNING").upper() +_sqlalchemy_log_level = getattr(logging, _log_level_env, logging.WARNING) +logging.getLogger('sqlalchemy').setLevel(_sqlalchemy_log_level) +logging.getLogger('sqlalchemy.engine').setLevel(_sqlalchemy_log_level) +logging.getLogger('sqlalchemy.dialects').setLevel(_sqlalchemy_log_level) +logging.getLogger('sqlalchemy.pool').setLevel(_sqlalchemy_log_level) + def create_app(config_name=None): """ @@ -53,12 +62,9 @@ def initialize_extensions(app): # Security bcrypt.init_app(app) - # CORS - cors.init_app( - app, - origins=app.config.get("CORS_ORIGINS", []), - supports_credentials=app.config.get("CORS_SUPPORTS_CREDENTIALS", True), - ) + # CORS - using custom middleware only (see app/middleware/cors.py) + # Flask-CORS disabled to avoid conflicts + # cors.init_app(app) # Marshmallow ma.init_app(app) @@ -84,15 +90,19 @@ def setup_middleware(app): """Setup application middleware.""" RequestIDMiddleware(app) SecurityHeadersMiddleware(app) - setup_cors(app, cors) + setup_cors(app) def register_blueprints(app): """Register application blueprints.""" from app.api import register_api_blueprints + from app.api.oidc import oidc_bp register_api_blueprints(app) + # Register OIDC blueprint at root level + app.register_blueprint(oidc_bp) + def register_error_handlers(app): """Register error handlers.""" @@ -169,7 +179,11 @@ def setup_logging(app): app.logger.setLevel(log_level) - # Reduce SQLAlchemy logging noise - logging.getLogger('sqlalchemy').setLevel(logging.WARNING) + # Configure SQLAlchemy logging level (also set at module level before DB init) + sqlalchemy_log_level = getattr(logging, app.config.get("SQLALCHEMY_LOG_LEVEL", "WARNING"), logging.WARNING) + logging.getLogger('sqlalchemy').setLevel(sqlalchemy_log_level) + logging.getLogger('sqlalchemy.engine').setLevel(sqlalchemy_log_level) + logging.getLogger('sqlalchemy.dialects').setLevel(sqlalchemy_log_level) + logging.getLogger('sqlalchemy.pool').setLevel(sqlalchemy_log_level) app.logger.info("Application startup") diff --git a/app/api/oidc.py b/app/api/oidc.py new file mode 100644 index 0000000..8d1d98a --- /dev/null +++ b/app/api/oidc.py @@ -0,0 +1,964 @@ +"""OIDC (OpenID Connect) API endpoints - Root level blueprint.""" +import base64 +import json +import secrets +from urllib.parse import urlencode, urlparse, parse_qs + +import bcrypt +from flask import Blueprint, request, redirect, jsonify, session, g, current_app, Response + +from app.utils.response import api_response +from app.services.oidc_service import ( + OIDCService, InvalidClientError, InvalidGrantError, InvalidRequestError +) +from app.services.auth_service import AuthService +from app.extensions import db +from app.models import User, OIDCClient +from app.models.organization import Organization +from app.exceptions.auth_exceptions import InvalidCredentialsError + + +# Create OIDC blueprint registered at root level +oidc_bp = Blueprint("oidc", __name__) + + +# ============================================================================ +# Helper Functions +# ============================================================================ + +def get_oidc_config(): + """Get OIDC configuration from app config.""" + base_url = current_app.config.get("OIDC_ISSUER_URL", "http://localhost:5000") + return { + "issuer": base_url, + "authorization_endpoint": f"{base_url}/oidc/authorize", + "token_endpoint": f"{base_url}/oidc/token", + "userinfo_endpoint": f"{base_url}/oidc/userinfo", + "jwks_uri": f"{base_url}/oidc/jwks", + "registration_endpoint": f"{base_url}/oidc/register", + "revocation_endpoint": f"{base_url}/oidc/revoke", + "introspection_endpoint": f"{base_url}/oidc/introspect", + "scopes_supported": ["openid", "profile", "email"], + "response_types_supported": ["code"], + "response_modes_supported": ["query"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + "claims_supported": ["sub", "name", "email", "email_verified"], + } + + +def authenticate_client(client_id, client_secret=None): + """Authenticate an OIDC client. + + Args: + client_id: The client ID + client_secret: Optional client secret + + Returns: + OIDCClient instance + + Raises: + InvalidClientError: If authentication fails + """ + client = OIDCClient.query.filter_by(client_id=client_id, is_active=True).first() + if not client: + raise InvalidClientError("Invalid client") + + if client.is_confidential and client_secret: + if not bcrypt.check_password_hash(client.client_secret_hash, client_secret): + raise InvalidClientError("Invalid client credentials") + + return client + + +def require_valid_token(): + """Validate Bearer token from Authorization header. + + Sets g.current_token and g.current_user on success. + + Raises: + InvalidGrantError: If token is invalid + """ + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + raise InvalidGrantError("Invalid token: Missing or invalid Authorization header") + + token = auth_header[7:] + claims = OIDCService.validate_access_token(token) + g.current_token = claims + + user = User.query.get(claims.get("sub")) + if not user: + raise InvalidGrantError("Invalid token: User not found") + + g.current_user = user + + +def parse_basic_auth(): + """Parse Basic authentication from Authorization header. + + Returns: + Tuple of (client_id, client_secret) or (None, None) + """ + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Basic "): + try: + encoded = auth_header[6:] + decoded = base64.b64decode(encoded).decode("utf-8") + client_id, client_secret = decoded.split(":", 1) + return client_id, client_secret + except Exception: + pass + return None, None + + +# ============================================================================ +# Discovery Endpoint +# ============================================================================ + +@oidc_bp.route("/.well-known/openid-configuration", methods=["GET"]) +def oidc_discovery(): + """OpenID Connect Discovery endpoint. + + Returns the OIDC configuration as JSON. + + Cache-Control: max-age=86400 + No authentication required. + + Returns: + 200: OIDC discovery document + """ + config = get_oidc_config() + + response = jsonify(config) + response.headers["Cache-Control"] = "max-age=86400" + return response, 200 + + +# ============================================================================ +# Authorization Endpoint +# ============================================================================ + +@oidc_bp.route("/oidc/authorize", methods=["GET", "POST"]) +def oidc_authorize(): + """OpenID Connect Authorization endpoint. + + Initiates the OIDC authentication flow. + + GET Parameters: + client_id: The client ID + redirect_uri: The redirect URI + response_type: Must be "code" for authorization code flow + scope: Space-separated scopes (e.g., "openid profile email") + state: Opaque state value for CSRF protection + nonce: Nonce for ID token replay protection + code_challenge: PKCE code challenge + code_challenge_method: PKCE method ("S256" or "plain") + prompt: "login", "consent", "select_account", "none" + max_age: Maximum authentication age in seconds + acr_values: Requested Authentication Context Class Reference + + POST Parameters: + Same as GET, plus: + email: User email + password: User password + + Returns: + 302: Redirect with authorization code or error + 200: Login page (GET when not authenticated) + 400: Invalid request + """ + # Parse request parameters + if request.method == "GET": + params = request.args.to_dict() + else: + params = request.form.to_dict() + + # Extract required parameters + client_id = params.get("client_id") + redirect_uri = params.get("redirect_uri") + response_type = params.get("response_type") + scope = params.get("scope", "") + state = params.get("state", "") + nonce = params.get("nonce", "") + code_challenge = params.get("code_challenge") + code_challenge_method = params.get("code_challenge_method") + + # Validate required parameters + errors = [] + if not client_id: + errors.append("client_id is required") + if not redirect_uri: + errors.append("redirect_uri is required") + if not response_type: + errors.append("response_type is required") + + if errors: + return _redirect_with_error(redirect_uri, "invalid_request", "; ".join(errors), state) + + # Validate response_type + if response_type != "code": + return _redirect_with_error( + redirect_uri, "unsupported_response_type", + "Only response_type=code is supported", state + ) + + # Validate client + client = OIDCClient.query.filter_by(client_id=client_id, is_active=True).first() + if not client: + return _redirect_with_error(redirect_uri, "unauthorized_client", "Invalid client", state) + + # Validate redirect URI + if not client.is_redirect_uri_allowed(redirect_uri): + return _redirect_with_error(redirect_uri, "invalid_request", "Invalid redirect_uri", state) + + # Validate scopes + requested_scopes = scope.split() if scope else [] + allowed_scopes = client.scopes or [] + valid_scopes = [s for s in requested_scopes if s in allowed_scopes] + + if not valid_scopes: + return _redirect_with_error(redirect_uri, "invalid_scope", "Invalid or no scopes requested", state) + + # Check if user is already authenticated via session + user_id = session.get("oidc_user_id") + + # Handle POST with credentials + if request.method == "POST" and not user_id: + email = params.get("email") + password = params.get("password") + + if not email or not password: + return _show_login_page( + client_id=client_id, + redirect_uri=redirect_uri, + scope=scope, + state=state, + nonce=nonce, + response_type=response_type, + error="Invalid credentials" + ) + + try: + user = AuthService.authenticate(email, password) + user_id = user.id + session["oidc_user_id"] = user_id + except InvalidCredentialsError: + return _show_login_page( + client_id=client_id, + redirect_uri=redirect_uri, + scope=scope, + state=state, + nonce=nonce, + response_type=response_type, + error="Invalid email or password" + ) + + # If no user, show login page + if not user_id: + return _show_login_page( + client_id=client_id, + redirect_uri=redirect_uri, + scope=scope, + state=state, + nonce=nonce, + response_type=response_type + ) + + # User is authenticated, generate authorization code + user = User.query.get(user_id) + if not user: + return _redirect_with_error(redirect_uri, "server_error", "User not found", state) + + try: + code = OIDCService.generate_authorization_code( + client_id=client_id, + user_id=user_id, + redirect_uri=redirect_uri, + scope=valid_scopes, + state=state, + nonce=nonce, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + ip_address=request.remote_addr, + user_agent=request.headers.get("User-Agent"), + ) + except Exception as e: + return _redirect_with_error(redirect_uri, "server_error", str(e), state) + + # Redirect with authorization code + redirect_params = {"code": code} + if state: + redirect_params["state"] = state + + return redirect(f"{redirect_uri}?{urlencode(redirect_params)}") + + +def _redirect_with_error(redirect_uri, error, error_description, state=None): + """Redirect to client with error parameters.""" + if not redirect_uri: + return api_response( + success=False, + message=error_description, + status=400, + error_type=error.upper(), + error_details={"error": error, "error_description": error_description}, + ) + + params = { + "error": error, + "error_description": error_description, + } + if state: + params["state"] = state + + return redirect(f"{redirect_uri}?{urlencode(params)}") + + +def _show_login_page(client_id, redirect_uri, scope, state, nonce, response_type, error=None): + """Show the login page for authorization.""" + # Simple HTML login page + html = f""" + + + + Sign In - OIDC Authorization + + + +
+

Sign In

+ {"

" + error + "

" if error else ""} +
+ + + + + + + +
+ + +
+ +
+ + +
+ + +
+

+ Cancel +

+
+ + + """ + return Response(html, mimetype="text/html"), 200 + + +# ============================================================================ +# Token Endpoint +# ============================================================================ + +@oidc_bp.route("/oidc/token", methods=["POST"]) +def oidc_token(): + """OpenID Connect Token endpoint. + + Exchanges authorization code for tokens or refreshes tokens. + + Request body (application/x-www-form-urlencoded): + grant_type: "authorization_code" or "refresh_token" + + For authorization_code: + code: The authorization code + redirect_uri: The redirect URI used in authorization + client_id: The client ID + client_secret: The client secret (optional if using Basic auth) + code_verifier: PKCE code verifier (optional) + + For refresh_token: + refresh_token: The refresh token + scope: Optional scope override + client_id: The client ID + client_secret: The client secret (optional if using Basic auth) + + Authentication: + - Basic auth with client_id:client_secret, or + - client_id + client_secret in request body + + Returns: + 200: JSON with tokens + 400: Invalid request + 401: Invalid client + """ + # Parse request body + if request.content_type and "application/x-www-form-urlencoded" in request.content_type: + data = request.form.to_dict() + else: + data = request.json or {} + + grant_type = data.get("grant_type") + + # Validate grant_type + if not grant_type: + return api_response( + success=False, + message="grant_type is required", + status=400, + error_type="INVALID_REQUEST", + error_details={"error": "invalid_request", "error_description": "grant_type is required"}, + ) + + # Authenticate client + client_id = data.get("client_id") + client_secret = data.get("client_secret") + + # Try Basic auth if client_id not in body + if not client_id: + client_id, client_secret = parse_basic_auth() + + if not client_id: + # Return 401 with WWW-Authenticate header for Basic auth + response = jsonify({ + "error": "invalid_client", + "error_description": "Client authentication required" + }) + response.headers["WWW-Authenticate"] = 'Basic realm="OIDC Token Endpoint"' + return response, 401 + + try: + client = authenticate_client(client_id, client_secret) + except InvalidClientError: + response = jsonify({ + "error": "invalid_client", + "error_description": "Invalid client credentials" + }) + return response, 401 + + # Handle authorization_code grant + if grant_type == "authorization_code": + return _handle_authorization_code_grant(data, client) + + # Handle refresh_token grant + elif grant_type == "refresh_token": + return _handle_refresh_token_grant(data, client) + + # Unsupported grant type + else: + return api_response( + success=False, + message="Unsupported grant_type", + status=400, + error_type="UNSUPPORTED_GRANT_TYPE", + error_details={"error": "unsupported_grant_type", "error_description": f"Grant type '{grant_type}' is not supported"}, + ) + + +def _handle_authorization_code_grant(data, client): + """Handle authorization_code grant type.""" + code = data.get("code") + redirect_uri = data.get("redirect_uri") + code_verifier = data.get("code_verifier") + + if not code: + return api_response( + success=False, + message="code is required", + status=400, + error_type="INVALID_REQUEST", + error_details={"error": "invalid_request", "error_description": "code is required"}, + ) + + if not redirect_uri: + return api_response( + success=False, + message="redirect_uri is required", + status=400, + error_type="INVALID_REQUEST", + error_details={"error": "invalid_request", "error_description": "redirect_uri is required"}, + ) + + try: + claims, user = OIDCService.validate_authorization_code( + code=code, + client_id=client.client_id, + redirect_uri=redirect_uri, + code_verifier=code_verifier, + ip_address=request.remote_addr, + user_agent=request.headers.get("User-Agent"), + ) + except InvalidGrantError as e: + return api_response( + success=False, + message=str(e), + status=400, + error_type="INVALID_GRANT", + error_details={"error": "invalid_grant", "error_description": str(e)}, + ) + + # Generate tokens + try: + tokens = OIDCService.generate_tokens( + client_id=client.client_id, + user_id=claims["user_id"], + scope=claims["scope"], + nonce=claims.get("nonce"), + ip_address=request.remote_addr, + user_agent=request.headers.get("User-Agent"), + auth_time=int(__import__("time").time()), + ) + except Exception as e: + return api_response( + success=False, + message="Failed to generate tokens", + status=500, + error_type="SERVER_ERROR", + error_details={"error": "server_error", "error_description": str(e)}, + ) + + return api_response( + data=tokens, + message="Tokens issued successfully", + status=200, + ) + + +def _handle_refresh_token_grant(data, client): + """Handle refresh_token grant type.""" + refresh_token = data.get("refresh_token") + scope = data.get("scope") + + if not refresh_token: + return api_response( + success=False, + message="refresh_token is required", + status=400, + error_type="INVALID_REQUEST", + error_details={"error": "invalid_request", "error_description": "refresh_token is required"}, + ) + + # Parse scope if provided + scope_list = scope.split() if scope else None + + try: + tokens = OIDCService.refresh_access_token( + refresh_token=refresh_token, + client_id=client.client_id, + scope=scope_list, + ip_address=request.remote_addr, + user_agent=request.headers.get("User-Agent"), + ) + except InvalidGrantError as e: + return api_response( + success=False, + message=str(e), + status=400, + error_type="INVALID_GRANT", + error_details={"error": "invalid_grant", "error_description": str(e)}, + ) + + return api_response( + data=tokens, + message="Tokens refreshed successfully", + status=200, + ) + + +# ============================================================================ +# UserInfo Endpoint +# ============================================================================ + +@oidc_bp.route("/oidc/userinfo", methods=["GET", "POST"]) +def oidc_userinfo(): + """OpenID Connect UserInfo endpoint. + + Returns claims about the authenticated user. + + Authorization: Bearer {access_token} + + Returns claims based on granted scopes: + - sub: User ID (always included) + - name: User full name (if "profile" scope) + - email: User email (if "email" scope) + - email_verified: Email verification status (if "email" scope) + + Returns: + 200: User claims + 401: Invalid or insufficient token + """ + try: + require_valid_token() + except InvalidGrantError as e: + return api_response( + success=False, + message=str(e), + status=401, + error_type="INVALID_TOKEN", + error_details={"error": "invalid_token", "error_description": str(e)}, + ) + + # Get userinfo + try: + userinfo = OIDCService.get_userinfo(g.current_token.get("access_token", "")) + except Exception as e: + return api_response( + success=False, + message="Failed to get user info", + status=500, + error_type="SERVER_ERROR", + error_details={"error": "server_error", "error_description": str(e)}, + ) + + return api_response( + data=userinfo, + message="User info retrieved successfully", + status=200, + ) + + +# ============================================================================ +# JWKS Endpoint +# ============================================================================ + +@oidc_bp.route("/oidc/jwks", methods=["GET"]) +def oidc_jwks(): + """OpenID Connect JSON Web Key Set endpoint. + + Returns the public keys used to sign tokens. + + Cache-Control: max-age=3600 + No authentication required. + + Returns: + 200: JWKS document + """ + try: + jwks = OIDCService.get_jwks() + except Exception as e: + return api_response( + success=False, + message="Failed to get JWKS", + status=500, + error_type="SERVER_ERROR", + error_details={"error": "server_error", "error_description": str(e)}, + ) + + response = jsonify(jwks) + response.headers["Cache-Control"] = "max-age=3600" + return response, 200 + + +# ============================================================================ +# Token Revocation Endpoint +# ============================================================================ + +@oidc_bp.route("/oidc/revoke", methods=["POST"]) +def oidc_revoke(): + """OAuth2 Token Revocation endpoint. + + Revokes an access token or refresh token. + + Request body (application/x-www-form-urlencoded): + token: The token to revoke + token_type_hint: Optional hint ("access_token" or "refresh_token") + client_id: The client ID + client_secret: The client secret (optional if using Basic auth) + + Authentication: + - Basic auth with client_id:client_secret, or + - client_id + client_secret in request body + + Returns: + 200: Token revoked successfully + 400: Invalid request + 401: Invalid client + """ + # Parse request body + if request.content_type and "application/x-www-form-urlencoded" in request.content_type: + data = request.form.to_dict() + else: + data = request.json or {} + + token = data.get("token") + + if not token: + return api_response( + success=False, + message="token is required", + status=400, + error_type="INVALID_REQUEST", + error_details={"error": "invalid_request", "error_description": "token is required"}, + ) + + # Authenticate client + client_id = data.get("client_id") + client_secret = data.get("client_secret") + + if not client_id: + client_id, client_secret = parse_basic_auth() + + if not client_id: + response = jsonify({ + "error": "invalid_client", + "error_description": "Client authentication required" + }) + response.headers["WWW-Authenticate"] = 'Basic realm="OIDC Revoke Endpoint"' + return response, 401 + + try: + client = authenticate_client(client_id, client_secret) + except InvalidClientError: + response = jsonify({ + "error": "invalid_client", + "error_description": "Invalid client credentials" + }) + return response, 401 + + token_type_hint = data.get("token_type_hint") + + try: + OIDCService.revoke_token( + token=token, + client_id=client.client_id, + token_type_hint=token_type_hint, + ip_address=request.remote_addr, + user_agent=request.headers.get("User-Agent"), + ) + except Exception as e: + # Revocation should succeed even if token is invalid + pass + + return api_response( + message="Token revoked successfully", + status=200, + ) + + +# ============================================================================ +# Token Introspection Endpoint +# ============================================================================ + +@oidc_bp.route("/oidc/introspect", methods=["POST"]) +def oidc_introspect(): + """OAuth2 Token Introspection endpoint. + + Returns information about a token. + + Request body (application/x-www-form-urlencoded): + token: The token to introspect + token_type_hint: Optional hint ("access_token" or "refresh_token") + client_id: The client ID + client_secret: The client secret (optional if using Basic auth) + + Authentication: + - Basic auth with client_id:client_secret, or + - client_id + client_secret in request body + + Returns: + 200: Token status and claims + 400: Invalid request + 401: Invalid client + """ + # Parse request body + if request.content_type and "application/x-www-form-urlencoded" in request.content_type: + data = request.form.to_dict() + else: + data = request.json or {} + + token = data.get("token") + + if not token: + return api_response( + success=False, + message="token is required", + status=400, + error_type="INVALID_REQUEST", + error_details={"error": "invalid_request", "error_description": "token is required"}, + ) + + # Authenticate client + client_id = data.get("client_id") + client_secret = data.get("client_secret") + + if not client_id: + client_id, client_secret = parse_basic_auth() + + if not client_id: + response = jsonify({ + "error": "invalid_client", + "error_description": "Client authentication required" + }) + response.headers["WWW-Authenticate"] = 'Basic realm="OIDC Introspect Endpoint"' + return response, 401 + + try: + client = authenticate_client(client_id, client_secret) + except InvalidClientError: + response = jsonify({ + "error": "invalid_client", + "error_description": "Invalid client credentials" + }) + return response, 401 + + token_type_hint = data.get("token_type_hint") + + try: + result = OIDCService.introspect_token( + token=token, + client_id=client.client_id, + ip_address=request.remote_addr, + user_agent=request.headers.get("User-Agent"), + ) + except Exception as e: + return api_response( + success=False, + message="Failed to introspect token", + status=500, + error_type="SERVER_ERROR", + error_details={"error": "server_error", "error_description": str(e)}, + ) + + return api_response( + data=result, + message="Token introspection successful", + status=200, + ) + + +# ============================================================================ +# Client Registration Endpoint (Optional) +# ============================================================================ + +@oidc_bp.route("/oidc/register", methods=["POST"]) +def oidc_register(): + """OpenID Connect Client Registration endpoint. + + Registers a new OIDC client. + + Request body (application/json): + client_name: Name of the client + redirect_uris: List of redirect URIs + token_endpoint_auth_method: "client_secret_basic" or "client_secret_post" + grant_types: List of grant types ["authorization_code", "refresh_token"] + response_types: List of response types ["code"] + scope: Space-separated scopes (default: "openid profile email") + + Returns: + 201: Client registered successfully + 400: Invalid request + """ + data = request.json or {} + + # Validate required fields + client_name = data.get("client_name") + redirect_uris = data.get("redirect_uris", []) + + if not client_name: + return api_response( + success=False, + message="client_name is required", + status=400, + error_type="INVALID_REQUEST", + error_details={"error": "invalid_request", "error_description": "client_name is required"}, + ) + + if not redirect_uris: + return api_response( + success=False, + message="redirect_uris is required", + status=400, + error_type="INVALID_REQUEST", + error_details={"error": "invalid_request", "error_description": "redirect_uris is required"}, + ) + + # Validate redirect_uris + for uri in redirect_uris: + try: + parsed = urlparse(uri) + if not parsed.scheme or not parsed.netloc: + raise ValueError(f"Invalid redirect URI: {uri}") + except Exception: + return api_response( + success=False, + message=f"Invalid redirect_uri: {uri}", + status=400, + error_type="INVALID_REQUEST", + error_details={"error": "invalid_request", "error_description": f"Invalid redirect_uri: {uri}"}, + ) + + # Generate client credentials + client_id = f"oidc_{secrets.token_urlsafe(16)}" + client_secret = f"secret_{secrets.token_urlsafe(24)}" + client_secret_hash = bcrypt.generate_password_hash(client_secret).decode("utf-8") + + # Get organization from request or default + org_id = data.get("organization_id") + if org_id: + organization = Organization.query.get(org_id) + else: + # Get first active organization or create a default one + organization = Organization.query.filter_by(is_active=True).first() + + if not organization: + # Create a default organization for the client + organization = Organization( + name=f"OIDC Clients", + slug=f"oidc-clients-{secrets.token_urlsafe(8)}", + ) + organization.save() + + # Create OIDC client + client = OIDCClient( + organization_id=organization.id, + name=client_name, + client_id=client_id, + client_secret_hash=client_secret_hash, + redirect_uris=redirect_uris, + grant_types=data.get("grant_types", ["authorization_code", "refresh_token"]), + response_types=data.get("response_types", ["code"]), + scopes=data.get("scope", "openid profile email").split(), + token_endpoint_auth_method=data.get("token_endpoint_auth_method", "client_secret_basic"), + is_active=True, + is_confidential=True, + require_pkce=True, + logo_uri=data.get("logo_uri"), + client_uri=data.get("client_uri"), + policy_uri=data.get("policy_uri"), + tos_uri=data.get("tos_uri"), + ) + client.save() + + # Return client credentials + return api_response( + data={ + "client_id": client_id, + "client_secret": client_secret, + "client_id_issued_at": int(__import__("time").time()), + "client_secret_expires_at": 0, # Never expires + "client_name": client_name, + "redirect_uris": redirect_uris, + "token_endpoint_auth_method": data.get("token_endpoint_auth_method", "client_secret_basic"), + "grant_types": client.grant_types, + "response_types": client.response_types, + "scope": " ".join(client.scopes), + }, + message="Client registered successfully", + status=201, + ) diff --git a/app/extensions.py b/app/extensions.py index 9113e1c..74da883 100644 --- a/app/extensions.py +++ b/app/extensions.py @@ -12,13 +12,7 @@ from flask_session import Session db = SQLAlchemy() migrate = Migrate() bcrypt = Bcrypt() -cors = CORS( - supports_credentials=True, - resources={r"/api/*": {"origins": "*"}}, # Apply CORS to all API routes - allow_headers=["Content-Type", "Authorization", "X-Request-ID"], - methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - expose_headers=["X-Request-ID"], -) +cors = CORS() ma = Marshmallow() limiter = Limiter( key_func=get_remote_address, diff --git a/app/middleware/cors.py b/app/middleware/cors.py index b59e642..85f3aba 100644 --- a/app/middleware/cors.py +++ b/app/middleware/cors.py @@ -1,17 +1,40 @@ """CORS middleware configuration.""" -from flask import request +from flask import request, make_response -def setup_cors(app, cors): +def setup_cors(app): """ Configure CORS for the application. Args: app: Flask application instance - cors: Flask-CORS instance """ - # CORS is already initialized in extensions.py - # This function provides additional configuration if needed + + @app.before_request + def handle_preflight(): + """Handle CORS preflight OPTIONS requests.""" + if request.method == "OPTIONS": + origin = request.headers.get("Origin") + cors_origins = app.config.get("CORS_ORIGINS", []) + + # Allow all origins if CORS_ORIGINS is "*" (string) or ["*"] (list with wildcard) + allow_all = cors_origins == "*" or (isinstance(cors_origins, list) and "*" in cors_origins) + + if allow_all: + response = make_response("", 204) + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, X-Request-ID" + response.headers["Access-Control-Max-Age"] = "3600" + return response + elif origin and origin in cors_origins: + response = make_response("", 204) + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, X-Request-ID" + response.headers["Access-Control-Allow-Credentials"] = "true" + response.headers["Access-Control-Max-Age"] = "3600" + return response @app.after_request def after_request_cors(response): @@ -19,11 +42,21 @@ def setup_cors(app, cors): origin = request.headers.get("Origin") cors_origins = app.config.get("CORS_ORIGINS", []) - # Allow all origins in development if CORS_ORIGINS is "*" - if cors_origins == "*" or origin in cors_origins: - response.headers["Access-Control-Allow-Origin"] = origin if cors_origins != "*" else "*" + # Allow all origins if CORS_ORIGINS is "*" (string) or ["*"] (list with wildcard) + allow_all = cors_origins == "*" or (isinstance(cors_origins, list) and "*" in cors_origins) + + if allow_all: + # When allowing all origins, set header to "*" + response.headers["Access-Control-Allow-Origin"] = "*" response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS" response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, X-Request-ID" response.headers["Access-Control-Max-Age"] = "3600" + elif origin and origin in cors_origins: + # When allowing specific origins, echo the request origin + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, X-Request-ID" + response.headers["Access-Control-Allow-Credentials"] = "true" + response.headers["Access-Control-Max-Age"] = "3600" return response diff --git a/app/models/__init__.py b/app/models/__init__.py index c7bdf26..89ff93d 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -7,6 +7,11 @@ 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", @@ -17,4 +22,9 @@ __all__ = [ "Session", "AuditLog", "OIDCClient", + "OIDCAuthCode", + "OIDCRefreshToken", + "OIDCSession", + "OIDCTokenMetadata", + "OIDCAuditLog", ] diff --git a/app/models/oidc_audit_log.py b/app/models/oidc_audit_log.py new file mode 100644 index 0000000..3b1ebcb --- /dev/null +++ b/app/models/oidc_audit_log.py @@ -0,0 +1,231 @@ +"""OIDC Audit Log model for comprehensive OIDC event tracking.""" +from datetime import datetime +from app.extensions import db +from app.models.base import BaseModel + + +class OIDCAuditLog(BaseModel): + """OIDC Audit Log model for comprehensive OIDC event tracking. + + This model logs all OIDC-related events for security, compliance, + and debugging purposes. + """ + + __tablename__ = "oidc_audit_logs" + + # Event type categorization + event_type = db.Column(db.String(100), nullable=False, index=True) + + # Client and User references + client_id = db.Column( + db.String(255), db.ForeignKey("oidc_clients.id"), nullable=True, index=True + ) + user_id = db.Column( + db.String(36), db.ForeignKey("users.id"), nullable=True, index=True + ) + + # Event outcome + success = db.Column(db.Boolean, default=True, nullable=False, index=True) + + # Error details (for failed events) + error_code = db.Column(db.String(100), nullable=True) + error_description = db.Column(db.Text, nullable=True) + + # Request context + ip_address = db.Column(db.String(45), nullable=True, index=True) + user_agent = db.Column(db.Text, nullable=True) + request_id = db.Column(db.String(36), nullable=True, index=True) + + # Additional event metadata + event_metadata = db.Column(db.JSON, nullable=True) + + # Relationships + client = db.relationship("OIDCClient", back_populates="audit_logs") + user = db.relationship("User", back_populates="oidc_audit_logs") + + def __repr__(self): + """String representation of OIDCAuditLog.""" + status = "success" if self.success else "failed" + return f"" + + @classmethod + def log_event(cls, event_type, client_id=None, user_id=None, success=True, + error_code=None, error_description=None, ip_address=None, + user_agent=None, request_id=None, event_metadata=None): + """Log an OIDC event. + + Args: + event_type: Type of event (e.g., "authorization_request", "token_issue") + client_id: The OIDC client ID + user_id: The user ID + success: Whether the event was successful + error_code: Error code if event failed + error_description: Error description if event failed + ip_address: Client IP address + user_agent: Client user agent + request_id: Request ID for correlation + event_metadata: Additional event metadata + + Returns: + OIDCAuditLog instance + """ + log = cls( + event_type=event_type, + client_id=client_id, + user_id=user_id, + success=success, + error_code=error_code, + error_description=error_description, + ip_address=ip_address, + user_agent=user_agent, + request_id=request_id, + event_metadata=event_metadata, + ) + db.session.add(log) + db.session.commit() + return log + + @classmethod + def log_authorization_request(cls, client_id, user_id, redirect_uri, scope, + ip_address=None, user_agent=None, request_id=None, + success=True, error_code=None, error_description=None): + """Log an authorization request event.""" + return cls.log_event( + event_type="authorization_request", + client_id=client_id, + user_id=user_id, + success=success, + error_code=error_code, + error_description=error_description, + ip_address=ip_address, + user_agent=user_agent, + request_id=request_id, + event_metadata={ + "redirect_uri": redirect_uri, + "scope": scope, + } + ) + + @classmethod + def log_token_issue(cls, client_id, user_id, token_type, + ip_address=None, user_agent=None, request_id=None): + """Log a token issuance event.""" + return cls.log_event( + event_type="token_issue", + client_id=client_id, + user_id=user_id, + success=True, + ip_address=ip_address, + user_agent=user_agent, + request_id=request_id, + event_metadata={"token_type": token_type} + ) + + @classmethod + def log_token_revocation(cls, client_id, user_id, token_type, reason=None, + ip_address=None, user_agent=None, request_id=None): + """Log a token revocation event.""" + return cls.log_event( + event_type="token_revocation", + client_id=client_id, + user_id=user_id, + success=True, + ip_address=ip_address, + user_agent=user_agent, + request_id=request_id, + event_metadata={ + "token_type": token_type, + "reason": reason, + } + ) + + @classmethod + def log_authentication_failure(cls, client_id, error_code, error_description, + ip_address=None, user_agent=None, request_id=None): + """Log an authentication failure event.""" + return cls.log_event( + event_type="authentication_failure", + client_id=client_id, + success=False, + error_code=error_code, + error_description=error_description, + ip_address=ip_address, + user_agent=user_agent, + request_id=request_id, + ) + + @classmethod + def get_events_for_user(cls, user_id, limit=100): + """Get audit events for a user. + + Args: + user_id: The user ID + limit: Maximum number of events to return + + Returns: + List of OIDCAuditLog instances + """ + return cls.query.filter_by(user_id=user_id, deleted_at=None)\ + .order_by(cls.created_at.desc())\ + .limit(limit)\ + .all() + + @classmethod + def get_events_for_client(cls, client_id, limit=100): + """Get audit events for a client. + + Args: + client_id: The client ID + limit: Maximum number of events to return + + Returns: + List of OIDCAuditLog instances + """ + return cls.query.filter_by(client_id=client_id, deleted_at=None)\ + .order_by(cls.created_at.desc())\ + .limit(limit)\ + .all() + + @classmethod + def get_failed_events(cls, client_id=None, user_id=None, start_date=None, + end_date=None, limit=100): + """Get failed audit events. + + Args: + client_id: Optional client ID filter + user_id: Optional user ID filter + start_date: Optional start date filter + end_date: Optional end date filter + limit: Maximum number of events to return + + Returns: + List of OIDCAuditLog instances + """ + query = cls.query.filter_by(success=False, deleted_at=None) + if client_id: + query = query.filter_by(client_id=client_id) + if user_id: + query = query.filter_by(user_id=user_id) + if start_date: + query = query.filter(cls.created_at >= start_date) + if end_date: + query = query.filter(cls.created_at <= end_date) + + return query.order_by(cls.created_at.desc()).limit(limit).all() + + def to_dict(self, exclude=None): + """Convert to dictionary.""" + return super().to_dict(exclude=exclude) + + +# Add relationship back to User model +from 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 +OIDCClient.audit_logs = db.relationship( + "OIDCAuditLog", back_populates="client", cascade="all, delete-orphan" +) diff --git a/app/models/oidc_authorization_code.py b/app/models/oidc_authorization_code.py new file mode 100644 index 0000000..4d39cea --- /dev/null +++ b/app/models/oidc_authorization_code.py @@ -0,0 +1,120 @@ +"""OIDC Authorization Code model for auth code flow.""" +from datetime import datetime, timedelta +from app.extensions import db +from app.models.base import BaseModel + + +class OIDCAuthCode(BaseModel): + """OIDC Authorization Code model for authorization code flow. + + Authorization codes are single-use, short-lived codes used in the + authorization code grant flow. The code is hashed for security. + """ + + __tablename__ = "oidc_authorization_codes" + + # Client and User references + client_id = db.Column( + db.String(255), db.ForeignKey("oidc_clients.id"), nullable=False, index=True + ) + user_id = db.Column( + db.String(36), db.ForeignKey("users.id"), nullable=False, index=True + ) + + # Authorization code (hashed for security) + code_hash = db.Column(db.String(255), nullable=False) + + # Request parameters + redirect_uri = db.Column(db.String(512), nullable=False) + scope = db.Column(db.JSON, nullable=True) # Requested scopes + nonce = db.Column(db.String(255), nullable=True) # For OIDC ID Token validation + code_verifier = db.Column(db.String(255), nullable=True) # For PKCE + + # Status tracking + expires_at = db.Column(db.DateTime, nullable=False, index=True) + used_at = db.Column(db.DateTime, nullable=True) + is_used = db.Column(db.Boolean, default=False, nullable=False) + + # Request metadata + ip_address = db.Column(db.String(45), nullable=True) + user_agent = db.Column(db.Text, nullable=True) + + # Relationships + client = db.relationship("OIDCClient", back_populates="authorization_codes") + user = db.relationship("User", back_populates="oidc_auth_codes") + + def __repr__(self): + """String representation of OIDCAuthCode.""" + return f"" + + def is_expired(self): + """Check if the authorization code has expired.""" + return datetime.utcnow() > self.expires_at + + def is_valid(self): + """Check if the authorization code is valid for use.""" + return not self.is_used and not self.is_expired() and self.deleted_at is None + + def mark_as_used(self): + """Mark the authorization code as used.""" + self.is_used = True + self.used_at = datetime.utcnow() + db.session.commit() + + @classmethod + def create_code(cls, client_id, user_id, code_hash, redirect_uri, scope=None, + nonce=None, code_verifier=None, ip_address=None, user_agent=None, + lifetime_seconds=600): + """Create a new authorization code. + + Args: + client_id: The OIDC client ID + user_id: The user ID + code_hash: Hashed authorization code + redirect_uri: The redirect URI + scope: Requested scopes + nonce: OIDC nonce + code_verifier: PKCE code verifier + ip_address: Client IP address + user_agent: Client user agent + lifetime_seconds: Code lifetime in seconds (default 10 minutes) + + Returns: + OIDCAuthCode instance + """ + code = cls( + client_id=client_id, + user_id=user_id, + code_hash=code_hash, + redirect_uri=redirect_uri, + scope=scope, + nonce=nonce, + code_verifier=code_verifier, + expires_at=datetime.utcnow() + timedelta(seconds=lifetime_seconds), + ip_address=ip_address, + user_agent=user_agent, + ) + db.session.add(code) + db.session.commit() + return code + + def to_dict(self, exclude=None): + """Convert to dictionary, excluding sensitive fields.""" + exclude = exclude or [] + # Always exclude code hash + exclude.append("code_hash") + exclude.append("code_verifier") + return super().to_dict(exclude=exclude) + + +# Add relationship back to User model +from 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 +OIDCClient.authorization_codes = db.relationship( + "OIDCAuthCode", back_populates="client", cascade="all, delete-orphan" +) diff --git a/app/models/oidc_refresh_token.py b/app/models/oidc_refresh_token.py new file mode 100644 index 0000000..e499915 --- /dev/null +++ b/app/models/oidc_refresh_token.py @@ -0,0 +1,159 @@ +"""OIDC Refresh Token model for token rotation.""" +from datetime import datetime +from app.extensions import db +from app.models.base import BaseModel + + +class OIDCRefreshToken(BaseModel): + """OIDC Refresh Token model for token refresh and rotation. + + Refresh tokens are long-lived credentials used to obtain new access tokens. + They support token rotation for enhanced security. + """ + + __tablename__ = "oidc_refresh_tokens" + + # Client and User references + client_id = db.Column( + db.String(255), db.ForeignKey("oidc_clients.id"), nullable=False, index=True + ) + user_id = db.Column( + db.String(36), db.ForeignKey("users.id"), nullable=False, index=True + ) + + # Token (hashed for security) + token_hash = db.Column(db.String(255), nullable=False, unique=True, index=True) + + # Associated access token ID + access_token_id = db.Column( + db.String(36), db.ForeignKey("sessions.id"), nullable=True, index=True + ) + + # Token scope + scope = db.Column(db.JSON, nullable=True) # Granted scopes + + # Timing + expires_at = db.Column(db.DateTime, nullable=False, index=True) + + # Revocation tracking + revoked_at = db.Column(db.DateTime, nullable=True) + revoked_reason = db.Column(db.String(255), nullable=True) + + # Token rotation metadata + previous_token_hash = db.Column(db.String(255), nullable=True) # For rotation + rotation_count = db.Column(db.Integer, default=0, nullable=False) + + # Request metadata + ip_address = db.Column(db.String(45), nullable=True) + user_agent = db.Column(db.Text, nullable=True) + + # Relationships + client = db.relationship("OIDCClient", back_populates="refresh_tokens") + user = db.relationship("User", back_populates="oidc_refresh_tokens") + access_token = db.relationship("Session", back_populates="oidc_refresh_token") + + def __repr__(self): + """String representation of OIDCRefreshToken.""" + return f"" + + def is_expired(self): + """Check if the refresh token has expired.""" + return datetime.utcnow() > self.expires_at + + def is_revoked(self): + """Check if the refresh token has been revoked.""" + return self.revoked_at is not None + + def is_valid(self): + """Check if the refresh token is valid for use.""" + return not self.is_revoked() and not self.is_expired() and self.deleted_at is None + + def revoke(self, reason=None): + """Revoke the refresh token. + + Args: + reason: Optional reason for revocation + """ + self.revoked_at = datetime.utcnow() + self.revoked_reason = reason + db.session.commit() + + def rotate(self, new_token_hash): + """Rotate the refresh token (invalidate old, create new). + + Args: + new_token_hash: Hash of the new refresh token + + Returns: + self for chaining + """ + # Store reference to old token + self.previous_token_hash = self.token_hash + self.token_hash = new_token_hash + self.rotation_count += 1 + # Extend expiration on rotation + from datetime import timedelta + self.expires_at = datetime.utcnow() + timedelta(days=30) + db.session.commit() + return self + + @classmethod + def create_token(cls, client_id, user_id, token_hash, scope=None, + access_token_id=None, ip_address=None, user_agent=None, + lifetime_seconds=2592000): + """Create a new refresh token. + + Args: + client_id: The OIDC client ID + user_id: The user ID + token_hash: Hashed refresh token + scope: Granted scopes + access_token_id: Associated access token ID + ip_address: Client IP address + user_agent: Client user agent + lifetime_seconds: Token lifetime in seconds (default 30 days) + + Returns: + OIDCRefreshToken instance + """ + from datetime import timedelta + token = cls( + client_id=client_id, + user_id=user_id, + token_hash=token_hash, + scope=scope, + access_token_id=access_token_id, + expires_at=datetime.utcnow() + timedelta(seconds=lifetime_seconds), + ip_address=ip_address, + user_agent=user_agent, + ) + db.session.add(token) + db.session.commit() + return token + + def to_dict(self, exclude=None): + """Convert to dictionary, excluding sensitive fields.""" + exclude = exclude or [] + # Always exclude token hashes + exclude.append("token_hash") + exclude.append("previous_token_hash") + return super().to_dict(exclude=exclude) + + +# Add relationship back to User model +from 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 +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 +Session.oidc_refresh_token = db.relationship( + "OIDCRefreshToken", back_populates="access_token", uselist=False +) diff --git a/app/models/oidc_session.py b/app/models/oidc_session.py new file mode 100644 index 0000000..8e0f503 --- /dev/null +++ b/app/models/oidc_session.py @@ -0,0 +1,162 @@ +"""OIDC Session model for OIDC session tracking.""" +from datetime import datetime +from app.extensions import db +from app.models.base import BaseModel + + +class OIDCSession(BaseModel): + """OIDC Session model for tracking OIDC authentication sessions. + + This model tracks the state during the OIDC authentication flow, + including PKCE parameters and nonce validation. + """ + + __tablename__ = "oidc_sessions" + + # User reference + user_id = db.Column( + db.String(36), db.ForeignKey("users.id"), nullable=False, index=True + ) + + # Client reference + client_id = db.Column( + db.String(255), db.ForeignKey("oidc_clients.id"), nullable=False, index=True + ) + + # State management + state = db.Column(db.String(255), nullable=False, index=True) + nonce = db.Column(db.String(255), nullable=True) # For OIDC ID Token validation + + # Authorization request parameters + redirect_uri = db.Column(db.String(512), nullable=False) + scope = db.Column(db.JSON, nullable=True) # Requested scopes + + # PKCE parameters + code_challenge = db.Column(db.String(255), nullable=True) + code_challenge_method = db.Column(db.String(10), nullable=True) # "S256" or "plain" + + # Timing + expires_at = db.Column(db.DateTime, nullable=False, index=True) + authenticated_at = db.Column(db.DateTime, nullable=True) + + # Relationships + user = db.relationship("User", back_populates="oidc_sessions") + client = db.relationship("OIDCClient", back_populates="oidc_sessions") + + def __repr__(self): + """String representation of OIDCSession.""" + return f"" + + def is_expired(self): + """Check if the OIDC session has expired.""" + return datetime.utcnow() > self.expires_at + + def is_authenticated(self): + """Check if the user has been authenticated in this session.""" + return self.authenticated_at is not None + + def mark_authenticated(self): + """Mark the session as authenticated.""" + self.authenticated_at = datetime.utcnow() + db.session.commit() + + def validate_nonce(self, expected_nonce): + """Validate the nonce matches the expected value. + + Args: + expected_nonce: The expected nonce value + + Returns: + bool: True if nonce matches + """ + return self.nonce == expected_nonce + + def validate_code_challenge(self, code_verifier): + """Validate the code verifier against the stored code challenge. + + Args: + code_verifier: The PKCE code verifier + + Returns: + bool: True if code challenge is valid + """ + if not self.code_challenge: + return False + + if self.code_challenge_method == "S256": + import hashlib + import base64 + # SHA256 hash of code_verifier + digest = hashlib.sha256(code_verifier.encode()).digest() + # Base64 URL encode without padding + expected = base64.urlsafe_b64encode(digest).decode().rstrip("=") + return self.code_challenge == expected + elif self.code_challenge_method == "plain": + return self.code_challenge == code_verifier + + return False + + @classmethod + def create_session(cls, user_id, client_id, state, redirect_uri, scope=None, + nonce=None, code_challenge=None, code_challenge_method=None, + lifetime_seconds=600): + """Create a new OIDC session. + + Args: + user_id: The user ID + client_id: The OIDC client ID + state: The state parameter + redirect_uri: The redirect URI + scope: Requested scopes + nonce: OIDC nonce + code_challenge: PKCE code challenge + code_challenge_method: PKCE method ("S256" or "plain") + lifetime_seconds: Session lifetime in seconds + + Returns: + OIDCSession instance + """ + from datetime import timedelta + session = cls( + user_id=user_id, + client_id=client_id, + state=state, + redirect_uri=redirect_uri, + scope=scope, + nonce=nonce, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + expires_at=datetime.utcnow() + timedelta(seconds=lifetime_seconds), + ) + db.session.add(session) + db.session.commit() + return session + + @classmethod + def get_by_state(cls, state): + """Get a session by state parameter. + + Args: + state: The state parameter + + Returns: + OIDCSession instance or None + """ + return cls.query.filter_by(state=state, deleted_at=None).first() + + def to_dict(self, exclude=None): + """Convert to dictionary.""" + return super().to_dict(exclude=exclude) + + +# Add relationship back to User model +from 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 +OIDCClient.oidc_sessions = db.relationship( + "OIDCSession", back_populates="client", cascade="all, delete-orphan" +) diff --git a/app/models/oidc_token_metadata.py b/app/models/oidc_token_metadata.py new file mode 100644 index 0000000..2833c30 --- /dev/null +++ b/app/models/oidc_token_metadata.py @@ -0,0 +1,192 @@ +"""OIDC Token Metadata model for token revocation tracking.""" +import uuid +from datetime import datetime +from app.extensions import db +from app.models.base import BaseModel + + +class OIDCTokenMetadata(BaseModel): + """OIDC Token Metadata model for tracking issued tokens. + + This model stores metadata about issued tokens (access tokens, refresh tokens, ID tokens) + for the purpose of token revocation. The id field matches the JTI (JWT ID) claim. + """ + + __tablename__ = "oidc_token_metadata" + + # Token identifier (matches JTI in JWT) + id = db.Column( + db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + + # Client and User references + client_id = db.Column( + db.String(255), db.ForeignKey("oidc_clients.id"), nullable=False, index=True + ) + user_id = db.Column( + db.String(36), db.ForeignKey("users.id"), nullable=False, index=True + ) + + # Token type + token_type = db.Column(db.String(50), nullable=False) # "access_token", "refresh_token", "id_token" + + # Token identifier for revocation lookup + token_jti = db.Column(db.String(255), nullable=False, index=True) # JWT ID claim + + # Timing + expires_at = db.Column(db.DateTime, nullable=False, index=True) + + # Revocation tracking + revoked_at = db.Column(db.DateTime, nullable=True) + revoked_reason = db.Column(db.String(255), nullable=True) + + # Relationships + client = db.relationship("OIDCClient", back_populates="token_metadata") + user = db.relationship("User", back_populates="oidc_token_metadata") + + def __repr__(self): + """String representation of OIDCTokenMetadata.""" + return f"" + + def is_expired(self): + """Check if the token has expired.""" + return datetime.utcnow() > self.expires_at + + def is_revoked(self): + """Check if the token has been revoked.""" + return self.revoked_at is not None + + def is_valid(self): + """Check if the token is valid (not expired and not revoked).""" + return not self.is_revoked() and not self.is_expired() and self.deleted_at is None + + def revoke(self, reason=None): + """Revoke the token. + + Args: + reason: Optional reason for revocation + """ + self.revoked_at = datetime.utcnow() + self.revoked_reason = reason + db.session.commit() + + @classmethod + def create_metadata(cls, client_id, user_id, token_type, token_jti, + expires_at, ip_address=None, user_agent=None): + """Create token metadata for tracking. + + Args: + client_id: The OIDC client ID + user_id: The user ID + token_type: Type of token ("access_token", "refresh_token", "id_token") + token_jti: JWT ID claim + expires_at: Token expiration datetime + ip_address: Client IP address + user_agent: Client user agent + + Returns: + OIDCTokenMetadata instance + """ + metadata = cls( + id=str(uuid.uuid4()), + client_id=client_id, + user_id=user_id, + token_type=token_type, + token_jti=token_jti, + expires_at=expires_at, + ) + db.session.add(metadata) + db.session.commit() + return metadata + + @classmethod + def get_by_jti(cls, token_jti): + """Get token metadata by JWT ID. + + Args: + token_jti: The JWT ID + + Returns: + OIDCTokenMetadata instance or None + """ + return cls.query.filter_by(token_jti=token_jti, deleted_at=None).first() + + @classmethod + def revoke_by_jti(cls, token_jti, reason=None): + """Revoke a token by its JWT ID. + + Args: + token_jti: The JWT ID + reason: Optional revocation reason + + Returns: + bool: True if token was found and revoked + """ + metadata = cls.get_by_jti(token_jti) + if metadata: + metadata.revoke(reason) + return True + return False + + @classmethod + def revoke_all_for_user(cls, user_id, client_id=None, reason=None): + """Revoke all tokens for a user. + + Args: + user_id: The user ID + client_id: Optional client ID to filter by + reason: Optional revocation reason + + Returns: + int: Number of tokens revoked + """ + query = cls.query.filter_by(user_id=user_id, deleted_at=None) + if client_id: + query = query.filter_by(client_id=client_id) + + tokens = query.filter(cls.revoked_at == None).all() + count = 0 + for token in tokens: + token.revoke(reason) + count += 1 + return count + + @classmethod + def revoke_all_for_client(cls, client_id, user_id=None, reason=None): + """Revoke all tokens for a client. + + Args: + client_id: The client ID + user_id: Optional user ID to filter by + reason: Optional revocation reason + + Returns: + int: Number of tokens revoked + """ + query = cls.query.filter_by(client_id=client_id, deleted_at=None) + if user_id: + query = query.filter_by(user_id=user_id) + + tokens = query.filter(cls.revoked_at == None).all() + count = 0 + for token in tokens: + token.revoke(reason) + count += 1 + return count + + def to_dict(self, exclude=None): + """Convert to dictionary.""" + return super().to_dict(exclude=exclude) + + +# Add relationship back to User model +from 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 +OIDCClient.token_metadata = db.relationship( + "OIDCTokenMetadata", back_populates="client", cascade="all, delete-orphan" +) diff --git a/app/services/__init__.py b/app/services/__init__.py index aa74efa..f9577da 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -4,6 +4,11 @@ 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", @@ -11,4 +16,10 @@ __all__ = [ "OrganizationService", "SessionService", "AuditService", + "OIDCService", + "OIDCError", + "OIDCJWKSService", + "OIDCTokenService", + "OIDCSessionService", + "OIDCAuditService", ] diff --git a/app/services/oidc_audit_service.py b/app/services/oidc_audit_service.py new file mode 100644 index 0000000..0524230 --- /dev/null +++ b/app/services/oidc_audit_service.py @@ -0,0 +1,408 @@ +"""OIDC Audit Service for comprehensive OIDC event logging.""" +from datetime import datetime +from typing import Dict, List, Optional + +from flask import g + +from app.models import OIDCAuditLog, OIDCClient, User +from app.exceptions.validation_exceptions import NotFoundError + + +class OIDCAuditService: + """Service for OIDC-specific audit logging. + + This service provides methods to log all OIDC-related events including: + - Authorization requests and responses + - Token issuance and refresh + - Token revocation + - UserInfo endpoint access + - Authentication failures + """ + + # Event type constants + EVENT_AUTHORIZATION_REQUEST = "authorization_request" + EVENT_AUTHORIZATION_RESPONSE = "authorization_response" + EVENT_TOKEN_ISSUE = "token_issue" + EVENT_TOKEN_REFRESH = "token_refresh" + EVENT_TOKEN_REVOCATION = "token_revocation" + EVENT_TOKEN_INTROSPECTION = "token_introspection" + EVENT_USERINFO_ACCESS = "userinfo_access" + EVENT_AUTHENTICATION_FAILURE = "authentication_failure" + EVENT_AUTHORIZATION_FAILURE = "authorization_failure" + EVENT_JWKS_ACCESS = "jwks_access" + EVENT_REGISTRATION = "client_registration" + + @classmethod + def _get_request_context(cls) -> Dict: + """Extract request context for logging. + + Returns: + Dictionary with IP, user_agent, and request_id + """ + from flask import request + + return { + "ip_address": request.remote_addr if request else None, + "user_agent": request.headers.get("User-Agent") if request else None, + "request_id": g.get("request_id"), + } + + @classmethod + def log_event( + cls, + event_type: str, + client_id: str = None, + user_id: str = None, + success: bool = True, + error_code: str = None, + error_description: str = None, + metadata: Dict = None + ) -> OIDCAuditLog: + """Log a generic OIDC event. + + Args: + event_type: Type of event + client_id: OIDC client ID + user_id: User ID + success: Whether the event was successful + error_code: Error code if failed + error_description: Error description if failed + metadata: Additional event metadata + + Returns: + OIDCAuditLog instance + """ + context = cls._get_request_context() + + log = OIDCAuditLog.log_event( + event_type=event_type, + client_id=client_id, + user_id=user_id, + success=success, + error_code=error_code, + error_description=error_description, + ip_address=context["ip_address"], + user_agent=context["user_agent"], + request_id=context["request_id"], + metadata=metadata, + ) + + return log + + @classmethod + def log_authorization_event( + cls, + client_id: str, + user_id: str = None, + success: bool = True, + error_code: str = None, + error_description: str = None, + redirect_uri: str = None, + scope: list = None, + response_type: str = None + ) -> OIDCAuditLog: + """Log an authorization event. + + Args: + client_id: OIDC client ID + user_id: User ID (if authenticated) + success: Whether authorization was successful + error_code: Error code if failed + error_description: Error description if failed + redirect_uri: Redirect URI from request + scope: Requested scopes + response_type: Response type (e.g., "code") + + Returns: + OIDCAuditLog instance + """ + metadata = { + "redirect_uri": redirect_uri, + "scope": scope, + "response_type": response_type, + } + metadata = {k: v for k, v in metadata.items() if v is not None} + + return cls.log_event( + event_type=cls.EVENT_AUTHORIZATION_REQUEST, + client_id=client_id, + user_id=user_id, + success=success, + error_code=error_code, + error_description=error_description, + metadata=metadata, + ) + + @classmethod + def log_token_event( + cls, + client_id: str, + user_id: str = None, + token_type: str = "access_token", + success: bool = True, + error_code: str = None, + error_description: str = None, + grant_type: str = None, + scopes: list = None + ) -> OIDCAuditLog: + """Log a token issuance or refresh event. + + Args: + client_id: OIDC client ID + user_id: User ID + token_type: Type of token issued + success: Whether token issuance was successful + error_code: Error code if failed + error_description: Error description if failed + grant_type: Grant type used (e.g., "authorization_code", "refresh_token") + scopes: Scopes included in the token + + Returns: + OIDCAuditLog instance + """ + metadata = { + "token_type": token_type, + "grant_type": grant_type, + "scopes": scopes, + } + metadata = {k: v for k, v in metadata.items() if v is not None} + + return cls.log_event( + event_type=cls.EVENT_TOKEN_ISSUE if token_type else cls.EVENT_TOKEN_REFRESH, + client_id=client_id, + user_id=user_id, + success=success, + error_code=error_code, + error_description=error_description, + metadata=metadata, + ) + + @classmethod + def log_userinfo_event( + cls, + access_token: str = None, + user_id: str = None, + client_id: str = None, + success: bool = True, + error_code: str = None, + error_description: str = None, + scopes_claimed: list = None + ) -> OIDCAuditLog: + """Log a UserInfo endpoint access event. + + Args: + access_token: Access token used (masked) + user_id: User ID returned + client_id: Client ID making the request + success: Whether access was successful + error_code: Error code if failed + error_description: Error description if failed + scopes_claimed: Scopes claimed in the request + + Returns: + OIDCAuditLog instance + """ + # Mask the access token for security + masked_token = None + if access_token: + masked_token = access_token[:8] + "..." + access_token[-4:] if len(access_token) > 12 else "***" + + metadata = { + "token_prefix": masked_token, + "scopes_claimed": scopes_claimed, + } + metadata = {k: v for k, v in metadata.items() if v is not None} + + return cls.log_event( + event_type=cls.EVENT_USERINFO_ACCESS, + client_id=client_id, + user_id=user_id, + success=success, + error_code=error_code, + error_description=error_description, + metadata=metadata, + ) + + @classmethod + def log_token_revocation_event( + cls, + client_id: str, + user_id: str = None, + token_type: str = "access_token", + reason: str = None, + success: bool = True, + error_code: str = None, + error_description: str = None + ) -> OIDCAuditLog: + """Log a token revocation event. + + Args: + client_id: OIDC client ID + user_id: User ID + token_type: Type of token being revoked + reason: Revocation reason + success: Whether revocation was successful + error_code: Error code if failed + error_description: Error description if failed + + Returns: + OIDCAuditLog instance + """ + metadata = { + "token_type": token_type, + "reason": reason, + } + metadata = {k: v for k, v in metadata.items() if v is not None} + + return cls.log_event( + event_type=cls.EVENT_TOKEN_REVOCATION, + client_id=client_id, + user_id=user_id, + success=success, + error_code=error_code, + error_description=error_description, + metadata=metadata, + ) + + @classmethod + def log_authentication_failure( + cls, + client_id: str = None, + error_code: str = "authentication_failed", + error_description: str = "Authentication failed", + user_id: str = None + ) -> OIDCAuditLog: + """Log an authentication failure event. + + Args: + client_id: OIDC client ID + error_code: Error code + error_description: Error description + user_id: User ID if known + + Returns: + OIDCAuditLog instance + """ + return cls.log_event( + event_type=cls.EVENT_AUTHENTICATION_FAILURE, + client_id=client_id, + user_id=user_id, + success=False, + error_code=error_code, + error_description=error_description, + ) + + @classmethod + def get_events_for_user( + cls, + user_id: str, + limit: int = 100, + include_deleted: bool = False + ) -> List[OIDCAuditLog]: + """Get audit events for a specific user. + + Args: + user_id: User ID + limit: Maximum number of events to return + include_deleted: Include soft-deleted events + + Returns: + List of OIDCAuditLog instances + """ + return OIDCAuditLog.get_events_for_user(user_id, limit) + + @classmethod + def get_events_for_client( + cls, + client_id: str, + limit: int = 100 + ) -> List[OIDCAuditLog]: + """Get audit events for a specific client. + + Args: + client_id: Client ID + limit: Maximum number of events to return + + Returns: + List of OIDCAuditLog instances + """ + return OIDCAuditLog.get_events_for_client(client_id, limit) + + @classmethod + def get_failed_events( + cls, + client_id: str = None, + user_id: str = None, + start_date: datetime = None, + end_date: datetime = None, + limit: int = 100 + ) -> List[OIDCAuditLog]: + """Get failed audit events for analysis. + + Args: + client_id: Optional client ID filter + user_id: Optional user ID filter + start_date: Optional start date filter + end_date: Optional end date filter + limit: Maximum number of events to return + + Returns: + List of failed OIDCAuditLog instances + """ + return OIDCAuditLog.get_failed_events( + client_id=client_id, + user_id=user_id, + start_date=start_date, + end_date=end_date, + limit=limit, + ) + + @classmethod + def get_event_summary( + cls, + client_id: str = None, + days: int = 30 + ) -> Dict: + """Get a summary of audit events. + + Args: + client_id: Optional client ID filter + days: Number of days to look back + + Returns: + Summary dictionary with event counts + """ + from datetime import timedelta + + start_date = datetime.utcnow() - timedelta(days=days) + + query = OIDCAuditLog.query.filter( + OIDCAuditLog.created_at >= start_date + ) + + if client_id: + query = query.filter_by(client_id=client_id) + + events = query.all() + + # Count by event type + event_counts = {} + success_count = 0 + failure_count = 0 + + for event in events: + event_type = event.event_type + event_counts[event_type] = event_counts.get(event_type, 0) + 1 + + if event.success: + success_count += 1 + else: + failure_count += 1 + + return { + "total_events": len(events), + "successful_events": success_count, + "failed_events": failure_count, + "by_event_type": event_counts, + "period_days": days, + } diff --git a/app/services/oidc_jwks_service.py b/app/services/oidc_jwks_service.py new file mode 100644 index 0000000..50b1d32 --- /dev/null +++ b/app/services/oidc_jwks_service.py @@ -0,0 +1,300 @@ +"""OIDC JWKS Service for key management and rotation.""" +import uuid +import json +import hashlib +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Tuple + +from flask import current_app + +from app.extensions import db + + +class JWKSKey: + """Represents a JWKS key entry.""" + + def __init__(self, kid: str, private_key: str, public_key: str, + algorithm: str = "RS256", created_at: datetime = None, + expires_at: datetime = None, is_active: bool = True): + self.kid = kid + self.private_key = private_key + self.public_key = public_key + self.algorithm = algorithm + self.created_at = created_at or datetime.utcnow() + self.expires_at = expires_at or datetime.utcnow() + timedelta(days=365) + self.is_active = is_active + + def to_jwk(self) -> Dict: + """Convert to JWK format for JWKS endpoint.""" + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa, padding + from cryptography.hazmat.backends import default_backend + + # Import cryptography here to avoid issues if not installed + try: + # Get public key from PEM + public_key = serialization.load_pem_public_key( + self.public_key.encode(), backend=default_backend() + ) + + # Get RSA parameters + public_numbers = public_key.public_numbers() + + return { + "kty": "RSA", + "kid": self.kid, + "use": "sig", + "alg": self.algorithm, + "n": _base64url_encode(public_numbers.n), + "e": _base64url_encode(public_numbers.e), + } + except ImportError: + # Fallback for when cryptography is not installed + return { + "kty": "RSA", + "kid": self.kid, + "use": "sig", + "alg": self.algorithm, + } + + def to_dict(self) -> Dict: + """Convert to dictionary for storage.""" + return { + "kid": self.kid, + "private_key": self.private_key, + "public_key": self.public_key, + "algorithm": self.algorithm, + "created_at": self.created_at.isoformat(), + "expires_at": self.expires_at.isoformat(), + "is_active": self.is_active, + } + + @classmethod + def from_dict(cls, data: Dict) -> "JWKSKey": + """Create from dictionary.""" + return cls( + kid=data["kid"], + private_key=data["private_key"], + public_key=data["public_key"], + algorithm=data.get("algorithm", "RS256"), + created_at=datetime.fromisoformat(data["created_at"]), + expires_at=datetime.fromisoformat(data["expires_at"]), + is_active=data.get("is_active", True), + ) + + +def _base64url_encode(value: int) -> str: + """Encode an integer to base64url format.""" + import base64 + byte_length = (value.bit_length() + 7) // 8 or 1 + encoded = value.to_bytes(byte_length, byteorder="big") + return base64.urlsafe_b64encode(encoded).decode().rstrip("=") + + +class OIDCJWKSService: + """Service for managing OIDC signing keys (JWKS). + + This service handles RSA key pair generation, rotation, and JWKS document + generation for the OIDC implementation. + """ + + _instance = None + _keys: Dict[str, JWKSKey] = {} + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._keys = {} + return cls._instance + + @classmethod + def reset(cls): + """Reset the singleton (for testing).""" + cls._instance = None + cls._keys = {} + + def _generate_kid(self, private_key: str) -> str: + """Generate a key ID from the private key fingerprint.""" + kid_hash = hashlib.sha256(private_key.encode()).hexdigest()[:32] + return kid_hash + + def _generate_rsa_key_pair(self) -> Tuple[str, str]: + """Generate a new RSA key pair in PEM format. + + Returns: + Tuple of (private_key_pem, public_key_pem) + """ + try: + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.backends import default_backend + + # Generate RSA private key + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend() + ) + + # Get public key + public_key = private_key.public_key() + + # Serialize to PEM + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ).decode() + + public_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo + ).decode() + + return private_pem, public_pem + except ImportError: + # Fallback for testing without cryptography + import secrets + return f"private_key_{secrets.token_hex(32)}", f"public_key_{secrets.token_hex(32)}" + + def get_jwks(self, include_private_keys: bool = False) -> Dict: + """Get the JWKS document containing public keys. + + Args: + include_private_keys: Whether to include private keys (for internal use only) + + Returns: + JWKS document dictionary + """ + now = datetime.utcnow() + + keys = [] + for kid, key in self._keys.items(): + # Only include active, non-expired keys + if key.is_active and key.expires_at > now: + if include_private_keys: + keys.append(key.to_dict()) + else: + keys.append(key.to_jwk()) + + return { + "keys": keys + } + + def get_signing_key(self) -> Optional[JWKSKey]: + """Get the current active signing key. + + Returns: + JWKSKey instance or None if no active key + """ + now = datetime.utcnow() + + for kid, key in self._keys.items(): + if key.is_active and key.expires_at > now: + return key + + return None + + def get_key_by_kid(self, kid: str) -> Optional[JWKSKey]: + """Get a specific key by its ID. + + Args: + kid: Key ID to look up + + Returns: + JWKSKey instance or None if not found + """ + return self._keys.get(kid) + + def generate_new_key_pair(self, expires_in_days: int = 365) -> JWKSKey: + """Generate a new RSA key pair for signing. + + Args: + expires_in_days: Days until key expiration + + Returns: + JWKSKey instance + """ + private_key, public_key = self._generate_rsa_key_pair() + kid = self._generate_kid(private_key) + + now = datetime.utcnow() + key = JWKSKey( + kid=kid, + private_key=private_key, + public_key=public_key, + algorithm="RS256", + created_at=now, + expires_at=now + timedelta(days=expires_in_days), + is_active=True, + ) + + self._keys[kid] = key + + # Deactivate old keys (but keep them for grace period) + for old_kid in self._keys: + if old_kid != kid: + self._keys[old_kid].is_active = False + + return key + + def rotate_keys(self, grace_period_hours: int = 24) -> Tuple[JWKSKey, List[str]]: + """Rotate signing keys, keeping previous key active for grace period. + + Args: + grace_period_hours: Hours to keep old keys active + + Returns: + Tuple of (new_key, list_of_deprecated_kids) + """ + now = datetime.utcnow() + grace_end = now + timedelta(hours=grace_period_hours) + + # Mark current key as deprecated + current_key = self.get_signing_key() + deprecated_kids = [] + + if current_key: + deprecated_kids.append(current_key.kid) + # Keep key active but mark as deprecated + current_key.is_active = False + current_key.expires_at = grace_end + + # Generate new key + new_key = self.generate_new_key_pair() + + # Clean up expired keys + expired_kids = [ + kid for kid, key in self._keys.items() + if key.expires_at < now + ] + for kid in expired_kids: + del self._keys[kid] + + return new_key, deprecated_kids + + def verify_key_exists(self, kid: str) -> bool: + """Check if a key with the given ID exists and is valid. + + Args: + kid: Key ID to check + + Returns: + True if key exists and is valid + """ + key = self.get_key_by_kid(kid) + if not key: + return False + + now = datetime.utcnow() + return key.is_active and key.expires_at > now + + def initialize_with_key(self) -> JWKSKey: + """Initialize the service with a key if none exists. + + Returns: + JWKSKey instance + """ + if not self._keys: + return self.generate_new_key_pair() + return self.get_signing_key() diff --git a/app/services/oidc_service.py b/app/services/oidc_service.py new file mode 100644 index 0000000..89ecf09 --- /dev/null +++ b/app/services/oidc_service.py @@ -0,0 +1,745 @@ +"""OIDC Service - Main OIDC service layer.""" +import secrets +import hashlib +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Tuple + +from flask import current_app, g + +from app.extensions import db +from app.models import ( + User, OIDCClient, OIDCAuthCode, OIDCRefreshToken, + OIDCSession, OIDCTokenMetadata +) +from 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 + + +class OIDCError(Exception): + """Base exception for OIDC errors.""" + + def __init__(self, error: str, error_description: str = None, status_code: int = 400): + self.error = error + self.error_description = error_description + self.status_code = status_code + + +class InvalidClientError(OIDCError): + """Raised when client authentication fails.""" + + def __init__(self, error_description: str = "Invalid client"): + super().__init__("invalid_client", error_description, 401) + + +class InvalidGrantError(OIDCError): + """Raised when grant is invalid.""" + + def __init__(self, error_description: str = "Invalid grant"): + super().__init__("invalid_grant", error_description, 400) + + +class InvalidRequestError(OIDCError): + """Raised when request is malformed.""" + + def __init__(self, error_description: str = "Invalid request"): + super().__init__("invalid_request", error_description, 400) + + +class OIDCService: + """Main OIDC service handling all OpenID Connect operations. + + This service provides: + - Authorization code generation and validation + - Token generation (access, refresh, ID tokens) + - Token refresh with rotation + - Token validation and introspection + - Token revocation + """ + + @staticmethod + def _generate_code() -> str: + """Generate a secure authorization code. + + Returns: + URL-safe base64 encoded code + """ + return secrets.token_urlsafe(32) + + @staticmethod + def _hash_value(value: str) -> str: + """Hash a value for secure storage. + + Args: + value: Value to hash + + Returns: + SHA256 hash + """ + return hashlib.sha256(value.encode()).hexdigest() + + @classmethod + def generate_authorization_code( + cls, + client_id: str, + user_id: str, + redirect_uri: str, + scope: list, + state: str, + nonce: str, + code_challenge: str = None, + code_challenge_method: str = None, + ip_address: str = None, + user_agent: str = None + ) -> str: + """Generate an authorization code for the auth code flow. + + Args: + client_id: OIDC client ID + user_id: User ID + redirect_uri: Redirect URI + scope: Requested scopes + state: State parameter + nonce: Nonce for ID token + code_challenge: PKCE code challenge + code_challenge_method: PKCE method ("S256" or "plain") + ip_address: Client IP address + user_agent: Client user agent + + Returns: + Authorization code string + + Raises: + ValidationError: If parameters are invalid + NotFoundError: If client not found + """ + # Validate client exists and is active + client = OIDCClient.query.filter_by(client_id=client_id).first() + if not client: + raise NotFoundError("Client not found") + + if not client.is_active: + raise ValidationError("Client is not active") + + # Validate redirect URI + if not client.is_redirect_uri_allowed(redirect_uri): + raise ValidationError("Invalid redirect_uri") + + # Validate scopes + allowed_scopes = client.scopes or [] + valid_scopes = [s for s in scope if s in allowed_scopes] + + if not valid_scopes: + raise ValidationError("Invalid scopes") + + # Generate authorization code + code = cls._generate_code() + code_hash = cls._hash_value(code) + + # Create auth code record + auth_code = OIDCAuthCode.create_code( + client_id=client.id, + user_id=user_id, + code_hash=code_hash, + redirect_uri=redirect_uri, + scope=valid_scopes, + nonce=nonce, + code_verifier=code_challenge, # Store for validation + ip_address=ip_address, + user_agent=user_agent, + lifetime_seconds=600, # 10 minutes + ) + + # Log authorization event + OIDCAuditService.log_authorization_event( + client_id=client_id, + user_id=user_id, + success=True, + redirect_uri=redirect_uri, + scope=valid_scopes, + ) + + return code + + @classmethod + def validate_authorization_code( + cls, + code: str, + client_id: str, + redirect_uri: str, + code_verifier: str = None, + ip_address: str = None, + user_agent: str = None + ) -> Tuple[Dict, User]: + """Validate and consume an authorization code. + + Args: + code: Authorization code + client_id: OIDC client ID + redirect_uri: Redirect URI + code_verifier: PKCE code verifier (required if PKCE was used) + ip_address: Client IP address + user_agent: Client user agent + + Returns: + Tuple of (claims dict, User instance) + + Raises: + InvalidGrantError: If code is invalid + ValidationError: If PKCE validation fails + """ + # Get client + client = OIDCClient.query.filter_by(client_id=client_id).first() + if not client: + raise InvalidGrantError("Invalid client") + + # Hash the provided code and find matching auth code + code_hash = cls._hash_value(code) + auth_code = OIDCAuthCode.query.filter_by( + code_hash=code_hash, + client_id=client.id, + deleted_at=None + ).first() + + if not auth_code: + OIDCAuditService.log_authorization_event( + client_id=client_id, + success=False, + error_code="invalid_grant", + error_description="Invalid or expired authorization code", + ) + raise InvalidGrantError("Invalid or expired authorization code") + + # Check if already used + if auth_code.is_used: + OIDCAuditService.log_authorization_event( + client_id=client_id, + user_id=auth_code.user_id, + success=False, + error_code="invalid_grant", + error_description="Authorization code already used", + ) + raise InvalidGrantError("Authorization code already used") + + # Check expiration + if auth_code.is_expired(): + OIDCAuditService.log_authorization_event( + client_id=client_id, + user_id=auth_code.user_id, + success=False, + error_code="invalid_grant", + error_description="Authorization code expired", + ) + raise InvalidGrantError("Authorization code expired") + + # Validate redirect URI + if auth_code.redirect_uri != redirect_uri: + raise InvalidGrantError("Invalid redirect_uri") + + # Validate PKCE if required + if client.require_pkce and auth_code.code_verifier: + if not code_verifier: + raise ValidationError("code_verifier is required") + + # Verify code verifier + expected_challenge = cls._compute_code_challenge(code_verifier, "S256") + if expected_challenge != auth_code.code_verifier: + OIDCAuditService.log_authorization_event( + client_id=client_id, + user_id=auth_code.user_id, + success=False, + error_code="invalid_grant", + error_description="Invalid code_verifier", + ) + raise InvalidGrantError("Invalid code_verifier") + + # Mark code as used + auth_code.mark_as_used() + + # Get user + user = User.query.get(auth_code.user_id) + if not user: + raise InvalidGrantError("User not found") + + claims = { + "user_id": auth_code.user_id, + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": auth_code.scope, + "nonce": auth_code.nonce, + } + + return claims, user + + @classmethod + def _compute_code_challenge(cls, verifier: str, method: str = "S256") -> str: + """Compute PKCE code challenge from verifier. + + Args: + verifier: Code verifier + method: Challenge method + + Returns: + Code challenge + """ + import hashlib + import base64 + + if method == "S256": + digest = hashlib.sha256(verifier.encode()).digest() + return base64.urlsafe_b64encode(digest).decode().rstrip("=") + return verifier + + @classmethod + def generate_tokens( + cls, + client_id: str, + user_id: str, + scope: list, + nonce: str = None, + refresh_token: str = None, + ip_address: str = None, + user_agent: str = None, + auth_time: int = None + ) -> Dict: + """Generate access token, ID token, and refresh token. + + Args: + client_id: OIDC client ID + user_id: User ID + scope: Granted scopes + nonce: Nonce for ID token + refresh_token: Existing refresh token (for rotation) + ip_address: Client IP address + user_agent: Client user agent + auth_time: Authentication time + + Returns: + Dictionary with tokens + """ + import hashlib + + # Get client + client = OIDCClient.query.filter_by(client_id=client_id).first() + if not client: + raise InvalidClientError() + + # Generate access token + access_token_jti = OIDCTokenService._generate_jti() + access_token = OIDCTokenService.create_access_token( + client_id=client_id, + user_id=user_id, + scope=scope, + jti=access_token_jti, + ) + + # Generate ID token + id_token = OIDCTokenService.create_id_token( + client_id=client_id, + user_id=user_id, + nonce=nonce, + scope=scope, + access_token=access_token, + auth_time=auth_time, + ) + + # Generate or rotate refresh token + if "refresh_token" in (client.grant_types or []): + if refresh_token: + # Rotate existing refresh token + refresh_token_obj = OIDCRefreshToken.query.filter_by( + token_hash=hashlib.sha256(refresh_token.encode()).hexdigest(), + deleted_at=None + ).first() + + if refresh_token_obj and refresh_token_obj.is_valid(): + # Create new refresh token + new_refresh, new_hash = OIDCTokenService.create_refresh_token( + client_id=client_id, + user_id=user_id, + scope=scope, + access_token_id=access_token_jti, + ) + + # Rotate in database + refresh_token_obj.rotate(new_hash) + final_refresh_token = new_refresh + else: + final_refresh_token = None + else: + # Create new refresh token + final_refresh_token, refresh_hash = OIDCTokenService.create_refresh_token( + client_id=client_id, + user_id=user_id, + scope=scope, + access_token_id=access_token_jti, + ) + + # Store refresh token + OIDCRefreshToken.create_token( + client_id=client.id, + user_id=user_id, + token_hash=refresh_hash, + scope=scope, + access_token_id=access_token_jti, + ip_address=ip_address, + user_agent=user_agent, + lifetime_seconds=client.refresh_token_lifetime or 2592000, + ) + else: + final_refresh_token = None + + # Store token metadata + client_db_id = client.id + + # Access token metadata + OIDCTokenMetadata.create_metadata( + client_id=client_db_id, + user_id=user_id, + token_type="access_token", + token_jti=access_token_jti, + expires_at=datetime.utcnow() + timedelta(seconds=client.access_token_lifetime or 3600), + ) + + # ID token metadata (using access token JTI as reference) + id_token_jti = OIDCTokenService._generate_jti() + OIDCTokenMetadata.create_metadata( + client_id=client_db_id, + user_id=user_id, + token_type="id_token", + token_jti=id_token_jti, + expires_at=datetime.utcnow() + timedelta(seconds=client.id_token_lifetime or 3600), + ) + + # Log token event + OIDCAuditService.log_token_event( + client_id=client_id, + user_id=user_id, + token_type="access_token", + success=True, + grant_type="authorization_code", + scopes=scope, + ) + + result = { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": client.access_token_lifetime or 3600, + "id_token": id_token, + } + + if final_refresh_token: + result["refresh_token"] = final_refresh_token + + return result + + @classmethod + def refresh_access_token( + cls, + refresh_token: str, + client_id: str, + scope: list = None, + ip_address: str = None, + user_agent: str = None + ) -> Dict: + """Refresh an access token with token rotation. + + Args: + refresh_token: The refresh token + client_id: OIDC client ID + scope: Optional scope override + ip_address: Client IP address + user_agent: Client user agent + + Returns: + Dictionary with new tokens + + Raises: + InvalidGrantError: If refresh token is invalid + """ + import hashlib + + # Get client + client = OIDCClient.query.filter_by(client_id=client_id).first() + if not client: + raise InvalidClientError() + + # Find refresh token + token_hash = hashlib.sha256(refresh_token.encode()).hexdigest() + refresh_token_obj = OIDCRefreshToken.query.filter_by( + token_hash=token_hash, + deleted_at=None + ).first() + + if not refresh_token_obj: + OIDCAuditService.log_token_event( + client_id=client_id, + success=False, + error_code="invalid_grant", + error_description="Invalid refresh token", + ) + raise InvalidGrantError("Invalid refresh token") + + # Check if valid + if not refresh_token_obj.is_valid(): + OIDCAuditService.log_token_event( + client_id=client_id, + user_id=refresh_token_obj.user_id, + success=False, + error_code="invalid_grant", + error_description="Refresh token expired or revoked", + ) + raise InvalidGrantError("Refresh token expired or revoked") + + # Validate client matches + if refresh_token_obj.client_id != client.id: + raise InvalidGrantError("Client mismatch") + + # Get original scope or use provided + granted_scope = scope or (refresh_token_obj.scope or []) + + # Generate new access token + access_token_jti = OIDCTokenService._generate_jti() + access_token = OIDCTokenService.create_access_token( + client_id=client_id, + user_id=refresh_token_obj.user_id, + scope=granted_scope, + jti=access_token_jti, + ) + + # Generate new ID token + id_token = OIDCTokenService.create_id_token( + client_id=client_id, + user_id=refresh_token_obj.user_id, + scope=granted_scope, + access_token=access_token, + ) + + # Rotate refresh token + new_refresh, new_hash = OIDCTokenService.create_refresh_token( + client_id=client_id, + user_id=refresh_token_obj.user_id, + scope=granted_scope, + access_token_id=access_token_jti, + ) + + refresh_token_obj.rotate(new_hash) + + # Store new token metadata + OIDCTokenMetadata.create_metadata( + client_id=client.id, + user_id=refresh_token_obj.user_id, + token_type="access_token", + token_jti=access_token_jti, + expires_at=datetime.utcnow() + timedelta(seconds=client.access_token_lifetime or 3600), + ) + + # Log refresh event + OIDCAuditService.log_token_event( + client_id=client_id, + user_id=refresh_token_obj.user_id, + token_type="access_token", + success=True, + grant_type="refresh_token", + scopes=granted_scope, + ) + + return { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": client.access_token_lifetime or 3600, + "id_token": id_token, + "refresh_token": new_refresh, + } + + @classmethod + def validate_access_token(cls, token: str, client_id: str = None) -> Dict: + """Validate an access token and return its claims. + + Args: + token: JWT access token + client_id: Optional client ID to validate audience + + Returns: + Token claims + + Raises: + InvalidTokenError: If token is invalid + """ + try: + claims = OIDCTokenService.validate_access_token(token, client_id) + return claims + except Exception as e: + OIDCAuditService.log_event( + event_type="token_validation", + client_id=client_id, + success=False, + error_code="invalid_token", + error_description=str(e), + ) + raise InvalidTokenError(str(e)) + + @classmethod + def revoke_token( + cls, + token: str, + client_id: str, + token_type_hint: str = None, + ip_address: str = None, + user_agent: str = None + ) -> bool: + """Revoke a token. + + Args: + token: Token to revoke + client_id: OIDC client ID + token_type_hint: Hint about token type + ip_address: Client IP address + user_agent: Client user agent + + Returns: + True if token was revoked + """ + import hashlib + + # Get client + client = OIDCClient.query.filter_by(client_id=client_id).first() + if not client: + raise InvalidClientError() + + revoked = False + token_hash = hashlib.sha256(token.encode()).hexdigest() + + # Try to revoke as refresh token + if token_type_hint in (None, "refresh_token"): + refresh_token = OIDCRefreshToken.query.filter_by( + token_hash=token_hash, + deleted_at=None + ).first() + + if refresh_token: + refresh_token.revoke(reason="revoked_by_client") + revoked = True + + OIDCAuditService.log_token_revocation_event( + client_id=client_id, + user_id=refresh_token.user_id, + token_type="refresh_token", + reason="revoked_by_client", + ) + + # Try to revoke as access token (JTI lookup) + if not revoked or token_type_hint in (None, "access_token"): + try: + # Decode token to get JTI + claims = OIDCTokenService.decode_token(token) + jti = claims.get("jti") + + if jti: + revoked_at = OIDCTokenMetadata.revoke_by_jti( + jti, + reason="revoked_by_client" + ) + if revoked_at: + revoked = True + + OIDCAuditService.log_token_revocation_event( + client_id=client_id, + user_id=claims.get("sub"), + token_type="access_token", + reason="revoked_by_client", + ) + except Exception: + pass + + return revoked + + @classmethod + def introspect_token( + cls, + token: str, + client_id: str = None, + ip_address: str = None, + user_agent: str = None + ) -> Dict: + """Introspect a token and return its status and claims. + + Args: + token: Token to introspect + client_id: Client ID for validation + ip_address: Client IP address + user_agent: Client user agent + + Returns: + Introspection response + """ + result = OIDCTokenService.introspect_token(token, client_id) + + # Log introspection + OIDCAuditService.log_event( + event_type="token_introspection", + client_id=client_id, + user_id=result.get("sub"), + success=result.get("active", False), + metadata={"active": result.get("active")}, + ) + + return result + + @classmethod + def get_jwks(cls) -> Dict: + """Get the JWKS document. + + Returns: + JWKS document + """ + jwks_service = OIDCJWKSService() + return jwks_service.get_jwks() + + @classmethod + def get_userinfo(cls, access_token: str) -> Dict: + """Get user information using access token. + + Args: + access_token: Access token + + Returns: + User information dictionary + """ + claims = cls.validate_access_token(access_token) + + user_id = claims.get("sub") + user = User.query.get(user_id) + + if not user: + raise NotFoundError("User not found") + + # Get scopes from token + scope_str = claims.get("scope", "") + scopes = scope_str.split() if scope_str else [] + + userinfo = {"sub": user_id} + + # Add claims based on scope + if "profile" in scopes and user.full_name: + userinfo["name"] = user.full_name + + if "email" in scopes: + userinfo["email"] = user.email + userinfo["email_verified"] = user.email_verified + + # Log userinfo access + OIDCAuditService.log_userinfo_event( + access_token=access_token, + user_id=user_id, + client_id=claims.get("client_id"), + success=True, + scopes_claimed=scopes, + ) + + return userinfo diff --git a/app/services/oidc_session_service.py b/app/services/oidc_session_service.py new file mode 100644 index 0000000..e771143 --- /dev/null +++ b/app/services/oidc_session_service.py @@ -0,0 +1,288 @@ +"""OIDC Session Service for session management during OIDC flow.""" +import secrets +from datetime import datetime, timedelta +from typing import Dict, Optional, Tuple + +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 + + +class OIDCSessionService: + """Service for managing OIDC authentication sessions. + + This service handles: + - Creating OIDC sessions during authorization flow + - Validating sessions with state and nonce + - Managing PKCE code challenges + - Cleaning up expired sessions + """ + + @staticmethod + def _generate_state() -> str: + """Generate a secure state parameter. + + Returns: + URL-safe base64 encoded state + """ + return secrets.token_urlsafe(32) + + @staticmethod + def _generate_nonce() -> str: + """Generate a secure nonce for OIDC. + + Returns: + URL-safe base64 encoded nonce + """ + return secrets.token_urlsafe(32) + + @staticmethod + def _generate_code_challenge(verifier: str, method: str = "S256") -> str: + """Generate a PKCE code challenge from verifier. + + Args: + verifier: The code verifier + method: Challenge method ("S256" or "plain") + + Returns: + Code challenge string + """ + import hashlib + import base64 + + if method == "S256": + digest = hashlib.sha256(verifier.encode()).digest() + return base64.urlsafe_b64encode(digest).decode().rstrip("=") + elif method == "plain": + return verifier + else: + raise ValueError(f"Unsupported code challenge method: {method}") + + @classmethod + def validate_code_verifier(cls, code_verifier: str, code_challenge: str, + method: str = "S256") -> bool: + """Validate a PKCE code verifier against the stored challenge. + + Args: + code_verifier: The code verifier from the token request + code_challenge: The code challenge from the authorization request + method: The challenge method used + + Returns: + True if validation succeeds + """ + if not code_verifier or not code_challenge: + return False + + # Validate code verifier length (43-128 characters) + if method == "S256" and not (43 <= len(code_verifier) <= 128): + return False + + # Calculate expected challenge + expected_challenge = cls._generate_code_challenge(code_verifier, method) + + return secrets.compare_digest(expected_challenge, code_challenge) + + @classmethod + def create_session( + cls, + user_id: str, + client_id: str, + state: str = None, + nonce: str = None, + redirect_uri: str = None, + scope: list = None, + code_challenge: str = None, + code_challenge_method: str = None, + lifetime_seconds: int = 600 + ) -> OIDCSession: + """Create a new OIDC session for the authorization flow. + + Args: + user_id: The user ID + client_id: The OIDC client ID + state: State parameter (generated if not provided) + nonce: Nonce for ID token validation (generated if not provided) + redirect_uri: Redirect URI from authorization request + scope: Requested scopes + code_challenge: PKCE code challenge + code_challenge_method: PKCE method ("S256" or "plain") + lifetime_seconds: Session lifetime in seconds + + Returns: + OIDCSession instance + """ + # Generate state and nonce if not provided + state = state or cls._generate_state() + nonce = nonce or cls._generate_nonce() + + session = OIDCSession.create_session( + user_id=user_id, + client_id=client_id, + state=state, + nonce=nonce, + redirect_uri=redirect_uri, + scope=scope, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + lifetime_seconds=lifetime_seconds, + ) + + return session + + @classmethod + def validate_session(cls, state: str, nonce: str = None) -> Tuple[OIDCSession, User]: + """Validate an OIDC session by state and optionally nonce. + + Args: + state: The state parameter + nonce: The nonce to validate (optional) + + Returns: + Tuple of (OIDCSession, User) + + Raises: + ValidationError: If session is invalid + NotFoundError: If session not found + """ + session = OIDCSession.get_by_state(state) + + if not session: + raise NotFoundError("OIDC session not found or expired") + + if session.is_expired(): + raise ValidationError("OIDC session has expired") + + # Validate nonce if provided + if nonce and not session.validate_nonce(nonce): + raise ValidationError("Invalid nonce") + + # Get user + user = User.query.get(session.user_id) + if not user: + raise NotFoundError("User not found") + + return session, user + + @classmethod + def validate_pkce(cls, session: OIDCSession, code_verifier: str) -> bool: + """Validate PKCE code verifier against the session's code challenge. + + Args: + session: OIDCSession instance + code_verifier: The code verifier from token request + + Returns: + True if validation succeeds + + Raises: + ValidationError: If PKCE validation fails + """ + if not session.code_challenge: + # No PKCE was used, skip validation + return True + + if not code_verifier: + raise ValidationError("code_verifier is required") + + is_valid = session.validate_code_challenge(code_verifier) + + if not is_valid: + raise ValidationError("Invalid code_verifier") + + return True + + @classmethod + def mark_session_authenticated(cls, session: OIDCSession) -> OIDCSession: + """Mark a session as authenticated (user has logged in). + + Args: + session: OIDCSession instance + + Returns: + Updated OIDCSession instance + """ + session.mark_authenticated() + return session + + @classmethod + def cleanup_expired_sessions(cls, older_than_hours: int = 24) -> int: + """Remove expired OIDC sessions. + + Args: + older_than_hours: Only delete sessions expired more than this many hours ago + + Returns: + Number of sessions deleted + """ + from datetime import timedelta + + cutoff = datetime.utcnow() - timedelta(hours=older_than_hours) + + # Get expired sessions + expired_sessions = OIDCSession.query.filter( + OIDCSession.expires_at < datetime.utcnow(), + OIDCSession.deleted_at == None + ).all() + + count = 0 + for session in expired_sessions: + # Only hard delete if past the grace period + if session.expires_at < cutoff: + session.delete() + count += 1 + + return count + + @classmethod + def get_session_by_state(cls, state: str) -> Optional[OIDCSession]: + """Get an OIDC session by state. + + Args: + state: The state parameter + + Returns: + OIDCSession instance or None + """ + return OIDCSession.get_by_state(state) + + @classmethod + def validate_redirect_uri(cls, client_id: str, redirect_uri: str) -> bool: + """Validate that a redirect URI is allowed for a client. + + Args: + client_id: The OIDC client ID + redirect_uri: The redirect URI to validate + + Returns: + True if redirect URI is allowed + """ + client = OIDCClient.query.filter_by(client_id=client_id).first() + if not client: + return False + + return client.is_redirect_uri_allowed(redirect_uri) + + @classmethod + def validate_scopes(cls, client_id: str, requested_scopes: list) -> list: + """Validate and filter scopes against client's allowed scopes. + + Args: + client_id: The OIDC client ID + requested_scopes: List of requested scopes + + Returns: + List of allowed scopes + """ + client = OIDCClient.query.filter_by(client_id=client_id).first() + if not client: + return [] + + allowed_scopes = client.scopes or [] + + # Filter to only allowed scopes + valid_scopes = [s for s in requested_scopes if s in allowed_scopes] + + return valid_scopes diff --git a/app/services/oidc_token_service.py b/app/services/oidc_token_service.py new file mode 100644 index 0000000..e8efdf5 --- /dev/null +++ b/app/services/oidc_token_service.py @@ -0,0 +1,439 @@ +"""OIDC Token Service for JWT token generation and validation.""" +import hashlib +import base64 +import secrets +from datetime import datetime, timedelta +from typing import Dict, Optional, Any + +import jwt +from flask import current_app, g + +from app.models import User, OIDCClient +from app.services.oidc_jwks_service import OIDCJWKSService + + +class OIDCTokenService: + """Service for generating and validating OIDC tokens. + + This service handles: + - Access token creation (JWT) + - ID token creation (JWT) + - Refresh token creation (opaque) + - Token signature verification + - Hash generation for PKCE claims (at_hash, c_hash) + """ + + @staticmethod + def _generate_jti() -> str: + """Generate a unique JWT ID.""" + return secrets.token_urlsafe(32) + + @staticmethod + def _generate_opaque_token(length: int = 43) -> str: + """Generate an opaque token (for refresh tokens). + + Args: + length: Length of the token + + Returns: + URL-safe base64 encoded token + """ + return secrets.token_urlsafe(length) + + @staticmethod + def _hash_token(token: str) -> str: + """Hash a token for secure storage. + + Args: + token: Token to hash + + Returns: + SHA256 hash of the token + """ + return hashlib.sha256(token.encode()).hexdigest() + + @staticmethod + def _base64url_encode(data: bytes) -> str: + """Encode bytes to base64url format without padding. + + Args: + data: Bytes to encode + + Returns: + Base64url encoded string + """ + return base64.urlsafe_b64encode(data).decode().rstrip("=") + + @staticmethod + def create_at_hash(access_token: str) -> str: + """Create the at_hash claim for ID token. + + Implements OIDC spec for access token hash generation. + Hash is the left-most half of the hash of the ASCII representation + of the access token. + + Args: + access_token: The access token string + + Returns: + Base64url encoded hash + """ + # Hash the access token using SHA256 + hash_digest = hashlib.sha256(access_token.encode()).digest() + + # Take left-most half of the hash + half_length = len(hash_digest) // 2 + left_half = hash_digest[:half_length] + + # Base64url encode + return OIDCTokenService._base64url_encode(left_half) + + @staticmethod + def create_c_hash(code: str) -> str: + """Create the c_hash claim for ID token. + + Implements OIDC spec for authorization code hash generation. + + Args: + code: The authorization code string + + Returns: + Base64url encoded hash + """ + # Hash the code using SHA256 + hash_digest = hashlib.sha256(code.encode()).digest() + + # Take left-most half of the hash + half_length = len(hash_digest) // 2 + left_half = hash_digest[:half_length] + + # Base64url encode + return OIDCTokenService._base64url_encode(left_half) + + @staticmethod + def _get_issuer() -> str: + """Get the OIDC issuer URL.""" + return current_app.config.get("OIDC_ISSUER_URL", "http://localhost:5000") + + @staticmethod + def _get_token_lifetime(client: OIDCClient, token_type: str) -> int: + """Get the token lifetime in seconds for a client. + + Args: + client: OIDCClient instance + token_type: Type of token ("access_token", "refresh_token", "id_token") + + Returns: + Lifetime in seconds + """ + lifetimes = { + "access_token": client.access_token_lifetime or 3600, + "refresh_token": client.refresh_token_lifetime or 2592000, + "id_token": client.id_token_lifetime or 3600, + } + return lifetimes.get(token_type, 3600) + + @classmethod + def create_access_token(cls, client_id: str, user_id: str, scope: list, + jti: str = None) -> str: + """Create a JWT access token. + + Args: + client_id: The OIDC client ID + user_id: The user ID (subject) + scope: List of granted scopes + jti: Optional JWT ID (generated if not provided) + + Returns: + JWT access token string + """ + jti = jti or cls._generate_jti() + now = datetime.utcnow() + + # Get client for token lifetime + client = OIDCClient.query.filter_by(client_id=client_id).first() + lifetime = cls._get_token_lifetime(client, "access_token") if client else 3600 + + claims = { + "iss": cls._get_issuer(), + "sub": user_id, + "aud": client_id, + "exp": int((now + timedelta(seconds=lifetime)).timestamp()), + "iat": int(now.timestamp()), + "nbf": int(now.timestamp()), + "jti": jti, + "client_id": client_id, + "scope": " ".join(scope) if isinstance(scope, list) else scope, + } + + # Get signing key + jwks_service = OIDCJWKSService() + signing_key = jwks_service.get_signing_key() + + if not signing_key: + raise ValueError("No signing key available") + + # Sign with RS256 + token = jwt.encode( + claims, + signing_key.private_key, + algorithm="RS256", + headers={"kid": signing_key.kid} + ) + + return token + + @classmethod + def create_id_token(cls, client_id: str, user_id: str, nonce: str = None, + scope: list = None, access_token: str = None, + auth_time: int = None) -> str: + """Create a JWT ID token. + + Args: + client_id: The OIDC client ID + user_id: The user ID (subject) + nonce: Nonce for replay protection + scope: Requested/Granted scopes + access_token: Associated access token (for at_hash) + auth_time: Authentication time (Unix timestamp) + + Returns: + JWT ID token string + """ + now = datetime.utcnow() + auth_time = auth_time or int(now.timestamp()) + + # Get client for token lifetime + client = OIDCClient.query.filter_by(client_id=client_id).first() + lifetime = cls._get_token_lifetime(client, "id_token") if client else 3600 + + # Get user for claims + user = User.query.get(user_id) + + claims = { + "iss": cls._get_issuer(), + "sub": user_id, + "aud": client_id, + "exp": int((now + timedelta(seconds=lifetime)).timestamp()), + "iat": int(now.timestamp()), + "auth_time": auth_time, + } + + # Add nonce if provided + if nonce: + claims["nonce"] = nonce + + # Add at_hash if access token provided + if access_token: + claims["at_hash"] = cls.create_at_hash(access_token) + + # Add standard claims if user exists + if user: + if user.email: + claims["email"] = user.email + claims["email_verified"] = user.email_verified + if user.full_name: + claims["name"] = user.full_name + + # Add scope if provided + if scope: + claims["scope"] = " ".join(scope) if isinstance(scope, list) else scope + + # Get signing key + jwks_service = OIDCJWKSService() + signing_key = jwks_service.get_signing_key() + + if not signing_key: + raise ValueError("No signing key available") + + # Sign with RS256 + token = jwt.encode( + claims, + signing_key.private_key, + algorithm="RS256", + headers={"kid": signing_key.kid} + ) + + return token + + @classmethod + def create_refresh_token(cls, client_id: str, user_id: str, + scope: list = None, access_token_id: str = None) -> str: + """Create an opaque refresh token. + + Args: + client_id: The OIDC client ID + user_id: The user ID + scope: List of granted scopes + access_token_id: Associated access token ID + + Returns: + Opaque refresh token string + """ + token = cls._generate_opaque_token() + + # Hash for storage + token_hash = cls._hash_token(token) + + return token, token_hash + + @classmethod + def verify_token_signature(cls, token: str) -> Dict: + """Verify the signature of a JWT token. + + Args: + token: JWT token string + + Returns: + Decoded token claims + + Raises: + jwt.InvalidSignatureError: If signature verification fails + jwt.ExpiredSignatureError: If token is expired + jwt.InvalidTokenError: If token is invalid + """ + # Get the JWKS with public keys + jwks_service = OIDCJWKSService() + jwks = jwks_service.get_jwks() + + # Get the key ID from token header + try: + unverified_header = jwt.get_unverified_header(token) + except jwt.DecodeError: + raise jwt.InvalidTokenError("Invalid token header") + + kid = unverified_header.get("kid") + + # Find the matching public key + public_key = None + for key in jwks.get("keys", []): + if key.get("kid") == kid: + try: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.backends import default_backend + + public_key = serialization.load_pem_public_key( + key["public_key"].encode() if isinstance(key["public_key"], str) + else key["public_key"], + backend=default_backend() + ) + break + except (ImportError, Exception): + continue + + if not public_key: + raise jwt.InvalidSignatureError(f"Key with kid={kid} not found") + + # Verify the signature + claims = jwt.decode( + token, + public_key, + algorithms=["RS256"], + audience=None, # We'll validate audience separately + issuer=cls._get_issuer(), + options={ + "verify_signature": True, + "verify_exp": True, + "verify_aud": False, # Handle audience manually + "verify_iss": False, # Handle issuer manually + } + ) + + return claims + + @classmethod + def decode_token(cls, token: str, verify: bool = False) -> Dict: + """Decode a JWT token without verification (for debugging). + + Args: + token: JWT token string + verify: Whether to verify signature + + Returns: + Decoded token claims + """ + if verify: + return cls.verify_token_signature(token) + + return jwt.decode( + token, + options={ + "verify_signature": False, + "verify_exp": False, + } + ) + + @classmethod + def validate_access_token(cls, token: str, client_id: str = None) -> Dict: + """Validate an access token and return its claims. + + Args: + token: JWT access token + client_id: Optional client ID to validate audience + + Returns: + Token claims dictionary + + Raises: + jwt.InvalidTokenError: If token is invalid + ValueError: If token is expired or audience mismatch + """ + claims = cls.verify_token_signature(token) + + # Check expiration + if claims.get("exp", 0) < datetime.utcnow().timestamp(): + raise ValueError("Token has expired") + + # Validate audience if client_id provided + if client_id: + if claims.get("aud") != client_id: + raise ValueError("Invalid audience") + + return claims + + @classmethod + def introspect_token(cls, token: str, client_id: str = None) -> Dict: + """Introspect a token and return its status and claims. + + Args: + token: JWT token to introspect + client_id: Client ID for audience validation + + Returns: + Dictionary with active status and claims + """ + result = { + "active": False, + } + + try: + claims = cls.validate_access_token(token, client_id) + + # Calculate remaining time + now = datetime.utcnow().timestamp() + exp = claims.get("exp", 0) + iat = claims.get("iat", 0) + + result["active"] = exp > now + result.update({ + "iss": claims.get("iss"), + "sub": claims.get("sub"), + "aud": claims.get("aud"), + "exp": exp, + "iat": iat, + "nbf": claims.get("nbf"), + "jti": claims.get("jti"), + "client_id": claims.get("client_id"), + "scope": claims.get("scope"), + "token_type": "Bearer", + }) + + # Add expiry in seconds + if exp > now: + result["exp"] = int(exp - now) + + except (jwt.InvalidTokenError, ValueError) as e: + result["active"] = False + result["error"] = str(e) + + return result diff --git a/config/__init__.py b/config/__init__.py index ab07fc0..2ea4c99 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -3,13 +3,11 @@ import os from config.base import BaseConfig from config.development import DevelopmentConfig from config.testing import TestingConfig -from config.production import ProductionConfig config_by_name = { "development": DevelopmentConfig, "testing": TestingConfig, - "production": ProductionConfig, "default": DevelopmentConfig, } @@ -18,4 +16,11 @@ def get_config(config_name=None): """Get configuration object based on environment.""" if config_name is None: config_name = os.getenv("FLASK_ENV", "development") + + # Lazy import of ProductionConfig to avoid requiring SECRET_KEY in non-production environments + if config_name == "production": + from config.production import ProductionConfig + config_by_name["production"] = ProductionConfig + return ProductionConfig + return config_by_name.get(config_name, DevelopmentConfig) diff --git a/config/base.py b/config/base.py index 9f32ed2..2bf5690 100644 --- a/config/base.py +++ b/config/base.py @@ -17,6 +17,7 @@ class BaseConfig: ) SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_ECHO = os.getenv("SQLALCHEMY_ECHO", "False").lower() == "true" + SQLALCHEMY_LOG_LEVEL = os.getenv("SQLALCHEMY_LOG_LEVEL", "WARNING") SQLALCHEMY_ENGINE_OPTIONS = { "pool_pre_ping": True, "pool_recycle": 300, @@ -47,9 +48,12 @@ class BaseConfig: # Redis REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") - # Flask session configuration - deprecated, migrating to Bearer token authentication - # SESSION_TYPE = "redis" - # SESSION_REDIS = None # Will be set at app initialization + + # Flask Session configuration + SESSION_TYPE = os.getenv("SESSION_TYPE", "filesystem") + SESSION_FILE_DIR = os.getenv("SESSION_FILE_DIR", "/tmp/flask_session") + SESSION_FILE_THRESHOLD = int(os.getenv("SESSION_FILE_THRESHOLD", "500")) + SESSION_REDIS = None # Will be set at app initialization # Rate Limiting RATELIMIT_ENABLED = os.getenv("RATELIMIT_ENABLED", "True").lower() == "true" @@ -60,8 +64,30 @@ class BaseConfig: LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") LOG_TO_STDOUT = os.getenv("LOG_TO_STDOUT", "False").lower() == "true" - # OIDC + # OIDC Configuration OIDC_ISSUER_URL = os.getenv("OIDC_ISSUER_URL", "http://localhost:5000") + OIDC_BASE_URL = os.getenv("OIDC_BASE_URL", OIDC_ISSUER_URL) + + # Token lifetimes + OIDC_ACCESS_TOKEN_LIFETIME = int(os.getenv("OIDC_ACCESS_TOKEN_LIFETIME", "3600")) + OIDC_REFRESH_TOKEN_LIFETIME = int(os.getenv("OIDC_REFRESH_TOKEN_LIFETIME", "2592000")) + OIDC_ID_TOKEN_LIFETIME = int(os.getenv("OIDC_ID_TOKEN_LIFETIME", "3600")) + OIDC_AUTHORIZATION_CODE_LIFETIME = int(os.getenv("OIDC_AUTHORIZATION_CODE_LIFETIME", "600")) + + # Security settings + OIDC_REQUIRE_PKCE = os.getenv("OIDC_REQUIRE_PKCE", "True").lower() == "true" + OIDC_ALLOW_IMPLICIT_FLOW = os.getenv("OIDC_ALLOW_IMPLICIT_FLOW", "False").lower() == "true" + OIDC_SUPPORTED_SCOPES = ["openid", "profile", "email"] + OIDC_DEFAULT_SCOPES = ["openid", "profile", "email"] + + # Key rotation + OIDC_KEY_ROTATION_DAYS = int(os.getenv("OIDC_KEY_ROTATION_DAYS", "90")) + OIDC_KEY_GRACE_PERIOD_DAYS = int(os.getenv("OIDC_KEY_GRACE_PERIOD_DAYS", "30")) + + # Rate limiting + OIDC_RATE_LIMIT_AUTHORIZE = os.getenv("OIDC_RATE_LIMIT_AUTHORIZE", "10/minute") + OIDC_RATE_LIMIT_TOKEN = os.getenv("OIDC_RATE_LIMIT_TOKEN", "20/minute") + OIDC_RATE_LIMIT_USERINFO = os.getenv("OIDC_RATE_LIMIT_USERINFO", "60/minute") # API Versioning API_VERSION = "1.0.0" diff --git a/config/development.py b/config/development.py index 74e87e7..ca84166 100644 --- a/config/development.py +++ b/config/development.py @@ -1,12 +1,13 @@ """Development environment configuration.""" from config.base import BaseConfig - +import os class DevelopmentConfig(BaseConfig): """Development configuration.""" DEBUG = True - SQLALCHEMY_ECHO = True + # Use environment variable like BaseConfig does + SQLALCHEMY_ECHO = os.getenv("SQLALCHEMY_ECHO", "False").lower() == "true" SESSION_COOKIE_SECURE = False # More verbose logging in development diff --git a/config/testing.py b/config/testing.py index 6ee817f..4ecfff0 100644 --- a/config/testing.py +++ b/config/testing.py @@ -1,5 +1,6 @@ """Testing environment configuration.""" from config.base import BaseConfig +import os class TestingConfig(BaseConfig): @@ -7,6 +8,9 @@ class TestingConfig(BaseConfig): TESTING = True DEBUG = True + + # Explicitly set SECRET_KEY for testing + SECRET_KEY = os.getenv("SECRET_KEY", "test-secret-key-for-testing") # Use in-memory SQLite for testing SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:" @@ -23,3 +27,7 @@ class TestingConfig(BaseConfig): # Use different Redis DB for testing REDIS_URL = "redis://localhost:6379/15" + + # Use filesystem for sessions in testing + SESSION_TYPE = "filesystem" + SESSION_FILE_DIR = "/tmp/flask_session_test" diff --git a/docs/OIDC.md b/docs/OIDC.md new file mode 100644 index 0000000..9d2a285 --- /dev/null +++ b/docs/OIDC.md @@ -0,0 +1,1516 @@ +# OpenID Connect (OIDC) Provider Documentation + +This document provides comprehensive documentation for the Authy2 OIDC (OpenID Connect) provider implementation. Use this as the main reference for integrating with the OIDC provider. + +## Table of Contents + +1. [Overview](#overview) +2. [Quick Start](#quick-start) +3. [API Endpoints Reference](#api-endpoints-reference) +4. [OIDC Client Configuration](#oidc-client-configuration) +5. [Integration Examples](#integration-examples) +6. [Security Considerations](#security-considerations) +7. [Deployment Checklist](#deployment-checklist) +8. [Troubleshooting](#troubleshooting) + +--- + +## Overview + +### What is OIDC? + +OpenID Connect (OIDC) is an identity layer built on top of OAuth 2.0 that allows clients to verify the identity of end-users and obtain basic profile information. It enables single sign-on (SSO) capabilities across applications. + +### Why Use OIDC? + +- **Standardized Authentication**: Industry-standard protocol with broad client library support +- **User Identity Verification**: Verifies user identity through ID tokens (JWTs) +- **Scoped Access**: Request specific user information with granular permissions +- **Security**: Built-in support for PKCE, token rotation, and secure token handling +- **Interoperability**: Works with numerous identity providers and client applications + +### Integration with Authy2 + +The OIDC provider integrates with the existing Authy2 authentication system: + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ OIDC Provider │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” │ +│ │ Discovery │ │ Authorization│ │ Token Endpoint │ │ +│ │ Endpoint │ │ Endpoint │ │ │ │ +│ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │ +│ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” │ +│ │ UserInfo │ │ JWKS │ │ Revocation/Introspect│ │ +│ │ Endpoint │ │ Endpoint │ │ │ │ +│ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā–¼ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Authy2 Core Services │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ • User Service • Session Service • Audit Service │ +│ • Auth Service • OIDC Token Service │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā–¼ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ PostgreSQL Database │ +ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤ +│ • Users • OIDC Clients • OIDC Authorization Codes │ +│ • Sessions • Refresh Tokens • Token Metadata & Audit Logs │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +### Supported OIDC Flows + +| Flow | Support | Description | +|------|---------|-------------| +| Authorization Code with PKCE | āœ… Full | Recommended for all clients | +| Authorization Code | āš ļø Deprecated | PKCE required for new clients | +| Refresh Token | āœ… Full | Token rotation supported | + +--- + +## Quick Start + +### Prerequisites + +1. **Python 3.9+** with pip +2. **PostgreSQL 13+** database +3. **Redis** (optional, for session storage) +4. **OIDC Client Library** for your platform + +### Installation + +1. Clone the repository and install dependencies: + +```bash +git clone +cd backend +pip install -r requirements/base.txt +``` + +2. Set up environment variables: + +```bash +cp .env.example .env +# Edit .env with your configuration +``` + +3. Run database migrations: + +```bash +python manage.py db upgrade +``` + +### Database Setup + +The OIDC provider requires the following tables (automatically created via migrations): + +- `oidc_clients` - Registered OIDC clients +- `oidc_authorization_codes` - Temporary authorization codes +- `oidc_refresh_tokens` - Refresh tokens with rotation support +- `oidc_sessions` - OIDC session tracking +- `oidc_token_metadata` - Token metadata for revocation +- `oidc_audit_logs` - Audit trail for all OIDC operations + +### Basic Configuration + +Configure the following environment variables: + +```bash +# Database +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/authy2 + +# Redis (optional) +REDIS_URL=redis://localhost:6379/0 + +# OIDC +OIDC_ISSUER_URL=http://localhost:5000 + +# Security +SECRET_KEY=your-secure-secret-key-min-32-chars +BCRYPT_LOG_ROUNDS=12 + +# Logging +LOG_LEVEL=INFO +``` + +### Creating Your First OIDC Client + +Register a new OIDC client using the registration endpoint: + +```bash +curl -X POST http://localhost:5000/oidc/register \ + -H "Content-Type: application/json" \ + -d '{ + "client_name": "My Application", + "redirect_uris": ["http://localhost:8080/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "scope": "openid profile email", + "token_endpoint_auth_method": "client_secret_basic" + }' +``` + +**Response:** + +```json +{ + "version": "1.0", + "success": true, + "code": 201, + "message": "Client registered successfully", + "request_id": "...", + "data": { + "client_id": "oidc_abc123...", + "client_secret": "secret_xyz789...", + "client_id_issued_at": 1704067200, + "client_secret_expires_at": 0, + "client_name": "My Application", + "redirect_uris": ["http://localhost:8080/callback"], + "token_endpoint_auth_method": "client_secret_basic", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "scope": "openid profile email" + } +} +``` + +**Important:** Save the `client_id` and `client_secret` securely. The `client_secret` will not be shown again. + +--- + +## API Endpoints Reference + +All endpoints follow the standard API response format documented in [`docs/architecture.md`](docs/architecture.md). + +### 1. Discovery Endpoint + +**URL:** `GET /.well-known/openid-configuration` + +Returns the OIDC provider configuration as JSON. + +**Request:** + +```bash +curl http://localhost:5000/.well-known/openid-configuration +``` + +**Response:** + +```json +{ + "issuer": "http://localhost:5000", + "authorization_endpoint": "http://localhost:5000/oidc/authorize", + "token_endpoint": "http://localhost:5000/oidc/token", + "userinfo_endpoint": "http://localhost:5000/oidc/userinfo", + "jwks_uri": "http://localhost:5000/oidc/jwks", + "registration_endpoint": "http://localhost:5000/oidc/register", + "revocation_endpoint": "http://localhost:5000/oidc/revoke", + "introspection_endpoint": "http://localhost:5000/oidc/introspect", + "scopes_supported": ["openid", "profile", "email"], + "response_types_supported": ["code"], + "response_modes_supported": ["query"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + "claims_supported": ["sub", "name", "email", "email_verified"] +} +``` + +**Headers:** +- `Cache-Control: max-age=86400` (cached for 24 hours) + +**Status Codes:** +- `200` - Success +- `500` - Server error + +--- + +### 2. Authorization Endpoint + +**URL:** `GET/POST /oidc/authorize` + +Initiates the OIDC authentication flow. Supports both GET (browser redirect) and POST (direct API) requests. + +**Request Parameters (GET/POST):** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `client_id` | string | Yes | The client ID | +| `redirect_uri` | string | Yes | Redirect URI after authorization | +| `response_type` | string | Yes | Must be `"code"` | +| `scope` | string | Yes | Space-separated scopes (e.g., `"openid profile email"`) | +| `state` | string | Recommended | Opaque state for CSRF protection | +| `nonce` | string | Recommended | Nonce for ID token replay protection | +| `code_challenge` | string | For PKCE | PKCE code challenge | +| `code_challenge_method` | string | For PKCE | `"S256"` or `"plain"` | +| `prompt` | string | No | `"login"`, `"consent"`, `"select_account"`, `"none"` | +| `max_age` | integer | No | Maximum authentication age in seconds | +| `acr_values` | string | No | Requested Authentication Context Class Reference | + +**POST-only Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `email` | string | Yes* | User email (for direct authentication) | +| `password` | string | Yes* | User password (for direct authentication) | + +*Required for POST requests without session + +**Request (GET - Browser):** + +``` +http://localhost:5000/oidc/authorize?\ + client_id=YOUR_CLIENT_ID&\ + redirect_uri=http://localhost:8080/callback&\ + response_type=code&\ + scope=openid%20profile%20email&\ + state=YOUR_STATE&\ + nonce=YOUR_NONCE&\ + code_challenge=YOUR_CODE_CHALLENGE&\ + code_challenge_method=S256 +``` + +**Request (POST - Direct API):** + +```bash +curl -X POST http://localhost:5000/oidc/authorize \ + -d "client_id=YOUR_CLIENT_ID" \ + -d "redirect_uri=http://localhost:8080/callback" \ + -d "response_type=code" \ + -d "scope=openid profile email" \ + -d "state=YOUR_STATE" \ + -d "nonce=YOUR_NONCE" \ + -d "code_challenge=YOUR_CODE_CHALLENGE" \ + -d "code_challenge_method=S256" \ + -d "email=user@example.com" \ + -d "password=UserPassword123!" +``` + +**Success Response (302 Redirect):** + +```http +HTTP/1.1 302 Found +Location: http://localhost:8080/callback?code=AUTHORIZATION_CODE&state=YOUR_STATE +``` + +**Error Response (302 Redirect with Error):** + +```http +HTTP/1.1 302 Found +Location: http://localhost:8080/callback?error=invalid_request&error_description=Invalid+client_id&state=YOUR_STATE +``` + +**Error Codes:** + +| Error Code | Description | +|------------|-------------| +| `invalid_request` | Missing or invalid required parameter | +| `unauthorized_client` | Client not authorized for this flow | +| `unsupported_response_type` | `response_type` not supported | +| `invalid_scope` | Invalid or disallowed scope | +| `invalid_request` | Invalid `redirect_uri` | + +**Status Codes:** +- `302` - Redirect to callback URL +- `200` - Login page (GET when not authenticated) +- `400` - Invalid request + +--- + +### 3. Token Endpoint + +**URL:** `POST /oidc/token` + +Exchanges authorization codes for tokens or refreshes tokens. + +**Request Headers:** +- `Content-Type: application/x-www-form-urlencoded` + +**Request Body:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `grant_type` | string | Yes | `"authorization_code"` or `"refresh_token"` | +| `client_id` | string | Yes* | The client ID | +| `client_secret` | string | Yes* | The client secret | + +*Required if not using Basic authentication + +**For `authorization_code` grant:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `code` | string | Yes | The authorization code | +| `redirect_uri` | string | Yes | The redirect URI used in authorization | +| `code_verifier` | string | For PKCE | PKCE code verifier | + +**For `refresh_token` grant:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `refresh_token` | string | Yes | The refresh token | +| `scope` | string | No | Optional scope override | + +**Request (Authorization Code):** + +```bash +curl -X POST http://localhost:5000/oidc/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=authorization_code" \ + -d "code=AUTHORIZATION_CODE" \ + -d "redirect_uri=http://localhost:8080/callback" \ + -d "client_id=YOUR_CLIENT_ID" \ + -d "client_secret=YOUR_CLIENT_SECRET" \ + -d "code_verifier=YOUR_CODE_VERIFIER" +``` + +**Request (Refresh Token):** + +```bash +curl -X POST http://localhost:5000/oidc/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=refresh_token" \ + -d "refresh_token=YOUR_REFRESH_TOKEN" \ + -d "client_id=YOUR_CLIENT_ID" \ + -d "client_secret=YOUR_CLIENT_SECRET" +``` + +**Success Response:** + +```json +{ + "version": "1.0", + "success": true, + "code": 200, + "message": "Tokens issued successfully", + "request_id": "...", + "data": { + "access_token": "eyJ...", + "token_type": "Bearer", + "expires_in": 3600, + "id_token": "eyJ...", + "refresh_token": "..." + } +} +``` + +**Token Response Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `access_token` | string | JWT access token | +| `token_type` | string | Always `"Bearer"` | +| `expires_in` | integer | Token lifetime in seconds | +| `id_token` | string | JWT ID token | +| `refresh_token` | string | Opaque refresh token (if granted) | + +**Error Response:** + +```json +{ + "version": "1.0", + "success": false, + "code": 400, + "message": "Invalid authorization code", + "error": { + "type": "INVALID_GRANT", + "details": { + "error": "invalid_grant", + "error_description": "Invalid or expired authorization code" + } + } +} +``` + +**Status Codes:** +- `200` - Tokens issued successfully +- `400` - Invalid request or grant +- `401` - Invalid client credentials +- `500` - Server error + +--- + +### 4. UserInfo Endpoint + +**URL:** `GET/POST /oidc/userinfo` + +Returns claims about the authenticated user. + +**Request Headers:** +- `Authorization: Bearer {access_token}` + +**Request:** + +```bash +curl -X GET http://localhost:5000/oidc/userinfo \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +**Response:** + +```json +{ + "version": "1.0", + "success": true, + "code": 200, + "message": "User info retrieved successfully", + "request_id": "...", + "data": { + "sub": "user-uuid", + "name": "John Doe", + "email": "john@example.com", + "email_verified": true + } +} +``` + +**Claims by Scope:** + +| Scope | Claims | +|-------|--------| +| `openid` | `sub` | +| `profile` | `name`, `preferred_username`, `picture` | +| `email` | `email`, `email_verified` | + +**Status Codes:** +- `200` - User info returned +- `401` - Invalid or expired token +- `500` - Server error + +--- + +### 5. JWKS Endpoint + +**URL:** `GET /oidc/jwks` + +Returns the JSON Web Key Set containing public keys for token verification. + +**Request:** + +```bash +curl http://localhost:5000/oidc/jwks +``` + +**Response:** + +```json +{ + "keys": [ + { + "kty": "RSA", + "kid": "key-id-123", + "use": "sig", + "alg": "RS256", + "n": "base64-encoded-modulus", + "e": "AQAB" + } + ] +} +``` + +**Key Properties:** + +| Property | Description | +|----------|-------------| +| `kty` | Key type (always `"RSA"`) | +| `kid` | Key ID for key selection | +| `use` | Key usage (`"sig"` for signature) | +| `alg` | Algorithm (`"RS256"`) | +| `n` | RSA modulus (base64url encoded) | +| `e` | RSA exponent (base64url encoded) | + +**Headers:** +- `Cache-Control: max-age=3600` (cached for 1 hour) + +**Status Codes:** +- `200` - JWKS returned +- `500` - Server error + +--- + +### 6. Token Revocation Endpoint + +**URL:** `POST /oidc/revoke` + +Revokes an access token or refresh token. + +**Request Headers:** +- `Content-Type: application/x-www-form-urlencoded` + +**Request Body:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `token` | string | Yes | The token to revoke | +| `token_type_hint` | string | No | `"access_token"` or `"refresh_token"` | +| `client_id` | string | Yes* | The client ID | +| `client_secret` | string | Yes* | The client secret | + +*Required if not using Basic authentication + +**Request:** + +```bash +curl -X POST http://localhost:5000/oidc/revoke \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "token=YOUR_TOKEN" \ + -d "token_type_hint=access_token" \ + -d "client_id=YOUR_CLIENT_ID" \ + -d "client_secret=YOUR_CLIENT_SECRET" +``` + +**Response:** + +```json +{ + "version": "1.0", + "success": true, + "code": 200, + "message": "Token revoked successfully", + "request_id": "..." +} +``` + +**Notes:** +- Revocation always returns 200, even if token is invalid +- Both access tokens and refresh tokens can be revoked +- Revoking a refresh token also invalidates associated access tokens + +**Status Codes:** +- `200` - Token revoked (or no-op) +- `400` - Invalid request +- `401` - Invalid client credentials +- `500` - Server error + +--- + +### 7. Token Introspection Endpoint + +**URL:** `POST /oidc/introspect` + +Returns information about a token's status and claims. + +**Request Headers:** +- `Content-Type: application/x-www-form-urlencoded` + +**Request Body:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `token` | string | Yes | The token to introspect | +| `token_type_hint` | string | No | `"access_token"` or `"refresh_token"` | +| `client_id` | string | Yes* | The client ID | +| `client_secret` | string | Yes* | The client secret | + +*Required if not using Basic authentication + +**Request:** + +```bash +curl -X POST http://localhost:5000/oidc/introspect \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "token=YOUR_ACCESS_TOKEN" \ + -d "client_id=YOUR_CLIENT_ID" \ + -d "client_secret=YOUR_CLIENT_SECRET" +``` + +**Response (Active Token):** + +```json +{ + "version": "1.0", + "success": true, + "code": 200, + "message": "Token introspection successful", + "request_id": "...", + "data": { + "active": true, + "iss": "http://localhost:5000", + "sub": "user-uuid", + "aud": "YOUR_CLIENT_ID", + "exp": 1704070800, + "iat": 1704067200, + "nbf": 1704067200, + "jti": "token-jti", + "client_id": "YOUR_CLIENT_ID", + "scope": "openid profile email", + "token_type": "Bearer" + } +} +``` + +**Response (Inactive/Expired Token):** + +```json +{ + "version": "1.0", + "success": true, + "code": 200, + "message": "Token introspection successful", + "request_id": "...", + "data": { + "active": false + } +} +``` + +**Status Codes:** +- `200` - Introspection complete +- `400` - Invalid request +- `401` - Invalid client credentials +- `500` - Server error + +--- + +### 8. Client Registration Endpoint + +**URL:** `POST /oidc/register` + +Registers a new OIDC client dynamically. + +**Request Headers:** +- `Content-Type: application/json` + +**Request Body:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `client_name` | string | Yes | Display name for the client | +| `redirect_uris` | array | Yes | Array of redirect URIs | +| `grant_types` | array | No | Array of grant types (default: `["authorization_code", "refresh_token"]`) | +| `response_types` | array | No | Array of response types (default: `["code"]`) | +| `scope` | string | No | Space-separated scopes (default: `"openid profile email"`) | +| `token_endpoint_auth_method` | string | No | `"client_secret_basic"` or `"client_secret_post"` | +| `logo_uri` | string | No | Client logo URL | +| `client_uri` | string | No | Client homepage URL | +| `policy_uri` | string | No | Privacy policy URL | +| `tos_uri` | string | No | Terms of service URL | +| `organization_id` | string | No | Organization ID for client ownership | + +**Request:** + +```bash +curl -X POST http://localhost:5000/oidc/register \ + -H "Content-Type: application/json" \ + -d '{ + "client_name": "My Application", + "redirect_uris": ["http://localhost:8080/callback", "https://myapp.com/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "scope": "openid profile email", + "token_endpoint_auth_method": "client_secret_basic", + "logo_uri": "https://myapp.com/logo.png", + "client_uri": "https://myapp.com", + "policy_uri": "https://myapp.com/privacy", + "tos_uri": "https://myapp.com/terms" + }' +``` + +**Success Response (201 Created):** + +```json +{ + "version": "1.0", + "success": true, + "code": 201, + "message": "Client registered successfully", + "request_id": "...", + "data": { + "client_id": "oidc_abc123...", + "client_secret": "secret_xyz789...", + "client_id_issued_at": 1704067200, + "client_secret_expires_at": 0, + "client_name": "My Application", + "redirect_uris": ["http://localhost:8080/callback", "https://myapp.com/callback"], + "token_endpoint_auth_method": "client_secret_basic", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "scope": "openid profile email" + } +} +``` + +**Validation Rules:** +- `redirect_uris` must contain valid URIs with scheme and netloc +- `grant_types` must be a subset of `["authorization_code", "refresh_token"]` +- `response_types` must be a subset of `["code"]` +- `scope` must be a subset of `["openid", "profile", "email"]` + +**Status Codes:** +- `201` - Client registered successfully +- `400` - Invalid request or validation error +- `500` - Server error + +--- + +## OIDC Client Configuration + +### Client Registration Parameters + +#### Required Fields + +| Field | Type | Description | +|-------|------|-------------| +| `client_name` | string | Human-readable client name | +| `redirect_uris` | array | Array of valid redirect URIs | + +#### Optional Fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `grant_types` | array | `["authorization_code", "refresh_token"]` | Supported grant types | +| `response_types` | array | `["code"]` | Supported response types | +| `scope` | string | `"openid profile email"` | Space-separated scopes | +| `token_endpoint_auth_method` | string | `"client_secret_basic"` | Client authentication method | +| `logo_uri` | string | - | Client logo URL | +| `client_uri` | string | - | Client homepage URL | +| `policy_uri` | string | - | Privacy policy URL | +| `tos_uri` | string | - | Terms of service URL | + +### Redirect URI Validation + +The OIDC provider validates redirect URIs according to RFC 6749: + +1. **Exact Matching**: Redirect URIs are matched exactly (no wildcards) +2. **Scheme Required**: Must have `http://`, `https://`, or custom scheme +3. **No Fragments**: Fragment components (`#`) are not allowed +4. **Query Parameters**: Allowed but must match exactly + +**Valid Redirect URIs:** +``` +https://myapp.com/callback +http://localhost:8080/callback +myapp://oauth/callback +``` + +**Invalid Redirect URIs:** +``` +# Fragment not allowed +https://myapp.com/callback#fragment + +# Wildcard not allowed +https://*.myapp.com/callback + +# Missing netloc +myapp:callback +``` + +### Client Authentication Methods + +| Method | Description | Use Case | +|--------|-------------|----------| +| `client_secret_basic` | Basic auth with `client_id:client_secret` | Server-side applications | +| `client_secret_post` | Credentials in request body | Server-side applications | +| `none` | No authentication (public clients) | Mobile/SPA applications | + +#### Example: Basic Authentication + +```bash +# With client credentials in body +curl -X POST http://localhost:5000/oidc/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=authorization_code" \ + -d "code=CODE" \ + -d "client_id=YOUR_CLIENT_ID" \ + -d "client_secret=YOUR_CLIENT_SECRET" + +# With Basic authentication header +curl -X POST http://localhost:5000/oidc/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -H "Authorization: Basic $(echo -n 'YOUR_CLIENT_ID:YOUR_CLIENT_SECRET' | base64)" \ + -d "grant_type=authorization_code" \ + -d "code=CODE" +``` + +--- + +## Integration Examples + +### OAuth2-Proxy Integration + +See [`docs/oauth2-proxy-config.yaml`](docs/oauth2-proxy-config.yaml) for complete configuration. + +**Quick Setup:** + +1. Register an OIDC client: + +```bash +curl -X POST http://localhost:5000/oidc/register \ + -H "Content-Type: application/json" \ + -d '{ + "client_name": "oauth2-proxy", + "redirect_uris": ["http://localhost:4180/oauth2/callback"], + "scope": "openid profile email" + }' +``` + +2. Create `oauth2-proxy.yaml`: + +```yaml +provider: "oidc" +oidc_issuer_url: "http://localhost:5000" +client_id: "your-client-id" +client_secret: "your-client-secret" +cookie_secret: "your-random-cookie-secret-min-32-chars" +cookie_name: "_oauth2_proxy" +http_address: "0.0.0.0:4180" +upstream: "http://127.0.0.1:8080/" +redirect_url: "http://localhost:4180/oauth2/callback" +scope: "openid profile email" +``` + +3. Start oauth2-proxy: + +```bash +oauth2-proxy -config oauth2-proxy.yaml +``` + +### Generic OIDC Client Integration + +#### Python Example + +```python +import requests +import base64 +import secrets +import hashlib + +class OIDCClient: + def __init__(self, issuer_url, client_id, client_secret): + self.issuer_url = issuer_url.rstrip('/') + self.client_id = client_id + self.client_secret = client_secret + + # Fetch discovery document + disc_url = f"{self.issuer_url}/.well-known/openid-configuration" + self.discovery = requests.get(disc_url).json() + + def generate_pkce(self): + """Generate PKCE code verifier and challenge.""" + code_verifier = secrets.token_urlsafe(43) + code_challenge = hashlib.sha256(code_verifier.encode()).digest() + code_challenge = base64.urlsafe_b64encode(code_challenge).decode().rstrip('=') + return code_verifier, code_challenge + + def authorize_url(self, redirect_uri, scopes, state=None, nonce=None): + """Generate authorization URL.""" + params = { + 'client_id': self.client_id, + 'redirect_uri': redirect_uri, + 'response_type': 'code', + 'scope': ' '.join(scopes), + 'state': state or secrets.token_hex(16), + 'nonce': nonce or secrets.token_hex(16), + } + + code_verifier, code_challenge = self.generate_pkce() + params['code_challenge'] = code_challenge + params['code_challenge_method'] = 'S256' + + # Build URL + query = '&'.join(f"{k}={requests.utils.quote(v)}" for k, v in params.items()) + return f"{self.discovery['authorization_endpoint']}?{query}", code_verifier + + def token(self, code, redirect_uri, code_verifier=None): + """Exchange authorization code for tokens.""" + data = { + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': redirect_uri, + 'client_id': self.client_id, + 'client_secret': self.client_secret, + } + if code_verifier: + data['code_verifier'] = code_verifier + + response = requests.post( + self.discovery['token_endpoint'], + data=data, + headers={'Content-Type': 'application/x-www-form-urlencoded'} + ) + return response.json() + + def userinfo(self, access_token): + """Get user info.""" + response = requests.get( + self.discovery['userinfo_endpoint'], + headers={'Authorization': f'Bearer {access_token}'} + ) + return response.json() + + def refresh(self, refresh_token): + """Refresh access token.""" + data = { + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token, + 'client_id': self.client_id, + 'client_secret': self.client_secret, + } + response = requests.post( + self.discovery['token_endpoint'], + data=data, + headers={'Content-Type': 'application/x-www-form-urlencoded'} + ) + return response.json() + +# Usage +client = OIDCClient( + issuer_url="http://localhost:5000", + client_id="your-client-id", + client_secret="your-client-secret" +) + +# Get authorization URL +auth_url, code_verifier = client.authorize_url( + redirect_uri="http://localhost:8080/callback", + scopes=["openid", "profile", "email"] +) + +# After user authorizes, exchange code for tokens +tokens = client.token("AUTHORIZATION_CODE", "http://localhost:8080/callback", code_verifier) + +# Get user info +userinfo = client.userinfo(tokens['access_token']) + +# Refresh token +new_tokens = client.refresh(tokens['refresh_token']) +``` + +### Example cURL Commands + +#### Complete Authorization Code Flow with PKCE + +```bash +#!/bin/bash +set -e + +BASE_URL="http://localhost:5000" +CLIENT_ID="your-client-id" +CLIENT_SECRET="your-client-secret" +EMAIL="user@example.com" +PASSWORD="UserPassword123!" +REDIRECT_URI="http://localhost:8080/callback" + +echo "=== OIDC Authorization Code Flow ===" + +# Step 1: Generate PKCE parameters +echo "1. Generating PKCE parameters..." +CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=' | tr '/+' '_-' | cut -c1-43) +CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl sha256 -binary | base64 | tr -d '=' | tr '/+' '_-') +STATE=$(openssl rand -hex 16) +NONCE=$(openssl rand -hex 16) +echo " Code verifier: ${CODE_VERIFIER:0:20}..." +echo " Code challenge: $CODE_CHALLENGE" + +# Step 2: Get authorization code +echo "2. Getting authorization code..." +AUTH_RESPONSE=$(curl -s -D - -X POST "$BASE_URL/oidc/authorize" \ + -d "client_id=$CLIENT_ID" \ + -d "redirect_uri=$REDIRECT_URI" \ + -d "response_type=code" \ + -d "scope=openid profile email" \ + -d "state=$STATE" \ + -d "nonce=$NONCE" \ + -d "code_challenge=$CODE_CHALLENGE" \ + -d "code_challenge_method=S256" \ + -d "email=$EMAIL" \ + -d "password=$PASSWORD") + +AUTH_CODE=$(echo "$AUTH_RESPONSE" | grep -i "Location:" | cut -d'?' -f2 | cut -d'=' -f2 | tr -d '\r') +echo " Authorization code: ${AUTH_CODE:0:20}..." + +# Step 3: Exchange code for tokens +echo "3. Exchanging code for tokens..." +TOKEN_RESPONSE=$(curl -s -X POST "$BASE_URL/oidc/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=authorization_code" \ + -d "code=$AUTH_CODE" \ + -d "redirect_uri=$REDIRECT_URI" \ + -d "client_id=$CLIENT_ID" \ + -d "client_secret=$CLIENT_SECRET" \ + -d "code_verifier=$CODE_VERIFIER") + +ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.data.access_token') +REFRESH_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.data.refresh_token') +echo " Access token received: ${ACCESS_TOKEN:0:20}..." + +# Step 4: Get user info +echo "4. Getting user info..." +USERINFO=$(curl -s -X GET "$BASE_URL/oidc/userinfo" \ + -H "Authorization: Bearer $ACCESS_TOKEN") +echo " User: $(echo "$USERINFO" | jq -r '.data.name')" + +# Step 5: Introspect token +echo "5. Introspecting token..." +INTROSPECT=$(curl -s -X POST "$BASE_URL/oidc/introspect" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "token=$ACCESS_TOKEN" \ + -d "client_id=$CLIENT_ID" \ + -d "client_secret=$CLIENT_SECRET") +echo " Token active: $(echo "$INTROSPECT" | jq -r '.data.active')" + +# Step 6: Refresh token +echo "6. Refreshing token..." +REFRESH_RESPONSE=$(curl -s -X POST "$BASE_URL/oidc/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=refresh_token" \ + -d "refresh_token=$REFRESH_TOKEN" \ + -d "client_id=$CLIENT_ID" \ + -d "client_secret=$CLIENT_SECRET") +echo " Token refreshed successfully" + +# Step 7: Revoke tokens +echo "7. Revoking tokens..." +curl -s -X POST "$BASE_URL/oidc/revoke" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "token=$REFRESH_TOKEN" \ + -d "token_type_hint=refresh_token" \ + -d "client_id=$CLIENT_ID" \ + -d "client_secret=$CLIENT_SECRET" > /dev/null +echo " Tokens revoked" + +echo "" +echo "=== Flow Complete ===" +``` + +--- + +## Security Considerations + +### PKCE Requirements + +Proof Key for Code Exchange (PKCE) is **strongly recommended** for all clients, including confidential clients. + +**Why PKCE?** +- Protects against authorization code interception attacks +- Required for public clients (SPA, mobile) +- Recommended for all clients per OAuth 2.1 + +**Implementation:** +1. Generate `code_verifier` (43-128 characters) +2. Create `code_challenge` from verifier (SHA256) +3. Send `code_challenge` and `code_challenge_method` in authorization request +4. Send `code_verifier` in token request + +```python +import hashlib +import base64 +import secrets + +# Generate code verifier +code_verifier = secrets.token_urlsafe(43) + +# Generate code challenge +code_challenge = base64.urlsafe_b64encode( + hashlib.sha256(code_verifier.encode()).digest() +).decode().rstrip('=') +``` + +### Token Lifetimes + +| Token Type | Default | Maximum | Description | +|------------|---------|---------|-------------| +| Access Token | 3600s (1 hour) | 86400s (24h) | Short-lived token for API access | +| ID Token | 3600s (1 hour) | 86400s (24h) | Identity token | +| Refresh Token | 2592000s (30 days) | 31536000s (1 year) | Long-lived token for refresh | + +**Configuration:** +Configure token lifetimes per client in the database or during registration. + +### Redirect URI Validation + +Strict redirect URI validation is critical for security: + +1. **Exact Matching**: Use exact string matching (no wildcards) +2. **HTTPS Required**: Require HTTPS in production +3. **No Wildcards**: Never allow wildcards in domains +4. **Validate All URIs**: Validate each registered redirect URI +5. **Case Sensitivity**: Consider case sensitivity in path components + +**Example Validation:** + +```python +from urllib.parse import urlparse + +def validate_redirect_uri(uri): + parsed = urlparse(uri) + + # Check for required components + if not parsed.scheme or not parsed.netloc: + raise ValueError("Invalid redirect URI: missing scheme or netloc") + + # Require HTTPS in production + if parsed.scheme != 'https' and parsed.netloc not in ('localhost', '127.0.0.1'): + raise ValueError("HTTPS required for redirect URI in production") + + # No fragments + if parsed.fragment: + raise ValueError("Redirect URI must not contain fragment") + + return True +``` + +### Client Secrets Management + +1. **Secure Storage**: Store secrets in environment variables or secrets manager +2. **Hash Storage**: Secrets are hashed (bcrypt) in the database +3. **Rotation**: Support secret rotation without service interruption +4. **Scope**: Limit client permissions to minimum required scopes + +**Environment Variables:** + +```bash +# Don't commit secrets to version control +OIDC_CLIENT_ID=your-client-id +OIDC_CLIENT_SECRET=your-client-secret +``` + +### Additional Security Measures + +1. **HTTPS/TLS**: Always use HTTPS in production +2. **State Parameter**: Always validate state parameter to prevent CSRF +3. **Nonce Validation**: Validate nonce in ID token to prevent replay attacks +4. **Token Binding**: Consider token binding for high-security scenarios +5. **Audit Logging**: Enable comprehensive audit logging +6. **Rate Limiting**: Implement rate limiting for all endpoints + +--- + +## Deployment Checklist + +### Environment Variables + +```bash +# Required +DATABASE_URL=postgresql://user:pass@localhost:5432/authy2 +SECRET_KEY=your-secure-secret-key-min-32-chars +OIDC_ISSUER_URL=https://your-oidc-provider.com + +# Recommended +BCRYPT_LOG_ROUNDS=12 +LOG_LEVEL=INFO +REDIS_URL=redis://localhost:6379/0 + +# Optional +CORS_ORIGINS=https://yourapp.com +RATELIMIT_ENABLED=true +``` + +### Database Migrations + +1. **Run migrations before deployment:** + +```bash +python manage.py db upgrade +``` + +2. **Verify migration:** + +```bash +python manage.py db current +python manage.py db history +``` + +3. **Backup database before migration:** + +```bash +pg_dump -h localhost -U postgres authy2 > backup.sql +``` + +### SSL/TLS Requirements + +**Production Requirements:** + +1. **TLS 1.2+**: Use TLS 1.2 or higher +2. **Valid Certificate**: Use certificates from trusted CA +3. **HSTS Header**: Enable HTTP Strict Transport Security +4. **No Mixed Content**: Ensure all resources load over HTTPS + +**Example Nginx Configuration:** + +```nginx +server { + listen 443 ssl; + server_name oidc.example.com; + + ssl_certificate /etc/ssl/certs/oidc.crt; + ssl_certificate_key /etc/ssl/private/oidc.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + location / { + proxy_pass http://127.0.0.1:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +server { + listen 80; + server_name oidc.example.com; + return 301 https://$host$request_uri; +} +``` + +### Monitoring and Logging + +**Recommended Metrics:** + +1. **Token Issuance Rate**: Tokens per minute/hour +2. **Error Rate**: 4xx and 5xx response codes +3. **Token Validation Failures**: Invalid token attempts +4. **Authorization Code Usage**: Single-use validation +5. **Client Activity**: Active clients and usage patterns + +**Log Format:** + +```json +{ + "timestamp": "2024-01-01T00:00:00Z", + "level": "INFO", + "event_type": "token_issued", + "client_id": "oidc_...", + "user_id": "user-uuid", + "scope": "openid profile email", + "ip_address": "192.168.1.1", + "request_id": "req-uuid" +} +``` + +### Pre-Deployment Checklist + +- [ ] Database migrations applied +- [ ] SSL/TLS certificates installed +- [ ] Environment variables configured +- [ ] Logging configured and tested +- [ ] Monitoring/alerting set up +- [ ] Backup procedures tested +- [ ] Load balancing configured +- [ ] Rate limiting enabled +- [ ] CORS configured for allowed origins +- [ ] Security headers enabled +- [ ] Performance tested under load + +--- + +## Troubleshooting + +### Common Errors and Solutions + +#### Error: `invalid_client` + +**Cause:** Client authentication failed. + +**Solutions:** +1. Verify `client_id` and `client_secret` are correct +2. Check if client is active (not disabled) +3. Ensure client authentication method matches + +```bash +# Test client authentication +curl -X POST http://localhost:5000/oidc/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials" \ + -d "client_id=YOUR_CLIENT_ID" \ + -d "client_secret=YOUR_CLIENT_SECRET" +``` + +#### Error: `invalid_grant` + +**Cause:** Authorization code is invalid, expired, or already used. + +**Solutions:** +1. Authorization codes expire after 10 minutes +2. Each code can only be used once +3. Ensure `redirect_uri` matches original request + +```bash +# Check authorization code validity +# Codes expire quickly and are single-use +``` + +#### Error: `invalid_request` - `code_verifier required` + +**Cause:** PKCE required but `code_verifier` not provided. + +**Solutions:** +1. Generate code verifier and challenge +2. Include `code_verifier` in token request +3. Ensure `code_challenge_method` is `S256` + +#### Error: `invalid_request` - `Invalid redirect_uri` + +**Cause:** Redirect URI doesn't match registered URIs. + +**Solutions:** +1. Verify exact redirect URI matches +2. Check for trailing slashes or whitespace +3. Ensure HTTPS in production + +```python +# Debug redirect URI validation +client = OIDCClient.query.filter_by(client_id=client_id).first() +allowed_uris = client.redirect_uris +is_valid = client.is_redirect_uri_allowed(redirect_uri) +``` + +#### Error: `invalid_scope` + +**Cause:** Requested scope not allowed for client. + +**Solutions:** +1. Client must request only allowed scopes +2. Check client configuration for allowed scopes + +```python +# Verify allowed scopes +client = OIDCClient.query.filter_by(client_id=client_id).first() +allowed_scopes = client.scopes # ["openid", "profile", "email"] +``` + +### Debug Logging + +**Enable Debug Logging:** + +```bash +export LOG_LEVEL=DEBUG +``` + +**Example Log Output:** + +```json +{ + "timestamp": "2024-01-01T00:00:00Z", + "level": "DEBUG", + "event_type": "authorization_code_issued", + "message": "Authorization code generated", + "client_id": "oidc_abc123", + "user_id": "user-uuid", + "scope": ["openid", "profile", "email"], + "redirect_uri": "http://localhost:8080/callback", + "code_challenge_method": "S256", + "ip_address": "192.168.1.1", + "request_id": "req-uuid" +} +``` + +### Token Validation Issues + +#### Token Expired + +```json +{ + "data": { + "active": false + } +} +``` + +**Solution:** Use refresh token to get new access token. + +#### Invalid Signature + +**Cause:** Token signed with different key. + +**Solutions:** +1. Fetch latest JWKS +2. Verify key ID (kid) matches +3. Check key rotation + +```python +import jwt + +# Fetch JWKS +jwks = requests.get("http://localhost:5000/oidc/jwks").json() + +# Get signing key +for key in jwks["keys"]: + if key["kid"] == token_header["kid"]: + public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key) + break +``` + +#### Audience Mismatch + +**Cause:** Token audience doesn't match client ID. + +**Solution:** Ensure `aud` claim matches your `client_id`. + +### Database Issues + +#### Connection Failed + +```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()" +``` + +#### Migration Issues + +```bash +# Check migration status +python manage.py db current + +# Show migration history +python manage.py db history + +# Stamp to specific version +python manage.py db stamp 001 +``` + +### Performance Issues + +#### Slow Token Issuance + +1. Check database connection pooling +2. Verify Redis connection (if used) +3. Monitor database query performance +4. Check for N+1 queries in token generation + +#### High Memory Usage + +1. Monitor JWKS caching +2. Check token metadata cleanup +3. Verify audit log rotation + +### Getting Help + +1. **Check Logs**: Review application logs for detailed error messages +2. **Test Endpoints**: Use [`docs/OIDC_TESTING.md`](docs/OIDC_TESTING.md) for manual testing +3. **Verify Configuration**: Check [`config/base.py`](config/base.py) for configuration options +4. **Run Tests**: Execute test suite to verify functionality: + +```bash +pytest tests/integration/test_oidc_flow.py -v +``` + +--- + +## Related Documentation + +- [Architecture Documentation](docs/architecture.md) - Overall system architecture +- [OIDC Testing Guide](docs/OIDC_TESTING.md) - Manual testing procedures +- [OAuth2-Proxy Configuration](docs/oauth2-proxy-config.yaml) - Example oauth2-proxy config +- [API Response Format](docs/architecture.md#api-response-format) - Standard response envelope +- [Configuration Reference](config/base.py) - Complete configuration options + +--- + +## Version History + +| Version | Date | Changes | +|---------|------|---------| +| 1.0.0 | 2024-01-01 | Initial OIDC provider documentation | + diff --git a/docs/OIDC_TESTING.md b/docs/OIDC_TESTING.md new file mode 100644 index 0000000..3044578 --- /dev/null +++ b/docs/OIDC_TESTING.md @@ -0,0 +1,572 @@ +# OIDC Testing Guide + +This guide provides step-by-step instructions for manually testing the OIDC implementation using curl commands. + +## Prerequisites + +1. A running instance of the authy2 backend +2. curl installed +3. A test user account +4. A registered OIDC client + +## Setup + +### Start the Backend + +```bash +# Development mode +python -m flask run --host=0.0.0.0 --port=5000 + +# Or using the manage.py script +python manage.py runserver --host=0.0.0.0 --port=5000 +``` + +### Register a Test User (if needed) + +```bash +curl -X POST http://localhost:5000/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "email": "test@example.com", + "password": "TestPassword123!", + "password_confirm": "TestPassword123!", + "full_name": "Test User" + }' +``` + +### Register an OIDC Client + +```bash +# Register a new OIDC client +curl -X POST http://localhost:5000/oidc/register \ + -H "Content-Type: application/json" \ + -d '{ + "client_name": "Test Client", + "redirect_uris": ["http://localhost:8080/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "scope": "openid profile email", + "token_endpoint_auth_method": "client_secret_basic" + }' +``` + +**Save the `client_id` and `client_secret` from the response for later use.** + +## Testing Endpoints + +### 1. Discovery Endpoint + +**Purpose:** Verify OIDC discovery configuration is accessible and correct. + +```bash +curl -s http://localhost:5000/.well-known/openid-configuration | jq +``` + +**Expected Response:** +```json +{ + "issuer": "http://localhost:5000", + "authorization_endpoint": "http://localhost:5000/oidc/authorize", + "token_endpoint": "http://localhost:5000/oidc/token", + "userinfo_endpoint": "http://localhost:5000/oidc/userinfo", + "jwks_uri": "http://localhost:5000/oidc/jwks", + "registration_endpoint": "http://localhost:5000/oidc/register", + "revocation_endpoint": "http://localhost:5000/oidc/revoke", + "introspection_endpoint": "http://localhost:5000/oidc/introspect", + "scopes_supported": ["openid", "profile", "email"], + "response_types_supported": ["code"], + "response_modes_supported": ["query"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + "claims_supported": ["sub", "name", "email", "email_verified"] +} +``` + +**Verification:** +- All endpoints are present and use the correct base URL +- Cache-Control header is set: `curl -I http://localhost:5000/.well-known/openid-configuration` + +### 2. JWKS Endpoint + +**Purpose:** Verify JWKS is accessible and contains valid signing keys. + +```bash +curl -s http://localhost:5000/oidc/jwks | jq +``` + +**Expected Response:** +```json +{ + "keys": [ + { + "kty": "RSA", + "kid": "...", + "use": "sig", + "alg": "RS256", + "n": "...", + "e": "..." + } + ] +} +``` + +**Verification:** +- At least one key is present +- Key has `kty: "RSA"`, `alg: "RS256"` +- Cache-Control header is set + +### 3. Authorization Code Flow with PKCE + +This is the complete OAuth2/OIDC authentication flow. + +#### Step 1: Generate PKCE Parameters + +```bash +# Generate code verifier (43-128 characters) +CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=' | tr '/+' '_-' | cut -c1-43) + +# Generate code challenge from verifier +CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl sha256 -binary | base64 | tr -d '=' | tr '/+' '_-') + +# Generate state parameter +STATE=$(openssl rand -hex 16) + +# Generate nonce for ID token +NONCE=$(openssl rand -hex 16) + +echo "Code Verifier: $CODE_VERIFIER" +echo "Code Challenge: $CODE_CHALLENGE" +echo "State: $STATE" +echo "Nonce: $NONCE" +``` + +#### Step 2: Request Authorization Code + +**Option A: Browser-based flow (redirect flow)** +``` +# Open this URL in a browser +http://localhost:5000/oidc/authorize?\ + client_id=YOUR_CLIENT_ID&\ + redirect_uri=http://localhost:8080/callback&\ + response_type=code&\ + scope=openid%20profile%20email&\ + state=YOUR_STATE&\ + nonce=YOUR_NONCE&\ + code_challenge=YOUR_CODE_CHALLENGE&\ + code_challenge_method=S256 +``` + +**Option B: POST-based flow (for testing with curl)** +```bash +curl -v -X POST http://localhost:5000/oidc/authorize \ + -d "client_id=YOUR_CLIENT_ID" \ + -d "redirect_uri=http://localhost:8080/callback" \ + -d "response_type=code" \ + -d "scope=openid profile email" \ + -d "state=$STATE" \ + -d "nonce=$NONCE" \ + -d "code_challenge=$CODE_CHALLENGE" \ + -d "code_challenge_method=S256" \ + -d "email=test@example.com" \ + -d "password=TestPassword123!" +``` + +**Expected Response:** 302 Redirect with `code` parameter + +```http +HTTP/1.1 302 Found +Location: http://localhost:8080/callback?code=AUTHORIZATION_CODE&state=YOUR_STATE +``` + +**Extract the authorization code:** +```bash +# From the Location header +AUTH_CODE=$(curl -v -X POST http://localhost:5000/oidc/authorize \ + -d "client_id=YOUR_CLIENT_ID" \ + -d "redirect_uri=http://localhost:8080/callback" \ + -d "response_type=code" \ + -d "scope=openid profile email" \ + -d "state=$STATE" \ + -d "nonce=$NONCE" \ + -d "code_challenge=$CODE_CHALLENGE" \ + -d "code_challenge_method=S256" \ + -d "email=test@example.com" \ + -d "password=TestPassword123!" 2>&1 | grep -i "Location:" | cut -d' ' -f2 | cut -d'?' -f2 | cut -d'=' -f2) +``` + +#### Step 3: Exchange Authorization Code for Tokens + +```bash +# Using client_id and client_secret +curl -X POST http://localhost:5000/oidc/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=authorization_code" \ + -d "code=$AUTH_CODE" \ + -d "redirect_uri=http://localhost:8080/callback" \ + -d "client_id=YOUR_CLIENT_ID" \ + -d "client_secret=YOUR_CLIENT_SECRET" \ + -d "code_verifier=$CODE_VERIFIER" +``` + +**Expected Response:** +```json +{ + "version": "1.0", + "success": true, + "code": 200, + "message": "Tokens issued successfully", + "request_id": "...", + "data": { + "access_token": "eyJ...", + "token_type": "Bearer", + "expires_in": 3600, + "id_token": "eyJ...", + "refresh_token": "..." + } +} +``` + +**Verification:** +- `access_token` is a JWT (check at jwt.io) +- `token_type` is "Bearer" +- `expires_in` indicates token lifetime +- `id_token` contains expected claims (sub, iss, aud, etc.) + +### 4. UserInfo Endpoint + +**Purpose:** Retrieve user information using the access token. + +```bash +curl -X GET http://localhost:5000/oidc/userinfo \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +**Expected Response:** +```json +{ + "version": "1.0", + "success": true, + "code": 200, + "message": "User info retrieved successfully", + "request_id": "...", + "data": { + "sub": "user-id", + "name": "Test User", + "email": "test@example.com", + "email_verified": true + } +} +``` + +**Verification:** +- `sub` matches the user ID +- `email` and `email_verified` are present if email scope was requested +- `name` is present if profile scope was requested + +### 5. Token Refresh + +**Purpose:** Obtain a new access token using a refresh token. + +```bash +curl -X POST http://localhost:5000/oidc/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=refresh_token" \ + -d "refresh_token=YOUR_REFRESH_TOKEN" \ + -d "client_id=YOUR_CLIENT_ID" \ + -d "client_secret=YOUR_CLIENT_SECRET" +``` + +**Expected Response:** +```json +{ + "version": "1.0", + "success": true, + "code": 200, + "message": "Tokens refreshed successfully", + "request_id": "...", + "data": { + "access_token": "eyJ...", + "token_type": "Bearer", + "expires_in": 3600, + "id_token": "eyJ...", + "refresh_token": "..." + } +} +``` + +**Verification:** +- New `access_token` is returned +- New `refresh_token` is returned (token rotation) +- Old refresh token is now invalid + +### 6. Token Revocation + +**Purpose:** Revoke a token to invalidate it. + +```bash +# Revoke access token +curl -X POST http://localhost:5000/oidc/revoke \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "token=YOUR_ACCESS_TOKEN" \ + -d "token_type_hint=access_token" \ + -d "client_id=YOUR_CLIENT_ID" \ + -d "client_secret=YOUR_CLIENT_SECRET" + +# Revoke refresh token +curl -X POST http://localhost:5000/oidc/revoke \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "token=YOUR_REFRESH_TOKEN" \ + -d "token_type_hint=refresh_token" \ + -d "client_id=YOUR_CLIENT_ID" \ + -d "client_secret=YOUR_CLIENT_SECRET" +``` + +**Expected Response:** +```json +{ + "version": "1.0", + "success": true, + "code": 200, + "message": "Token revoked successfully", + "request_id": "..." +} +``` + +**Verification:** +- Revoked refresh token cannot be used for refresh +- Revoked access token cannot be used for UserInfo + +### 7. Token Introspection + +**Purpose:** Check if a token is active and get its claims. + +```bash +curl -X POST http://localhost:5000/oidc/introspect \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "token=YOUR_ACCESS_TOKEN" \ + -d "client_id=YOUR_CLIENT_ID" \ + -d "client_secret=YOUR_CLIENT_SECRET" +``` + +**Expected Response (active token):** +```json +{ + "version": "1.0", + "success": true, + "code": 200, + "message": "Token introspection successful", + "request_id": "...", + "data": { + "active": true, + "iss": "http://localhost:5000", + "sub": "user-id", + "aud": "YOUR_CLIENT_ID", + "exp": 1234567890, + "iat": 1234564290, + "scope": "openid profile email", + "token_type": "Bearer" + } +} +``` + +**Expected Response (invalid/expired token):** +```json +{ + "version": "1.0", + "success": true, + "code": 200, + "message": "Token introspection successful", + "request_id": "...", + "data": { + "active": false + } +} +``` + +## Complete Flow Test Script + +Here's a comprehensive script that tests the complete OIDC flow: + +```bash +#!/bin/bash +set -e + +BASE_URL="http://localhost:5000" +CLIENT_ID="YOUR_CLIENT_ID" +CLIENT_SECRET="YOUR_CLIENT_SECRET" +EMAIL="test@example.com" +PASSWORD="TestPassword123!" +REDIRECT_URI="http://localhost:8080/callback" + +echo "=== OIDC Complete Flow Test ===" + +# 1. Discovery +echo -e "\n1. Testing Discovery Endpoint..." +curl -s "$BASE_URL/.well-known/openid-configuration" | jq . > /dev/null +echo " āœ“ Discovery endpoint working" + +# 2. JWKS +echo -e "\n2. Testing JWKS Endpoint..." +curl -s "$BASE_URL/oidc/jwks" | jq . > /dev/null +echo " āœ“ JWKS endpoint working" + +# 3. Generate PKCE parameters +echo -e "\n3. Generating PKCE parameters..." +CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=' | tr '/+' '_-' | cut -c1-43) +CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl sha256 -binary | base64 | tr -d '=' | tr '/+' '_-') +STATE=$(openssl rand -hex 16) +echo " āœ“ PKCE parameters generated" + +# 4. Get Authorization Code +echo -e "\n4. Getting Authorization Code..." +AUTH_RESPONSE=$(curl -s -D - -X POST "$BASE_URL/oidc/authorize" \ + -d "client_id=$CLIENT_ID" \ + -d "redirect_uri=$REDIRECT_URI" \ + -d "response_type=code" \ + -d "scope=openid profile email" \ + -d "state=$STATE" \ + -d "code_challenge=$CODE_CHALLENGE" \ + -d "code_challenge_method=S256" \ + -d "email=$EMAIL" \ + -d "password=$PASSWORD") + +AUTH_CODE=$(echo "$AUTH_RESPONSE" | grep -i "Location:" | cut -d'?' -f2 | cut -d'=' -f2 | tr -d '\r') +echo " āœ“ Authorization code received: ${AUTH_CODE:0:20}..." + +# 5. Exchange Code for Tokens +echo -e "\n5. Exchanging Code for Tokens..." +TOKEN_RESPONSE=$(curl -s -X POST "$BASE_URL/oidc/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=authorization_code" \ + -d "code=$AUTH_CODE" \ + -d "redirect_uri=$REDIRECT_URI" \ + -d "client_id=$CLIENT_ID" \ + -d "client_secret=$CLIENT_SECRET" \ + -d "code_verifier=$CODE_VERIFIER") + +ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.data.access_token') +REFRESH_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.data.refresh_token') +echo " āœ“ Tokens received" + +# 6. UserInfo +echo -e "\n6. Testing UserInfo Endpoint..." +USERINFO=$(curl -s -X GET "$BASE_URL/oidc/userinfo" \ + -H "Authorization: Bearer $ACCESS_TOKEN") +echo " āœ“ UserInfo response: $(echo "$USERINFO" | jq -r '.data.sub')" + +# 7. Token Refresh +echo -e "\n7. Testing Token Refresh..." +REFRESH_RESPONSE=$(curl -s -X POST "$BASE_URL/oidc/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=refresh_token" \ + -d "refresh_token=$REFRESH_TOKEN" \ + -d "client_id=$CLIENT_ID" \ + -d "client_secret=$CLIENT_SECRET") + +NEW_ACCESS_TOKEN=$(echo "$REFRESH_RESPONSE" | jq -r '.data.access_token') +NEW_REFRESH_TOKEN=$(echo "$REFRESH_RESPONSE" | jq -r '.data.refresh_token') +echo " āœ“ Token refresh successful" + +# 8. Token Introspection +echo -e "\n8. Testing Token Introspection..." +INTROSPECT=$(curl -s -X POST "$BASE_URL/oidc/introspect" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "token=$NEW_ACCESS_TOKEN" \ + -d "client_id=$CLIENT_ID" \ + -d "client_secret=$CLIENT_SECRET") +IS_ACTIVE=$(echo "$INTROSPECT" | jq -r '.data.active') +echo " āœ“ Token introspection: active=$IS_ACTIVE" + +# 9. Token Revocation +echo -e "\n9. Testing Token Revocation..." +curl -s -X POST "$BASE_URL/oidc/revoke" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "token=$NEW_REFRESH_TOKEN" \ + -d "token_type_hint=refresh_token" \ + -d "client_id=$CLIENT_ID" \ + -d "client_secret=$CLIENT_SECRET" > /dev/null +echo " āœ“ Token revoked" + +# 10. Verify Revoked Token +echo -e "\n10. Verifying Revoked Token..." +REVOKE_VERIFY=$(curl -s -X POST "$BASE_URL/oidc/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=refresh_token" \ + -d "refresh_token=$NEW_REFRESH_TOKEN" \ + -d "client_id=$CLIENT_ID" \ + -d "client_secret=$CLIENT_SECRET") +IS_INVALID=$(echo "$REVOKE_VERIFY" | jq -r '.success') +echo " āœ“ Revoked token is invalid: success=$IS_INVALID" + +echo -e "\n=== OIDC Flow Test Complete ===" +echo "All endpoints tested successfully!" +``` + +## Error Handling Tests + +### Invalid Client + +```bash +curl -X POST http://localhost:5000/oidc/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=authorization_code" \ + -d "code=invalid" \ + -d "client_id=invalid_client" \ + -d "client_secret=invalid_secret" +``` + +### Invalid Authorization Code + +```bash +curl -X POST http://localhost:5000/oidc/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=authorization_code" \ + -d "code=INVALID_CODE" \ + -d "redirect_uri=http://localhost:8080/callback" \ + -d "client_id=YOUR_CLIENT_ID" +``` + +### Expired Authorization Code + +Authorization codes expire after 10 minutes. Wait 10+ minutes and try to use the code again. + +### Invalid PKCE Verifier + +Use an incorrect `code_verifier` during token exchange: +```bash +curl -X POST http://localhost:5000/oidc/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=authorization_code" \ + -d "code=YOUR_AUTH_CODE" \ + -d "redirect_uri=http://localhost:8080/callback" \ + -d "client_id=YOUR_CLIENT_ID" \ + -d "code_verifier=wrong_verifier" +``` + +## Troubleshooting + +### Connection Refused + +Ensure the backend is running: +```bash +ps aux | grep flask +lsof -i :5000 +``` + +### Authentication Failures + +1. Verify user credentials are correct +2. Check that the user exists in the database +3. Ensure the client is active and has correct redirect URIs + +### Token Errors + +1. Verify access token hasn't expired +2. Check that the token was signed by the OIDC provider +3. Ensure the audience (client_id) matches + +### Redirect URI Mismatch + +Ensure the `redirect_uri` used in authorization and token exchange exactly matches a registered redirect URI. diff --git a/docs/oauth2-proxy-config.yaml b/docs/oauth2-proxy-config.yaml new file mode 100644 index 0000000..243f9cb --- /dev/null +++ b/docs/oauth2-proxy-config.yaml @@ -0,0 +1,251 @@ +# OAuth2-Proxy Configuration Example +# ================================ +# This configuration file demonstrates how to configure oauth2-proxy +# to use this OIDC provider for authentication. +# +# oauth2-proxy project: https://oauth2-proxy.github.io/oauth2-proxy/ +# +# Usage: +# oauth2-proxy -config /path/to/oauth2-proxy-config.yaml +# +# Environment variables can also be used by prefixing with OAUTH2_PROXY_ +# e.g., OAUTH2_PROXY_PROVIDER="oidc" + +# Server Configuration +# -------------------- +# The address and port to bind to +http_address: "0.0.0.0:4180" +https_address: ":4443" + +# OIDC Provider Configuration +# --------------------------- +# Provider configuration - OIDC for our authy2 backend +provider: "oidc" + +# OIDC issuer URL - points to our OIDC discovery endpoint +# This should be the base URL of your authy2 backend +oidc_issuer_url: "http://localhost:5000" + +# Email domains to allow (empty means any email is allowed) +# email_domains: +# - "*" + +# Client Configuration +# -------------------- +# Client ID and secret obtained from OIDC Client Registration +# Run: curl -X POST http://localhost:5000/oidc/register -H "Content-Type: application/json" -d '{"client_name":"oauth2-proxy","redirect_uris":["http://localhost:4180/oauth2/callback"],"scope":"openid profile email"}' +client_id: "your-client-id-here" +client_secret: "your-client-secret-here" + +# Client ID file (alternative to providing secret directly) +# client_id_file: "/etc/oauth2-proxy/client_id" +# client_secret_file: "/etc/oauth2-proxy/client_secret" + +# OIDC Scopes +# ------------ +# Scopes to request from the OIDC provider +# The "openid" scope is always requested +# Available scopes in our OIDC provider: openid, profile, email +scope: "openid profile email" + +# Cookie Configuration +# -------------------- +# Secret key for cookie encryption (should be random and kept secret) +# Generate with: openssl rand -base64 32 | head -c 32 | xargs +cookie_secret: "your-random-cookie-secret-min-32-chars" + +# Name of the cookie that oauth2-proxy will use +cookie_name: "_oauth2_proxy" + +# Cookie options +cookie_expire: "168h" # 7 days +cookie_refresh: "1h" # Refresh cookie every hour +secure_cookies: false # Set to true in production with HTTPS +http_only_cookies: true + +# Upstream Configuration +# --------------------- +# The upstream application to proxy requests to +# Multiple upstreams can be configured +upstream: "http://127.0.0.1:8080/" + +# Internal upstream (not accessible from internet) +# internal_upstream: "http://127.0.0.1:8081/" + +# Response Configuration +# ---------------------- +# URL to redirect users to after successful authentication +# Can be overridden per-request with &rd parameter +redirect_url: "http://localhost:4180/oauth2/callback" + +# Sign-in URL (shown when not authenticated) +sign_in_url: "http://localhost:4180/sign_in" + +# Sign-out URL +sign_out_url: "http://localhost:4180/sign_out" + +# Proxy Configuration +# ------------------- +# List of paths to protect +# Requests to these paths will require authentication +proxy_root_controller: true + +# Skip JWT verification for specific routes (advanced) +# skip_auth_routes: +# - path: /public +# regex: false +# - path: /api/health +# regex: true + +# Headers Configuration +# --------------------- +# Headers to set for authenticated requests +# These headers are passed to the upstream application +set_authorization_header: true +set_x_auth_request_header: true + +# Pass headers from OIDC provider +# pass_access_token: true +# pass_id_token_header: true + +# Custom headers +# headers: +# X-Forwarded-User: "${email}" +# X-Forwarded-Groups: "${groups}" + +# Token Validation +# ---------------- +# Validate tokens against the OIDC provider +validate_session: true + +# Refresh expired tokens +# refresh_token: true + +# Logging Configuration +# --------------------- +# Log level: debug, info, warn, error +log_level: "info" + +# Log format: apache, json, nginx +log_format: "json" + +# Metrics Configuration +# --------------------- +# Enable metrics endpoint +metrics_address: "0.0.0.0:9090" + +# Request Logging +# --------------- +# Log requests to stdout +request_logging: true + +# Batch request logging +# batch_request_logging: false + +# Reverse Proxy Headers +# --------------------- +# Use X-Real-IP header from reverse proxy +real_ip_header: "X-Real-IP" + +# Trusted CIDRs (for determining client IP) +# trusted_cirs: +# - "10.0.0.0/8" +# - "172.16.0.0/12" +# - "192.168.0.0/16" + +# Rate Limiting +# ------------- +# Enable rate limiting +# enable_ratelimit: true +# ratelimit: +# type: "memory" +# requests_per_second: 10 + +# Advanced Options +# ---------------- +# Whitelist emails (users who can authenticate) +# whitelist_emails: +# - "admin@example.com" + +# Blacklist emails (users who cannot authenticate) +# blacklist_emails: +# - "banned@example.com" + +# Whitelist domains +# whitelist_domains: +# - "@example.com" + +# Skip OIDC discovery (use manual endpoints) +# skip_oidc_discovery: false +# login_url: "http://localhost:5000/oidc/authorize" +# redeem_url: "http://localhost:5000/oidc/token" +# profile_url: "http://localhost:5000/oidc/userinfo" +# validate_url: "http://localhost:5000/oidc/jwks" + +# TLS Configuration +# ----------------- +# Enable TLS (uncomment in production) +# tls: true +# tls_cert_file: "/etc/ssl/certs/oauth2-proxy.crt" +# tls_key_file: "/etc/ssl/private/oauth2-proxy.key" + +# Skip TLS verification (for testing only) +# tls_insecure_skip_verify: false + +# OIDC Extra Configuration +# ------------------------ +# Extra parameters to pass to authorization request +# authorise_params: +# acr_values: "urn:goauthentik.io:authentication:factor" +# max_age: "3600" + +# Ping path for health checks +# ping_path: "/ping" + +# Example Usage Scenarios +# ======================= + +# Scenario 1: Basic Setup with Local OIDC Provider +# ------------------------------------------------ +# Use this configuration when running oauth2-proxy locally +# pointing to the authy2 backend running on localhost:5000 + +# Scenario 2: Production Setup with HTTPS +# --------------------------------------- +# For production, use HTTPS for all connections +# Set secure_cookies: true +# Configure TLS certificates +# Point to your production OIDC issuer URL + +# Scenario 3: Docker Compose Setup +# -------------------------------- +# Example docker-compose.yml for oauth2-proxy: +# +# version: '3' +# services: +# oauth2-proxy: +# image: oauth2-proxy/oauth2-proxy:latest +# ports: +# - "4180:4180" +# volumes: +# - ./oauth2-proxy-config.yaml:/etc/oauth2-proxy/config.yaml +# environment: +# - OAUTH2_PROXY_PROVIDER=oidc +# - OAUTH2_PROXY_OIDC_ISSUER_URL=http://authy2:5000 +# - OAUTH2_PROXY_CLIENT_ID=${OIDC_CLIENT_ID} +# - OAUTH2_PROXY_CLIENT_SECRET=${OIDC_CLIENT_SECRET} +# - OAUTH2_PROXY_COOKIE_SECRET=${COOKIE_SECRET} +# depends_on: +# - authy2 + +# Scenario 4: Kubernetes Ingress with oauth2-proxy +# ------------------------------------------------- +# Example annotation for Kubernetes Ingress: +# +# nginx.ingress.kubernetes.io/auth-url: https://$host/oauth2/auth +# nginx.ingress.kubernetes.io/auth-signin: https://$host/oauth2/sign_in +# nginx.ingress.kubernetes.io/configuration-snippet: | +# auth_request_set $user $upstream_http_x_auth_request_user; +# auth_request_set $email $upstream_http_x_auth_request_email; +# proxy_set_header X-User $user; +# proxy_set_header X-Email $email; diff --git a/manage.py b/manage.py index a9258fb..4517285 100644 --- a/manage.py +++ b/manage.py @@ -1,11 +1,12 @@ """Management script for Flask application.""" import os -from flask.cli import FlaskGroup -from app import create_app from dotenv import load_dotenv -# Load environment variables -load_dotenv() +# Load environment variables FIRST, before any app imports +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 # Create application app = create_app(os.getenv("FLASK_ENV", "development")) diff --git a/migrations/001_create_oidc_tables.py b/migrations/001_create_oidc_tables.py new file mode 100644 index 0000000..9c6fba3 --- /dev/null +++ b/migrations/001_create_oidc_tables.py @@ -0,0 +1,150 @@ +"""Database migration: Create OIDC tables. + +Revision ID: 001 +Revises: +Create Date: 2024-01-01 00:00:00 + +This migration creates all OIDC-related tables for the authorization code flow, +refresh token management, OIDC session tracking, token metadata, and audit logging. +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# Revision identifiers +revision = '001' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + """Create OIDC tables.""" + + # OIDC Authorization Codes table + op.create_table( + 'oidc_authorization_codes', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('created_at', sa.DateTime, nullable=False), + sa.Column('updated_at', sa.DateTime, nullable=False), + sa.Column('deleted_at', sa.DateTime, nullable=True), + sa.Column('client_id', sa.String(255), sa.ForeignKey('oidc_clients.id'), nullable=False), + sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id'), nullable=False), + sa.Column('code_hash', sa.String(255), nullable=False), + sa.Column('redirect_uri', sa.String(512), nullable=False), + sa.Column('scope', postgresql.JSON, nullable=True), + sa.Column('nonce', sa.String(255), nullable=True), + sa.Column('code_verifier', sa.String(255), nullable=True), + sa.Column('expires_at', sa.DateTime, nullable=False), + sa.Column('used_at', sa.DateTime, nullable=True), + sa.Column('is_used', sa.Boolean, default=False, nullable=False), + sa.Column('ip_address', sa.String(45), nullable=True), + sa.Column('user_agent', sa.Text, nullable=True), + ) + op.create_index('ix_oidc_authorization_codes_client_id', 'oidc_authorization_codes', ['client_id']) + op.create_index('ix_oidc_authorization_codes_user_id', 'oidc_authorization_codes', ['user_id']) + op.create_index('ix_oidc_authorization_codes_expires_at', 'oidc_authorization_codes', ['expires_at']) + + # OIDC Refresh Tokens table + op.create_table( + 'oidc_refresh_tokens', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('created_at', sa.DateTime, nullable=False), + sa.Column('updated_at', sa.DateTime, nullable=False), + sa.Column('deleted_at', sa.DateTime, nullable=True), + sa.Column('client_id', sa.String(255), sa.ForeignKey('oidc_clients.id'), nullable=False), + sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id'), nullable=False), + sa.Column('token_hash', sa.String(255), nullable=False), + sa.Column('access_token_id', sa.String(36), sa.ForeignKey('sessions.id'), nullable=True), + sa.Column('scope', postgresql.JSON, nullable=True), + sa.Column('expires_at', sa.DateTime, nullable=False), + sa.Column('revoked_at', sa.DateTime, nullable=True), + sa.Column('revoked_reason', sa.String(255), nullable=True), + sa.Column('previous_token_hash', sa.String(255), nullable=True), + sa.Column('rotation_count', sa.Integer, default=0, nullable=False), + sa.Column('ip_address', sa.String(45), nullable=True), + sa.Column('user_agent', sa.Text, nullable=True), + ) + op.create_index('ix_oidc_refresh_tokens_client_id', 'oidc_refresh_tokens', ['client_id']) + op.create_index('ix_oidc_refresh_tokens_user_id', 'oidc_refresh_tokens', ['user_id']) + op.create_index('ix_oidc_refresh_tokens_token_hash', 'oidc_refresh_tokens', ['token_hash'], unique=True) + op.create_index('ix_oidc_refresh_tokens_access_token_id', 'oidc_refresh_tokens', ['access_token_id']) + op.create_index('ix_oidc_refresh_tokens_expires_at', 'oidc_refresh_tokens', ['expires_at']) + + # OIDC Sessions table + op.create_table( + 'oidc_sessions', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('created_at', sa.DateTime, nullable=False), + sa.Column('updated_at', sa.DateTime, nullable=False), + sa.Column('deleted_at', sa.DateTime, nullable=True), + sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id'), nullable=False), + sa.Column('client_id', sa.String(255), sa.ForeignKey('oidc_clients.id'), nullable=False), + sa.Column('state', sa.String(255), nullable=False), + sa.Column('nonce', sa.String(255), nullable=True), + sa.Column('redirect_uri', sa.String(512), nullable=False), + sa.Column('scope', postgresql.JSON, nullable=True), + sa.Column('code_challenge', sa.String(255), nullable=True), + sa.Column('code_challenge_method', sa.String(10), nullable=True), + sa.Column('expires_at', sa.DateTime, nullable=False), + sa.Column('authenticated_at', sa.DateTime, nullable=True), + ) + op.create_index('ix_oidc_sessions_user_id', 'oidc_sessions', ['user_id']) + op.create_index('ix_oidc_sessions_client_id', 'oidc_sessions', ['client_id']) + op.create_index('ix_oidc_sessions_state', 'oidc_sessions', ['state']) + op.create_index('ix_oidc_sessions_expires_at', 'oidc_sessions', ['expires_at']) + + # OIDC Token Metadata table + op.create_table( + 'oidc_token_metadata', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('created_at', sa.DateTime, nullable=False), + sa.Column('updated_at', sa.DateTime, nullable=False), + sa.Column('deleted_at', sa.DateTime, nullable=True), + sa.Column('client_id', sa.String(255), sa.ForeignKey('oidc_clients.id'), nullable=False), + sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id'), nullable=False), + sa.Column('token_type', sa.String(50), nullable=False), + sa.Column('token_jti', sa.String(255), nullable=False), + sa.Column('expires_at', sa.DateTime, nullable=False), + sa.Column('revoked_at', sa.DateTime, nullable=True), + sa.Column('revoked_reason', sa.String(255), nullable=True), + ) + op.create_index('ix_oidc_token_metadata_client_id', 'oidc_token_metadata', ['client_id']) + op.create_index('ix_oidc_token_metadata_user_id', 'oidc_token_metadata', ['user_id']) + op.create_index('ix_oidc_token_metadata_token_jti', 'oidc_token_metadata', ['token_jti']) + op.create_index('ix_oidc_token_metadata_expires_at', 'oidc_token_metadata', ['expires_at']) + + # OIDC Audit Logs table + op.create_table( + 'oidc_audit_logs', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('created_at', sa.DateTime, nullable=False), + sa.Column('updated_at', sa.DateTime, nullable=False), + sa.Column('deleted_at', sa.DateTime, nullable=True), + sa.Column('event_type', sa.String(100), nullable=False), + sa.Column('client_id', sa.String(255), sa.ForeignKey('oidc_clients.id'), nullable=True), + sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id'), nullable=True), + sa.Column('success', sa.Boolean, default=True, nullable=False), + sa.Column('error_code', sa.String(100), nullable=True), + sa.Column('error_description', sa.Text, nullable=True), + sa.Column('ip_address', sa.String(45), nullable=True), + sa.Column('user_agent', sa.Text, nullable=True), + sa.Column('request_id', sa.String(36), nullable=True), + sa.Column('event_metadata', postgresql.JSON, nullable=True), + ) + op.create_index('ix_oidc_audit_logs_event_type', 'oidc_audit_logs', ['event_type']) + op.create_index('ix_oidc_audit_logs_client_id', 'oidc_audit_logs', ['client_id']) + op.create_index('ix_oidc_audit_logs_user_id', 'oidc_audit_logs', ['user_id']) + op.create_index('ix_oidc_audit_logs_success', 'oidc_audit_logs', ['success']) + op.create_index('ix_oidc_audit_logs_ip_address', 'oidc_audit_logs', ['ip_address']) + op.create_index('ix_oidc_audit_logs_request_id', 'oidc_audit_logs', ['request_id']) + + +def downgrade(): + """Drop OIDC tables.""" + op.drop_table('oidc_audit_logs') + op.drop_table('oidc_token_metadata') + op.drop_table('oidc_sessions') + op.drop_table('oidc_refresh_tokens') + op.drop_table('oidc_authorization_codes') diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..28ebcef --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,74 @@ +"""Flask-Migrate environment configuration.""" +import os +import sys + +# Add the parent directory to the path so we can import the app +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Load environment variables +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 + +# Get the app +app = create_app(os.getenv("FLASK_ENV", "development")) + +# Set the Flask application context +with app.app_context(): + from alembic import context + + # this is the Alembic Config object, which provides access + # to the values within the .ini file in use. + config = context.config + + # Set the SQLAlchemy URL from the app config + config.set_main_option('sqlalchemy.url', app.config.get('SQLALCHEMY_DATABASE_URI')) + + # Set the target metadata + target_metadata = db.metadata + + def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here too. By skipping the Engine creation + we don't even need a DBAPI to be available. + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + + def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + """ + connection = db.engine.connect() + context.configure( + connection=connection, + target_metadata=target_metadata + ) + + try: + with context.begin_transaction(): + context.run_migrations() + finally: + connection.close() + + if context.is_offline_mode(): + run_migrations_offline() + else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..d8fc3e1 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/requirements/base.txt b/requirements/base.txt index 7d8559b..ffbaa0f 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -17,6 +17,10 @@ marshmallow-sqlalchemy==0.29.0 bcrypt==4.1.2 Flask-Bcrypt==1.0.1 +# JWT / OIDC +PyJWT==2.8.0 +cryptography==41.0.7 + # CORS Flask-CORS==4.0.0 @@ -37,4 +41,4 @@ Flask-Session==0.5.0 Flask-Limiter==3.5.0 # Logging -python-json-logger==2.0.7 +python-json-logger==2.0.7 \ No newline at end of file diff --git a/scripts/seed_data.py b/scripts/seed_data.py index 17a80da..4bd9aea 100644 --- a/scripts/seed_data.py +++ b/scripts/seed_data.py @@ -7,6 +7,12 @@ This script creates: - Proper organization memberships with different roles """ import sys +import secrets +import hashlib +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 @@ -14,13 +20,10 @@ 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 dotenv import load_dotenv - -# Load environment variables -load_dotenv() # Create application app = create_app() @@ -115,6 +118,38 @@ def add_org_member(org, user_id, role, inviter_id): raise e +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.""" + existing = OIDCClient.query.filter_by(client_id=client_id, deleted_at=None).first() + if existing: + print(f" → OIDC Client {name} already exists, skipping") + return existing + + try: + # Hash the client secret + client_secret_hash = hashlib.sha256(client_secret.encode()).hexdigest() + + client = OIDCClient( + organization_id=org_id, + name=name, + client_id=client_id, + client_secret_hash=client_secret_hash, + redirect_uris=redirect_uris, + grant_types=grant_types, + response_types=response_types, + scopes=scopes, + **kwargs + ) + client.save() + print(f" → Created OIDC client: {name}") + return client + except Exception as e: + print(f" → Error creating OIDC client {name}: {e}") + raise e + + def seed_data(): """Seed the database with test data.""" print("=" * 60) @@ -387,6 +422,113 @@ def seed_data(): if sarah and tech_org: add_org_member(tech_org, charlie.id, OrganizationRole.MEMBER, sarah.id) + # ========================================================================= + # Step 5: Create OIDC Clients + # ========================================================================= + print("\n[Step 5] Creating OIDC Clients...") + oidc_clients = {} + + # OIDC Client for Acme Corp - Internal Portal + if acme_org: + print("\n Acme Corporation OIDC Clients:") + acme_portal_client = create_or_get_oidc_client( + org_id=acme_org.id, + name="Acme Internal Portal", + client_id="acme-portal-001", + client_secret="acme_secret_portal_2024", + redirect_uris=[ + "https://portal.acme-corp.com/auth/callback", + "http://localhost:3000/auth/callback", + ], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scopes=["openid", "profile", "email", "offline_access"], + is_active=True, + is_confidential=True, + require_pkce=True, + access_token_lifetime=3600, # 1 hour + refresh_token_lifetime=2592000, # 30 days + id_token_lifetime=3600, # 1 hour + logo_uri="https://portal.acme-corp.com/logo.png", + client_uri="https://portal.acme-corp.com", + ) + oidc_clients["acme-portal"] = acme_portal_client + + # OIDC Client for Acme Corp - Mobile App + acme_mobile_client = create_or_get_oidc_client( + org_id=acme_org.id, + name="Acme Mobile App", + client_id="acme-mobile-001", + client_secret="acme_secret_mobile_2024", + redirect_uris=[ + "com.acmecorp.app://oauth/callback", + "http://localhost:8080/callback", + ], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scopes=["openid", "profile", "email", "offline_access"], + is_active=True, + is_confidential=False, # Public client (mobile) + require_pkce=True, + access_token_lifetime=1800, # 30 minutes + refresh_token_lifetime=604800, # 7 days + id_token_lifetime=1800, # 30 minutes + ) + oidc_clients["acme-mobile"] = acme_mobile_client + + # OIDC Client for Tech Startup + if tech_org: + print("\n Tech Startup OIDC Clients:") + tech_app_client = create_or_get_oidc_client( + org_id=tech_org.id, + name="Tech Startup Dashboard", + client_id="tech-dashboard-001", + client_secret="tech_secret_dashboard_2024", + redirect_uris=[ + "https://dashboard.tech-startup.com/auth/callback", + "http://localhost:4200/auth/callback", + ], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scopes=["openid", "profile", "email", "offline_access"], + is_active=True, + is_confidential=True, + require_pkce=True, + access_token_lifetime=3600, # 1 hour + refresh_token_lifetime=2592000, # 30 days + id_token_lifetime=3600, # 1 hour + logo_uri="https://tech-startup.com/logo.png", + client_uri="https://tech-startup.com", + ) + oidc_clients["tech-dashboard"] = tech_app_client + + # OIDC Client for Data Systems + if data_org: + print("\n Data Systems OIDC Clients:") + data_api_client = create_or_get_oidc_client( + org_id=data_org.id, + name="Data Systems API Client", + client_id="data-api-001", + client_secret="data_secret_api_2024", + redirect_uris=[ + "https://api.data-systems.com/oauth/callback", + "http://localhost:5000/oauth/callback", + ], + grant_types=["authorization_code", "refresh_token", "client_credentials"], + response_types=["code"], + scopes=["openid", "profile", "email", "api:read", "api:write"], + is_active=True, + is_confidential=True, + require_pkce=False, # Server-to-server client + access_token_lifetime=7200, # 2 hours + refresh_token_lifetime=2592000, # 30 days + id_token_lifetime=3600, # 1 hour + client_uri="https://data-systems.com", + ) + oidc_clients["data-api"] = data_api_client + + print(f"\n Created {len(oidc_clients)} OIDC clients") + # ========================================================================= # Summary # ========================================================================= @@ -398,6 +540,7 @@ def seed_data(): print(f" Organizations: {len(org_objects)}") print(f" Admin Users: {len(admin_objects)}") print(f" Regular Users: {len(all_users)}") + print(f" OIDC Clients: {len(oidc_clients)}") print("\nšŸ” Test Credentials:") print("\n Admin Accounts:") @@ -421,6 +564,30 @@ def seed_data(): print(f" {org.name} (slug: {slug})") print(f" Members: {member_count}, Owner: {owner_email}") + print("\nšŸ” OIDC Clients:") + for key, client in oidc_clients.items(): + print(f" {client.name}") + print(f" Client ID: {client.client_id}") + print(f" Organization: {client.organization.name}") + print(f" Grant Types: {', '.join(client.grant_types)}") + print(f" Scopes: {', '.join(client.scopes)}") + print(f" Redirect URIs: {len(client.redirect_uris)} configured") + + if oidc_clients: + print("\n šŸ“ OIDC Client Credentials (for testing):") + print(" Acme Portal:") + print(" client_id: acme-portal-001") + print(" client_secret: acme_secret_portal_2024") + print(" Acme Mobile:") + print(" client_id: acme-mobile-001") + print(" client_secret: acme_secret_mobile_2024") + print(" Tech Dashboard:") + print(" client_id: tech-dashboard-001") + print(" client_secret: tech_secret_dashboard_2024") + print(" Data API:") + print(" client_id: data-api-001") + print(" client_secret: data_secret_api_2024") + print("\n" + "=" * 60) diff --git a/tests/integration/test_oidc_flow.py b/tests/integration/test_oidc_flow.py new file mode 100644 index 0000000..f39e54f --- /dev/null +++ b/tests/integration/test_oidc_flow.py @@ -0,0 +1,1028 @@ +"""Integration tests for OIDC flow. + +This module tests the complete OIDC authorization code flow with PKCE, +including discovery, authorization, token exchange, userinfo, refresh, +and revocation endpoints. +""" +import hashlib +import base64 +import json +import secrets +import time +import pytest + + +@pytest.mark.integration +class TestOIDCDiscovery: + """Tests for OIDC Discovery endpoint.""" + + def test_discovery_returns_valid_json(self, client): + """Test that discovery endpoint returns valid JSON configuration.""" + response = client.get("/.well-known/openid-configuration") + + assert response.status_code == 200 + data = response.get_json() + + # Check required OIDC discovery fields + assert "issuer" in data + assert "authorization_endpoint" in data + assert "token_endpoint" in data + assert "userinfo_endpoint" in data + assert "jwks_uri" in data + assert "registration_endpoint" in data + assert "revocation_endpoint" in data + assert "introspection_endpoint" in data + + def test_discovery_cache_header(self, client): + """Test that discovery endpoint sets cache header.""" + response = client.get("/.well-known/openid-configuration") + + assert response.status_code == 200 + cache_header = response.headers.get("Cache-Control", "") + assert "max-age" in cache_header + + def test_discovery_scopes_supported(self, client): + """Test that discovery returns supported scopes.""" + response = client.get("/.well-known/openid-configuration") + + data = response.get_json() + assert "scopes_supported" in data + assert "openid" in data["scopes_supported"] + assert "profile" in data["scopes_supported"] + assert "email" in data["scopes_supported"] + + def test_discovery_response_types(self, client): + """Test that discovery returns supported response types.""" + response = client.get("/.well-known/openid-configuration") + + data = response.get_json() + assert "response_types_supported" in data + assert "code" in data["response_types_supported"] + + def test_discovery_algorithms(self, client): + """Test that discovery returns supported algorithms.""" + response = client.get("/.well-known/openid-configuration") + + data = response.get_json() + assert "id_token_signing_alg_values_supported" in data + assert "RS256" in data["id_token_signing_alg_values_supported"] + + +@pytest.mark.integration +class TestOIDCJWKS: + """Tests for OIDC JWKS endpoint.""" + + def test_jwks_returns_valid_jwks(self, client): + """Test that JWKS endpoint returns valid JWKS document.""" + response = client.get("/oidc/jwks") + + assert response.status_code == 200 + data = response.get_json() + + assert "keys" in data + assert isinstance(data["keys"], list) + assert len(data["keys"]) > 0 + + # Check key structure + key = data["keys"][0] + assert "kty" in key + assert "kid" in key + assert "alg" in key + assert key["kty"] == "RSA" + + def test_jwks_cache_header(self, client): + """Test that JWKS endpoint sets cache header.""" + response = client.get("/oidc/jwks") + + assert response.status_code == 200 + cache_header = response.headers.get("Cache-Control", "") + assert "max-age" in cache_header + + 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 + + with app.app_context(): + # Initialize with a key + jwks_service = OIDCJWKSService() + jwks_service.initialize_with_key() + + response = client.get("/oidc/jwks") + data = response.get_json() + + assert len(data["keys"]) > 0 + key = data["keys"][0] + assert "n" in key + assert "e" in key + + +@pytest.mark.integration +class TestOIDCClientRegistration: + """Tests for OIDC Client Registration endpoint.""" + + def test_register_client_success(self, client, test_organization): + """Test successful client registration.""" + registration_data = { + "client_name": "Test OAuth2 Client", + "redirect_uris": ["https://example.com/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "scope": "openid profile email", + "token_endpoint_auth_method": "client_secret_basic", + } + + response = client.post( + "/oidc/register", + data=json.dumps(registration_data), + content_type="application/json", + ) + + assert response.status_code == 201 + data = response.get_json() + assert data["success"] is True + assert "client_id" in data["data"] + assert "client_secret" in data["data"] + assert data["data"]["client_name"] == "Test OAuth2 Client" + + def test_register_client_missing_name(self, client): + """Test client registration fails without client_name.""" + registration_data = { + "redirect_uris": ["https://example.com/callback"], + } + + response = client.post( + "/oidc/register", + data=json.dumps(registration_data), + content_type="application/json", + ) + + assert response.status_code == 400 + data = response.get_json() + assert data["success"] is False + + def test_register_client_missing_redirect_uris(self, client): + """Test client registration fails without redirect_uris.""" + registration_data = { + "client_name": "Test Client", + } + + response = client.post( + "/oidc/register", + data=json.dumps(registration_data), + content_type="application/json", + ) + + assert response.status_code == 400 + data = response.get_json() + assert data["success"] is False + + def test_register_client_invalid_redirect_uri(self, client): + """Test client registration fails with invalid redirect URI.""" + registration_data = { + "client_name": "Test Client", + "redirect_uris": ["not-a-valid-uri"], + } + + response = client.post( + "/oidc/register", + data=json.dumps(registration_data), + content_type="application/json", + ) + + assert response.status_code == 400 + data = response.get_json() + assert data["success"] is False + + +@pytest.mark.integration +class TestOIDCAuthorizationCodeFlow: + """Tests for OIDC Authorization Code Flow with PKCE.""" + + @pytest.fixture + def test_client(self, client, test_organization, test_user): + """Create a test OIDC client.""" + from app.models import OIDCClient + + client_data = OIDCClient( + organization_id=test_organization.id, + name="Test PKCE Client", + client_id="test_pkce_client", + client_secret_hash="dummy_hash", + redirect_uris=["https://example.com/callback"], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scopes=["openid", "profile", "email"], + token_endpoint_auth_method="client_secret_basic", + is_active=True, + is_confidential=True, + require_pkce=True, + ) + from app.extensions import db + db.session.add(client_data) + db.session.commit() + + return client_data + + def _generate_pkce_pair(self): + """Generate PKCE code verifier and challenge. + + Returns: + Tuple of (code_verifier, code_challenge) + """ + code_verifier = secrets.token_urlsafe(32) + + # Generate S256 code challenge + digest = hashlib.sha256(code_verifier.encode()).digest() + code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=") + + return code_verifier, code_challenge + + def test_authorization_endpoint_missing_params(self, client, test_client): + """Test authorization fails with missing required parameters.""" + response = client.get("/oidc/authorize") + + assert response.status_code == 400 + + def test_authorization_endpoint_invalid_client(self, client): + """Test authorization fails with invalid client_id.""" + response = client.get( + "/oidc/authorize", + query_string={ + "client_id": "nonexistent_client", + "redirect_uri": "https://example.com/callback", + "response_type": "code", + } + ) + + assert response.status_code == 302 # Redirect with error + + def test_authorization_endpoint_invalid_redirect_uri(self, client, test_client): + """Test authorization fails with invalid redirect_uri.""" + response = client.get( + "/oidc/authorize", + query_string={ + "client_id": test_client.client_id, + "redirect_uri": "https://malicious.com/callback", + "response_type": "code", + } + ) + + assert response.status_code == 302 # Redirect with error + + def test_authorization_endpoint_unsupported_response_type(self, client, test_client): + """Test authorization fails with unsupported response_type.""" + response = client.get( + "/oidc/authorize", + query_string={ + "client_id": test_client.client_id, + "redirect_uri": "https://example.com/callback", + "response_type": "token", # Not supported + } + ) + + assert response.status_code == 302 # Redirect with error + + def test_authorization_endpoint_invalid_scope(self, client, test_client): + """Test authorization fails with invalid scope.""" + response = client.get( + "/oidc/authorize", + query_string={ + "client_id": test_client.client_id, + "redirect_uri": "https://example.com/callback", + "response_type": "code", + "scope": "invalid_scope", + } + ) + + assert response.status_code == 302 # Redirect with error + + def test_authorization_code_flow_with_pkce(self, client, app, test_client, test_user): + """Test complete authorization code flow with PKCE.""" + # Step 1: Generate PKCE parameters + code_verifier, code_challenge = self._generate_pkce_pair() + state = secrets.token_urlsafe(16) + nonce = secrets.token_urlsafe(16) + + # Step 2: Request authorization code via POST with credentials + response = client.post( + "/oidc/authorize", + data={ + "client_id": test_client.client_id, + "redirect_uri": "https://example.com/callback", + "response_type": "code", + "scope": "openid profile email", + "state": state, + "nonce": nonce, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "email": test_user.email, + "password": test_user._test_password, + } + ) + + assert response.status_code == 302 + + # Parse redirect URL to get authorization code + redirect_location = response.headers.get("Location", "") + assert "code=" in redirect_location + + # Extract code from redirect + from urllib.parse import parse_qs, urlparse + parsed = urlparse(redirect_location) + params = parse_qs(parsed.query) + auth_code = params.get("code", [None])[0] + returned_state = params.get("state", [None])[0] + + assert auth_code is not None + assert returned_state == state + + 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 + + # First, generate an authorization code + with app.app_context(): + code = OIDCService.generate_authorization_code( + client_id=test_client.client_id, + user_id=test_user.id, + redirect_uri="https://example.com/callback", + scope=["openid", "profile", "email"], + state="test_state", + nonce="test_nonce", + code_challenge=None, + code_challenge_method=None, + ) + + # Get the code hash for lookup + code_hash = hashlib.sha256(code.encode()).hexdigest() + + # Step 2: Exchange code for tokens + response = client.post( + "/oidc/token", + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "https://example.com/callback", + "client_id": test_client.client_id, + "client_secret": "", # Not needed for this test + }, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + + # Check token response + tokens = data["data"] + assert "access_token" in tokens + assert "token_type" in tokens + assert tokens["token_type"] == "Bearer" + assert "id_token" in tokens + assert "refresh_token" in tokens + assert "expires_in" in tokens + + def test_token_exchange_invalid_code(self, client, test_client): + """Test token exchange fails with invalid authorization code.""" + response = client.post( + "/oidc/token", + data={ + "grant_type": "authorization_code", + "code": "invalid_code", + "redirect_uri": "https://example.com/callback", + "client_id": test_client.client_id, + }, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 400 + data = response.get_json() + assert data["success"] is False + + def test_token_exchange_missing_code(self, client, test_client): + """Test token exchange fails without authorization code.""" + response = client.post( + "/oidc/token", + data={ + "grant_type": "authorization_code", + "redirect_uri": "https://example.com/callback", + "client_id": test_client.client_id, + }, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 400 + data = response.get_json() + assert data["success"] is False + + 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 + + # Generate PKCE pair + code_verifier, code_challenge = self._generate_pkce_pair() + + # Generate authorization code with PKCE + with app.app_context(): + code = OIDCService.generate_authorization_code( + client_id=test_client.client_id, + user_id=test_user.id, + redirect_uri="https://example.com/callback", + scope=["openid", "profile", "email"], + state="test_state", + nonce="test_nonce", + code_challenge=code_challenge, + code_challenge_method="S256", + ) + + # Token exchange without code_verifier should fail + response = client.post( + "/oidc/token", + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "https://example.com/callback", + "client_id": test_client.client_id, + # Missing code_verifier + }, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 400 + data = response.get_json() + assert data["success"] is False + + 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 + + # Generate PKCE pair + code_verifier, code_challenge = self._generate_pkce_pair() + + # Generate authorization code with PKCE + with app.app_context(): + code = OIDCService.generate_authorization_code( + client_id=test_client.client_id, + user_id=test_user.id, + redirect_uri="https://example.com/callback", + scope=["openid", "profile", "email"], + state="test_state", + nonce="test_nonce", + code_challenge=code_challenge, + code_challenge_method="S256", + ) + + # Token exchange with correct code_verifier + response = client.post( + "/oidc/token", + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "https://example.com/callback", + "client_id": test_client.client_id, + "code_verifier": code_verifier, + }, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + + +@pytest.mark.integration +class TestOIDCUserInfo: + """Tests for OIDC UserInfo endpoint.""" + + @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 + + client_data = OIDCClient( + organization_id=test_organization.id, + name="Test UserInfo Client", + client_id="test_userinfo_client", + client_secret_hash="dummy_hash", + redirect_uris=["https://example.com/callback"], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scopes=["openid", "profile", "email"], + token_endpoint_auth_method="client_secret_basic", + is_active=True, + is_confidential=False, + require_pkce=False, + ) + from app.extensions import db + db.session.add(client_data) + db.session.commit() + + # Generate tokens directly + with client.application.app_context(): + tokens = OIDCService.generate_tokens( + client_id=client_data.client_id, + user_id=test_user.id, + scope=["openid", "profile", "email"], + nonce="test_nonce", + ) + + return client_data, tokens["access_token"], test_user + + def test_userinfo_without_token(self, client): + """Test UserInfo endpoint returns 401 without token.""" + response = client.get("/oidc/userinfo") + + assert response.status_code == 401 + data = response.get_json() + assert data["success"] is False + + def test_userinfo_with_invalid_token(self, client): + """Test UserInfo endpoint returns 401 with invalid token.""" + response = client.get( + "/oidc/userinfo", + headers={"Authorization": "Bearer invalid_token"} + ) + + assert response.status_code == 401 + + def test_userinfo_with_valid_token(self, client, test_client_with_user): + """Test UserInfo endpoint returns claims with valid token.""" + _, access_token, test_user = test_client_with_user + + response = client.get( + "/oidc/userinfo", + headers={"Authorization": f"Bearer {access_token}"} + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + + userinfo = data["data"] + assert "sub" in userinfo + assert userinfo["sub"] == test_user.id + assert "email" in userinfo + assert userinfo["email"] == test_user.email + + 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 + + # Create client with only openid scope + client_data = OIDCClient( + organization_id=test_organization.id, + name="Test OpenID Client", + client_id="test_openid_client", + client_secret_hash="dummy_hash", + redirect_uris=["https://example.com/callback"], + grant_types=["authorization_code"], + response_types=["code"], + scopes=["openid"], # Only openid + token_endpoint_auth_method="client_secret_basic", + is_active=True, + is_confidential=False, + require_pkce=False, + ) + from app.extensions import db + db.session.add(client_data) + db.session.commit() + + with app.app_context(): + tokens = OIDCService.generate_tokens( + client_id=client_data.client_id, + user_id=test_user.id, + scope=["openid"], + ) + + response = client.get( + "/oidc/userinfo", + headers={"Authorization": f"Bearer {tokens['access_token']}"} + ) + + assert response.status_code == 200 + data = response.get_json() + userinfo = data["data"] + + # Should only have sub claim with openid scope + assert userinfo["sub"] == test_user.id + assert "email" not in userinfo + assert "name" not in userinfo + + +@pytest.mark.integration +class TestOIDCTokenRefresh: + """Tests for OIDC Token Refresh.""" + + @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 + + client_data = OIDCClient( + organization_id=test_organization.id, + name="Test Refresh Client", + client_id="test_refresh_client", + client_secret_hash="dummy_hash", + redirect_uris=["https://example.com/callback"], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scopes=["openid", "profile", "email"], + token_endpoint_auth_method="client_secret_basic", + is_active=True, + is_confidential=False, + require_pkce=False, + ) + from app.extensions import db + db.session.add(client_data) + db.session.commit() + + with client.application.app_context(): + tokens = OIDCService.generate_tokens( + client_id=client_data.client_id, + user_id=test_user.id, + scope=["openid", "profile", "email"], + ) + + return client_data, tokens["refresh_token"] + + def test_refresh_access_token(self, client, test_client_with_refresh_token): + """Test refreshing an access token.""" + client_data, refresh_token = test_client_with_refresh_token + + response = client.post( + "/oidc/token", + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_data.client_id, + }, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + + tokens = data["data"] + assert "access_token" in tokens + assert "refresh_token" in tokens # Token rotation + assert "id_token" in tokens + assert "expires_in" in tokens + + def test_refresh_without_refresh_token(self, client, test_client_with_refresh_token): + """Test refresh fails without refresh token.""" + client_data = test_client_with_refresh_token[0] + + response = client.post( + "/oidc/token", + data={ + "grant_type": "refresh_token", + "client_id": client_data.client_id, + }, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 400 + data = response.get_json() + assert data["success"] is False + + def test_refresh_with_invalid_token(self, client, test_client_with_refresh_token): + """Test refresh fails with invalid refresh token.""" + client_data = test_client_with_refresh_token[0] + + response = client.post( + "/oidc/token", + data={ + "grant_type": "refresh_token", + "refresh_token": "invalid_refresh_token", + "client_id": client_data.client_id, + }, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 400 + data = response.get_json() + assert data["success"] is False + + +@pytest.mark.integration +class TestOIDCTokenRevocation: + """Tests for OIDC Token Revocation.""" + + @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 + + client_data = OIDCClient( + organization_id=test_organization.id, + name="Test Revoke Client", + client_id="test_revoke_client", + client_secret_hash="dummy_hash", + redirect_uris=["https://example.com/callback"], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scopes=["openid", "profile", "email"], + token_endpoint_auth_method="client_secret_basic", + is_active=True, + is_confidential=False, + require_pkce=False, + ) + from app.extensions import db + db.session.add(client_data) + db.session.commit() + + with client.application.app_context(): + tokens = OIDCService.generate_tokens( + client_id=client_data.client_id, + user_id=test_user.id, + scope=["openid", "profile", "email"], + ) + + return client_data, tokens["access_token"], tokens["refresh_token"] + + def test_revoke_access_token(self, client, test_client_with_tokens): + """Test revoking an access token.""" + client_data, access_token, refresh_token = test_client_with_tokens + + response = client.post( + "/oidc/revoke", + data={ + "token": access_token, + "token_type_hint": "access_token", + "client_id": client_data.client_id, + }, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + + def test_revoke_refresh_token(self, client, test_client_with_tokens): + """Test revoking a refresh token.""" + client_data, access_token, refresh_token = test_client_with_tokens + + response = client.post( + "/oidc/revoke", + data={ + "token": refresh_token, + "token_type_hint": "refresh_token", + "client_id": client_data.client_id, + }, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + + def test_revoke_without_token(self, client, test_client_with_tokens): + """Test revocation fails without token.""" + client_data = test_client_with_tokens[0] + + response = client.post( + "/oidc/revoke", + data={ + "client_id": client_data.client_id, + }, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 400 + data = response.get_json() + assert data["success"] is False + + def test_revoke_without_client_auth(self, client, test_client_with_tokens): + """Test revocation fails without client authentication.""" + _, access_token, _ = test_client_with_tokens + + response = client.post( + "/oidc/revoke", + data={ + "token": access_token, + }, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 401 + + +@pytest.mark.integration +class TestOIDCTokenIntrospection: + """Tests for OIDC Token Introspection.""" + + @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 + + client_data = OIDCClient( + organization_id=test_organization.id, + name="Test Introspect Client", + client_id="test_introspect_client", + client_secret_hash="dummy_hash", + redirect_uris=["https://example.com/callback"], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scopes=["openid", "profile", "email"], + token_endpoint_auth_method="client_secret_basic", + is_active=True, + is_confidential=False, + require_pkce=False, + ) + from app.extensions import db + db.session.add(client_data) + db.session.commit() + + with client.application.app_context(): + tokens = OIDCService.generate_tokens( + client_id=client_data.client_id, + user_id=test_user.id, + scope=["openid", "profile", "email"], + ) + + return client_data, tokens["access_token"] + + def test_introspect_active_token(self, client, test_client_with_tokens): + """Test introspecting an active token.""" + client_data, access_token = test_client_with_tokens + + response = client.post( + "/oidc/introspect", + data={ + "token": access_token, + "client_id": client_data.client_id, + }, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["success"] is True + + result = data["data"] + assert result["active"] is True + assert "sub" in result + assert "exp" in result + + def test_introspect_without_token(self, client, test_client_with_tokens): + """Test introspection fails without token.""" + client_data = test_client_with_tokens[0] + + response = client.post( + "/oidc/introspect", + data={ + "client_id": client_data.client_id, + }, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 400 + data = response.get_json() + assert data["success"] is False + + +@pytest.mark.integration +class TestOIDCCompleteFlow: + """Tests for complete OIDC authentication flow.""" + + 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 + + # Create a test client + with app.app_context(): + client_data = OIDCClient( + organization_id=test_organization.id, + name="Complete Flow Client", + client_id="complete_flow_client", + client_secret_hash="dummy_hash", + redirect_uris=["https://example.com/callback"], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scopes=["openid", "profile", "email"], + token_endpoint_auth_method="client_secret_basic", + is_active=True, + is_confidential=False, + require_pkce=True, + ) + db.session.add(client_data) + db.session.commit() + + # Generate PKCE parameters + code_verifier = secrets.token_urlsafe(32) + digest = hashlib.sha256(code_verifier.encode()).digest() + code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=") + state = secrets.token_urlsafe(16) + nonce = secrets.token_urlsafe(16) + + # Step 1: Authorization Request + auth_response = client.post( + "/oidc/authorize", + data={ + "client_id": client_data.client_id, + "redirect_uri": "https://example.com/callback", + "response_type": "code", + "scope": "openid profile email", + "state": state, + "nonce": nonce, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "email": test_user.email, + "password": test_user._test_password, + } + ) + + assert auth_response.status_code == 302 + + # Extract authorization code + redirect_location = auth_response.headers.get("Location", "") + from urllib.parse import parse_qs, urlparse + parsed = urlparse(redirect_location) + params = parse_qs(parsed.query) + auth_code = params.get("code", [None])[0] + + assert auth_code is not None + + # Step 2: Token Exchange + token_response = client.post( + "/oidc/token", + data={ + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": "https://example.com/callback", + "client_id": client_data.client_id, + "code_verifier": code_verifier, + }, + content_type="application/x-www-form-urlencoded", + ) + + assert token_response.status_code == 200 + token_data = token_response.get_json() + tokens = token_data["data"] + + access_token = tokens["access_token"] + refresh_token = tokens["refresh_token"] + id_token = tokens["id_token"] + + # Step 3: UserInfo Request + userinfo_response = client.get( + "/oidc/userinfo", + headers={"Authorization": f"Bearer {access_token}"} + ) + + assert userinfo_response.status_code == 200 + userinfo_data = userinfo_response.get_json() + assert userinfo_data["data"]["sub"] == test_user.id + + # Step 4: Token Refresh + refresh_response = client.post( + "/oidc/token", + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_data.client_id, + }, + content_type="application/x-www-form-urlencoded", + ) + + assert refresh_response.status_code == 200 + refresh_data = refresh_response.get_json() + new_access_token = refresh_data["data"]["access_token"] + new_refresh_token = refresh_data["data"]["refresh_token"] + + # Step 5: Token Revocation + revoke_response = client.post( + "/oidc/revoke", + data={ + "token": new_refresh_token, + "token_type_hint": "refresh_token", + "client_id": client_data.client_id, + }, + content_type="application/x-www-form-urlencoded", + ) + + assert revoke_response.status_code == 200 + + # Verify refresh token was revoked + refresh_after_revoke = client.post( + "/oidc/token", + data={ + "grant_type": "refresh_token", + "refresh_token": new_refresh_token, + "client_id": client_data.client_id, + }, + content_type="application/x-www-form-urlencoded", + ) + + assert refresh_after_revoke.status_code == 400