Files
gatehouse-api/gatehouse_app/api/v1/oidc.py
T

888 lines
32 KiB
Python

"""OIDC (OpenID Connect) API endpoints - API v1 blueprint."""
import base64
import json
import logging
import secrets
from datetime import datetime, timezone
from urllib.parse import urlencode, urlparse
import bcrypt
from flask import request, redirect, jsonify, session, g, current_app
from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.utils.response import api_response
from gatehouse_app.services.oidc import (
OIDCService, InvalidClientError, InvalidGrantError, InvalidRequestError
)
from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.services.mfa_policy_service import MfaPolicyService
from gatehouse_app.extensions import db
from gatehouse_app.extensions import bcrypt as flask_bcrypt
from gatehouse_app.extensions import redis_client as _redis_client_ref
from gatehouse_app.models import User, OIDCClient
from gatehouse_app.models.organization.organization import Organization
from gatehouse_app.exceptions.auth_exceptions import (
InvalidCredentialsError,
AccountSuspendedError,
AccountInactiveError,
)
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.services.oidc_audit_service import OIDCAuditService
from gatehouse_app.utils.validators import validate_cors_origins
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers for Redis-backed OIDC pending state
# ---------------------------------------------------------------------------
_OIDC_PENDING_TTL = 600
def _oidc_redis():
"""Return the shared Redis client, or None if not yet initialised."""
import gatehouse_app.extensions as _ext
return _ext.redis_client
def _stash_oidc_params(oidc_session_id: str, params: dict) -> None:
"""Store OIDC params in Redis with a TTL. Falls back to Flask session."""
rc = _oidc_redis()
key = f"oidc_pending:{oidc_session_id}"
if rc is not None:
rc.setex(key, _OIDC_PENDING_TTL, json.dumps(params))
else:
session[f"oidc_pending_{oidc_session_id}"] = params
def _fetch_oidc_params(oidc_session_id: str, *, consume: bool = False) -> dict | None:
"""Retrieve (and optionally delete) OIDC params from Redis / Flask session."""
rc = _oidc_redis()
key = f"oidc_pending:{oidc_session_id}"
if rc is not None:
raw = rc.get(key)
if raw is None:
return None
params = json.loads(raw)
if consume:
rc.delete(key)
return params
else:
params = session.get(f"oidc_pending_{oidc_session_id}")
if params and consume:
session.pop(f"oidc_pending_{oidc_session_id}", None)
return params
# ============================================================================
# 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}/api/v1/oidc/authorize",
"token_endpoint": f"{base_url}/api/v1/oidc/token",
"userinfo_endpoint": f"{base_url}/api/v1/oidc/userinfo",
"jwks_uri": f"{base_url}/api/v1/oidc/jwks",
"registration_endpoint": f"{base_url}/api/v1/oidc/register",
"revocation_endpoint": f"{base_url}/api/v1/oidc/revoke",
"introspection_endpoint": f"{base_url}/api/v1/oidc/introspect",
"scopes_supported": ["openid", "profile", "email", "roles"],
"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", "roles"],
}
def authenticate_client(client_id, client_secret=None):
"""Authenticate an OIDC client."""
logger.debug(f"[OIDC] Client validation: client_id={client_id}, confidential={client_secret is not None}")
client = OIDCClient.query.filter_by(client_id=client_id, is_active=True).first()
if not client:
logger.debug(f"[OIDC] Client validation: client_id={client_id}, exists=False")
raise InvalidClientError("Invalid client")
logger.debug(f"[OIDC] Client validation: client_id={client_id}, client_id_db={client.id}, exists=True")
if client.is_confidential and client_secret:
secret_match = _check_password_hash(client, client_secret)
logger.debug(f"[OIDC] Client secret validation: client_id={client_id}, match={secret_match}")
if not secret_match:
raise InvalidClientError("Invalid client credentials")
return client
def require_valid_token():
"""Validate Bearer token from Authorization header."""
logger.debug("[OIDC USERINFO] require_valid_token() called")
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:]
try:
claims = OIDCService.validate_access_token(token)
except Exception as e:
logger.error("[OIDC USERINFO] Token validation failed: %s: %s", type(e).__name__, str(e))
raise
g.current_token = claims
g.access_token = token
user_id = claims.get("sub")
user = User.query.get(user_id)
if not user:
raise InvalidGrantError("Invalid token: User not found")
g.current_user = user
def _check_password_hash(client, password):
"""Check password hash with backward compatibility for old bcrypt format."""
pw_hash = client.client_secret_hash
try:
return flask_bcrypt.check_password_hash(pw_hash, password)
except ValueError:
pass
try:
match = bcrypt.checkpw(
pw_hash.encode('utf-8') if isinstance(pw_hash, str) else pw_hash,
password.encode('utf-8') if isinstance(password, str) else password
)
if match:
new_hash = flask_bcrypt.generate_password_hash(
password.decode('utf-8') if isinstance(password, bytes) else password
).decode('utf-8')
client.client_secret_hash = new_hash
db.session.commit()
logger.info(f"[OIDC] Migrated client secret hash to new format: client_id={client.client_id}")
return match
except Exception:
return False
def parse_basic_auth():
"""Parse Basic authentication from Authorization header."""
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
# ============================================================================
# OIDC UI Bridge
# ============================================================================
@api_v1_bp.route("/oidc/begin", methods=["POST"])
def oidc_begin():
"""Stash OIDC authorize params server-side, return a one-time session ID."""
data = request.get_json(silent=True) or {}
oidc_session_id = data.get("oidc_session_id") or request.args.get("oidc_session_id")
if not oidc_session_id:
return api_response(success=False, message="oidc_session_id required", status=400)
params = _fetch_oidc_params(oidc_session_id)
if not params:
return api_response(success=False, message="OIDC session expired or invalid", status=400)
client = OIDCClient.query.filter_by(client_id=params.get("client_id"), is_active=True).first()
client_name = client.name if client else params.get("client_id", "Unknown Application")
return api_response(
data={
"oidc_session_id": oidc_session_id,
"client_name": client_name,
"scopes": params.get("scope", "").split(),
"redirect_uri": params.get("redirect_uri"),
},
message="OIDC session found",
)
@api_v1_bp.route("/oidc/complete", methods=["POST"])
def oidc_complete():
"""Complete an OIDC authorization flow after the UI has authenticated the user."""
from gatehouse_app.models.user.session import Session as GHSession
from gatehouse_app.utils.constants import SessionStatus
data = request.get_json(silent=True) or {}
oidc_session_id = data.get("oidc_session_id")
bearer_token = data.get("token")
if not oidc_session_id or not bearer_token:
return api_response(success=False, message="oidc_session_id and token required", status=400)
gh_session = GHSession.query.filter_by(token=bearer_token, status=SessionStatus.ACTIVE).first()
if not gh_session or gh_session.is_expired():
return api_response(success=False, message="Invalid or expired token", status=401)
user_id = str(gh_session.user_id)
from gatehouse_app.models.user.user import User as _User
from gatehouse_app.utils.constants import UserStatus
_complete_user = _User.query.filter_by(id=user_id, deleted_at=None).first()
if not _complete_user or _complete_user.status in (
UserStatus.SUSPENDED, UserStatus.COMPLIANCE_SUSPENDED, UserStatus.INACTIVE
):
return api_response(
success=False,
message="Your account is not active or has been suspended.",
status=403,
error_type="ACCOUNT_SUSPENDED",
)
params = _fetch_oidc_params(oidc_session_id, consume=True)
if not params:
return api_response(success=False, message="OIDC session expired or invalid", status=400)
client_id = params["client_id"]
redirect_uri = params["redirect_uri"]
state = params.get("state", "")
nonce = params.get("nonce", "")
scope = params.get("scope", "openid")
client = OIDCClient.query.filter_by(client_id=client_id, is_active=True).first()
if not client:
return api_response(success=False, message="OIDC client not found", status=400)
try:
valid_scopes = [s for s in scope.split() if s in (client.scopes or [])]
if not valid_scopes:
valid_scopes = ["openid"]
code = OIDCService.generate_authorization_code(
client_id=client_id,
user_id=user_id,
redirect_uri=redirect_uri,
scope=valid_scopes,
state=state,
nonce=nonce,
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
)
except Exception as e:
logger.error("[OIDC complete] Code generation failed: %s", str(e))
return api_response(success=False, message=f"Failed to generate authorization code: {e}", status=500)
redirect_params = {"code": code}
if state:
redirect_params["state"] = state
redirect_url = f"{redirect_uri}?{urlencode(redirect_params)}"
return api_response(data={"redirect_url": redirect_url}, message="Authorization complete")
# ============================================================================
# Authorization Endpoint
# ============================================================================
@api_v1_bp.route("/oidc/authorize", methods=["GET", "POST"])
def oidc_authorize():
"""OpenID Connect Authorization endpoint."""
logger.debug("[OIDC] oidc_authorize called")
if request.method == "GET":
params = request.args.to_dict()
else:
params = request.form.to_dict()
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")
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)
if response_type != "code":
return _redirect_with_error(
redirect_uri, "unsupported_response_type",
"Only response_type=code is supported", state
)
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)
if not client.is_redirect_uri_allowed(redirect_uri):
return _redirect_with_error(redirect_uri, "invalid_request", "Invalid redirect_uri", state)
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)
user_id = session.get("oidc_user_id")
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)
policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me=False)
if not policy_result.can_create_full_session:
return _show_login_page(
client_id=client_id,
redirect_uri=redirect_uri,
scope=scope,
state=state,
nonce=nonce,
response_type=response_type,
error="Your account requires multi factor enrollment before using single sign on"
)
user_id = user.id
session["oidc_user_id"] = user_id
except AccountSuspendedError:
return _show_login_page(
client_id=client_id,
redirect_uri=redirect_uri,
scope=scope,
state=state,
nonce=nonce,
response_type=response_type,
error="Your account has been suspended. Please contact an administrator.",
)
except AccountInactiveError:
return _show_login_page(
client_id=client_id,
redirect_uri=redirect_uri,
scope=scope,
state=state,
nonce=nonce,
response_type=response_type,
error="Your account is not active. Please verify your email.",
)
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 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 = User.query.get(user_id)
if not user:
return _redirect_with_error(redirect_uri, "server_error", "User not found", state)
from gatehouse_app.utils.constants import UserStatus as _UserStatus
if user.status in (_UserStatus.SUSPENDED, _UserStatus.COMPLIANCE_SUSPENDED):
session.pop("oidc_user_id", None)
return _show_login_page(
client_id=client_id,
redirect_uri=redirect_uri,
scope=scope,
state=state,
nonce=nonce,
response_type=response_type,
error="Your account has been suspended. Please contact an administrator.",
)
if user.status == _UserStatus.INACTIVE:
session.pop("oidc_user_id", None)
return _show_login_page(
client_id=client_id,
redirect_uri=redirect_uri,
scope=scope,
state=state,
nonce=nonce,
response_type=response_type,
error="Your account is not active. Please verify your email.",
)
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:
logger.error("[OIDC] Authorization code generation failed: %s", str(e))
return _redirect_with_error(redirect_uri, "server_error", str(e), state)
redirect_params = {"code": code}
if state:
redirect_params["state"] = state
redirect_url = f"{redirect_uri}?{urlencode(redirect_params)}"
return redirect(redirect_url)
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):
"""Redirect to the Gatehouse React UI login page for a proper login experience."""
ui_base_url = current_app.config.get("OIDC_UI_URL", "http://localhost:8080")
oidc_session_id = secrets.token_urlsafe(32)
_stash_oidc_params(oidc_session_id, {
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": scope,
"state": state,
"nonce": nonce,
"response_type": response_type,
})
params = {"oidc_session_id": oidc_session_id}
if error:
params["error"] = error
return redirect(f"{ui_base_url}/oidc-login?{urlencode(params)}")
# ============================================================================
# Token Endpoint
# ============================================================================
@api_v1_bp.route("/oidc/token", methods=["POST"])
def oidc_token():
"""OpenID Connect Token endpoint."""
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")
if not grant_type:
response = jsonify({
"error": "invalid_request",
"error_description": "grant_type is required"
})
return response, 400
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 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
if grant_type == "authorization_code":
return _handle_authorization_code_grant(data, client)
elif grant_type == "refresh_token":
return _handle_refresh_token_grant(data, client)
else:
response = jsonify({
"error": "unsupported_grant_type",
"error_description": f"Grant type '{grant_type}' is not supported"
})
return response, 400
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 jsonify({"error": "invalid_request", "error_description": "code is required"}), 400
if not redirect_uri:
return jsonify({"error": "invalid_request", "error_description": "redirect_uri is required"}), 400
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 jsonify({"error": "invalid_grant", "error_description": str(e)}), 400
except Exception as e:
return jsonify({"error": "invalid_grant", "error_description": str(e)}), 400
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 jsonify({"error": "server_error", "error_description": str(e)}), 500
response = jsonify(tokens)
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
return response, 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 jsonify({"error": "invalid_request", "error_description": "refresh_token is required"}), 400
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 jsonify({"error": "invalid_grant", "error_description": str(e)}), 400
response = jsonify(tokens)
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
return response, 200
# ============================================================================
# UserInfo Endpoint
# ============================================================================
@api_v1_bp.route("/oidc/userinfo", methods=["GET", "POST"])
def oidc_userinfo():
"""OpenID Connect UserInfo endpoint."""
try:
require_valid_token()
except InvalidGrantError as e:
response = jsonify({
"error": "invalid_token",
"error_description": str(e)
})
response.headers["WWW-Authenticate"] = 'Bearer realm="OIDC UserInfo Endpoint", error="invalid_token"'
return response, 401
except Exception as e:
response = jsonify({
"error": "server_error",
"error_description": str(e)
})
response.headers["WWW-Authenticate"] = 'Bearer realm="OIDC UserInfo Endpoint", error="server_error"'
return response, 500
access_token = g.access_token
try:
userinfo = OIDCService.get_userinfo(access_token)
except Exception as e:
return jsonify({"error": "server_error", "error_description": str(e)}), 500
response = jsonify(userinfo)
response.headers["Cache-Control"] = "no-cache, no-store"
return response, 200
# ============================================================================
# JWKS Endpoint
# ============================================================================
@api_v1_bp.route("/oidc/jwks", methods=["GET"])
def oidc_jwks():
"""OpenID Connect JSON Web Key Set endpoint."""
try:
jwks = OIDCService.get_jwks()
except Exception as e:
return jsonify({"error": "server_error", "error_description": str(e)}), 500
response = jsonify(jwks)
response.headers["Cache-Control"] = "max-age=3600"
return response, 200
# ============================================================================
# Token Revocation Endpoint
# ============================================================================
@api_v1_bp.route("/oidc/revoke", methods=["POST"])
def oidc_revoke():
"""OAuth2 Token Revocation endpoint."""
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 jsonify({"error": "invalid_request", "error_description": "token is required"}), 400
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:
return jsonify({"error": "invalid_client", "error_description": "Invalid client credentials"}), 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:
pass
return "", 200
# ============================================================================
# Token Introspection Endpoint
# ============================================================================
@api_v1_bp.route("/oidc/introspect", methods=["POST"])
def oidc_introspect():
"""OAuth2 Token Introspection endpoint."""
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 jsonify({"error": "invalid_request", "error_description": "token is required"}), 400
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:
return jsonify({"error": "invalid_client", "error_description": "Invalid client credentials"}), 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 jsonify({"error": "server_error", "error_description": str(e)}), 500
response = jsonify(result)
response.headers["Cache-Control"] = "no-cache, no-store"
return response, 200
# ============================================================================
# Client Registration Endpoint
# ============================================================================
@api_v1_bp.route("/oidc/register", methods=["POST"])
def oidc_register():
"""OpenID Connect Client Registration endpoint."""
data = request.json or {}
client_name = data.get("client_name")
redirect_uris = data.get("redirect_uris", [])
if not client_name:
return jsonify({"error": "invalid_request", "error_description": "client_name is required"}), 400
if not redirect_uris:
return jsonify({"error": "invalid_request", "error_description": "redirect_uris is required"}), 400
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 jsonify({"error": "invalid_request", "error_description": f"Invalid redirect_uri: {uri}"}), 400
cors_origins_raw = data.get("allowed_cors_origins")
cors_origins, cors_error = validate_cors_origins(cors_origins_raw)
if cors_error:
return jsonify({"error": "invalid_request", "error_description": cors_error}), 400
client_id = f"oidc_{secrets.token_urlsafe(16)}"
client_secret = f"secret_{secrets.token_urlsafe(24)}"
client_secret_hash = flask_bcrypt.generate_password_hash(client_secret).decode("utf-8")
org_id = data.get("organization_id")
if org_id:
organization = Organization.query.filter_by(id=org_id, deleted_at=None).first()
else:
organization = Organization.query.filter_by(is_active=True, deleted_at=None).first()
if not organization:
organization = Organization(
name=f"OIDC Clients",
slug=f"oidc-clients-{secrets.token_urlsafe(8)}",
)
organization.save()
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 roles").split(),
allowed_cors_origins=cors_origins,
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()
OIDCAuditService.log_event(
event_type="client_registration",
client_id=client_id,
user_id=g.current_user.id if hasattr(g, "current_user") else None,
success=True,
metadata={
"client_name": client_name,
"redirect_uris": redirect_uris,
"organization_id": str(organization.id),
},
)
response = jsonify({
"client_id": client_id,
"client_secret": client_secret,
"client_id_issued_at": int(__import__("time").time()),
"client_secret_expires_at": 0,
"client_name": client_name,
"redirect_uris": redirect_uris,
"allowed_cors_origins": client.allowed_cors_origins,
"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),
})
return response, 201