Feat(Chore): Verify Flow, Invites, Suspend, Depart Cert Policy

feat: add password reset and email verification flow
feat: add org invite listing, cancellation, and invite link fallback
feat: add user suspend/unsuspend with audit logging
feat: add department certificate policy (expiry, extensions)
feat: enforce dept cert policy on SSH certificate signing
feat: wire up OIDC consent and token flow (replace mocks)
feat: rework CLI auth bridge to use frontend login flow
feat: add admin OAuth provider management (CRUD)
chore: refactor model import paths after module reorganisation
chore: clean up config, decorators, and dev tooling
This commit is contained in:
2026-03-01 16:50:27 +05:45
parent 07193a2d2e
commit a0d4e59c24
39 changed files with 2035 additions and 611 deletions
+288 -22
View File
@@ -101,24 +101,24 @@ def token_please():
"""
CLI token acquisition endpoint.
Initiates an OAuth login flow and, on success, redirects the user's browser
to the CLI's local callback server (redirect_url) with the session token
appended, e.g.: http://127.0.0.1:8250/?token=<SESSION_TOKEN>
Redirects the user's browser to the Gatehouse login page so they can
authenticate using any method (password, OAuth, passkey, TOTP, etc.).
On successful login the frontend delivers the session token directly to
the CLI's local callback server.
This endpoint is designed for CLI clients that:
1. Start a local HTTP server on LISTENER_SERVER_PORT (e.g. 8250)
2. Open a browser to /api/v1/token_please?redirect_url=http://127.0.0.1:8250/?token=
3. Wait for the browser to POST the token back to their local server
3. Wait for the browser to deliver the token to their local server
Query parameters:
redirect_url: Local callback URL where the token will be appended
provider: OAuth provider to use (default: 'google')
"""
from urllib.parse import urlencode
import secrets
from urllib.parse import urlencode, quote
from flask import current_app, redirect as flask_redirect
redirect_url = request.args.get("redirect_url", "").strip()
provider = request.args.get("provider", "google").lower()
if not redirect_url:
return api_response(
@@ -139,26 +139,92 @@ def token_please():
error_type="INVALID_REDIRECT_URL",
)
# Store the CLI redirect URL in Redis keyed by a short-lived token so the
# frontend can retrieve it after login without it being visible in the URL.
cli_token = secrets.token_urlsafe(32)
try:
provider_type = get_provider_type(provider)
auth_url, state = OAuthFlowService.initiate_login_flow(
provider_type=provider_type,
organization_id=None,
redirect_uri=None,
)
except (OAuthFlowError, ExternalAuthError) as e:
import gatehouse_app.extensions as _ext
rc = _ext.redis_client
if rc is not None:
rc.setex(f"cli_redirect:{cli_token}", _OAUTH_BRIDGE_TTL, redirect_url)
else:
logger.warning("Redis not available; passing cli_redirect directly in URL")
cli_token = None
except Exception:
cli_token = None
frontend_url = current_app.config.get("FRONTEND_URL", "http://localhost:8080")
if cli_token:
# Pass an opaque token; the frontend exchanges it for the real URL via
# GET /api/v1/cli/redirect-url?token=<cli_token>
login_url = f"{frontend_url}/login?cli_token={cli_token}"
else:
# Fallback: put the redirect URL directly (still localhost-only, validated above)
login_url = f"{frontend_url}/login?cli_redirect={quote(redirect_url, safe='')}"
logger.info(f"CLI token_please: redirecting browser to Gatehouse login page")
return flask_redirect(login_url, code=302)
@api_v1_bp.route("/cli/redirect-url", methods=["GET"])
def cli_redirect_url_lookup():
"""
Exchange a short-lived cli_token for the CLI's local redirect URL.
Called by the frontend LoginPage after it detects the cli_token query
param so it can obtain the actual CLI callback URL from Redis without
exposing it in the browser URL bar.
Query parameters:
token: The cli_token issued by /token_please
Returns:
200: { "redirect_url": "http://127.0.0.1:8250/?token=" }
400: Missing token
404: Token not found or expired
"""
cli_token = request.args.get("token", "").strip()
if not cli_token:
return api_response(
success=False,
message=getattr(e, "message", str(e)),
status=getattr(e, "status_code", 400),
error_type=getattr(e, "error_type", "OAUTH_ERROR"),
message="token query parameter is required",
status=400,
error_type="MISSING_TOKEN",
)
# Store the CLI redirect URL so the callback can use it
_store_cli_redirect(state, redirect_url)
try:
import gatehouse_app.extensions as _ext
rc = _ext.redis_client
if rc is not None:
key = f"cli_redirect:{cli_token}"
val = rc.get(key)
if val is None:
return api_response(
success=False,
message="CLI token not found or expired",
status=404,
error_type="TOKEN_NOT_FOUND",
)
# Keep the key alive until the login actually completes (consume on use
# would break multi-step auth like TOTP), so we leave it as-is.
redirect_url = val.decode() if isinstance(val, bytes) else val
return api_response(data={"redirect_url": redirect_url})
except Exception as e:
logger.error(f"cli_redirect_url_lookup error: {e}")
return api_response(
success=False,
message="Internal error looking up CLI token",
status=500,
error_type="INTERNAL_ERROR",
)
logger.info(f"CLI token_please: provider={provider}, redirect_url={redirect_url}, redirecting to OAuth")
return flask_redirect(auth_url, code=302)
return api_response(
success=False,
message="Redis not available",
status=503,
error_type="SERVICE_UNAVAILABLE",
)
# =============================================================================
@@ -175,7 +241,7 @@ def list_providers():
200: List of providers with their configuration status
401: Not authenticated
"""
from gatehouse_app.models.authentication_method import ApplicationProviderConfig
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
from gatehouse_app.services.external_auth_service import ExternalProviderConfig
# Check app-level provider configs (ApplicationProviderConfig)
@@ -1173,3 +1239,203 @@ def _get_provider_endpoints(provider_type: AuthMethodType):
"UNSUPPORTED_PROVIDER",
400,
)
# =============================================================================
# Admin: Application-level OAuth Provider Management
# =============================================================================
@api_v1_bp.route("/admin/oauth/providers", methods=["GET"])
@login_required
def admin_list_app_providers():
"""List all application-level OAuth provider configurations (admin only).
Returns:
200: List of providers with client_id and enabled status
401: Not authenticated
403: Not an admin
"""
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
from gatehouse_app.models import OrganizationMember
from gatehouse_app.utils.constants import OrganizationRole
# Verify caller is admin in any org
admin_memberships = OrganizationMember.query.filter(
OrganizationMember.user_id == g.current_user.id,
OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN]),
).all()
if not admin_memberships:
return api_response(
success=False,
message="Admin access required",
status=403,
error_type="FORBIDDEN",
)
PROVIDERS = [
{"id": "google", "name": "Google"},
{"id": "github", "name": "GitHub"},
{"id": "microsoft", "name": "Microsoft"},
]
db_configs = {
c.provider_type: c
for c in ApplicationProviderConfig.query.all()
}
result = []
for p in PROVIDERS:
cfg = db_configs.get(p["id"])
result.append({
"id": p["id"],
"name": p["name"],
"is_configured": cfg is not None,
"is_enabled": cfg.is_enabled if cfg else False,
"client_id": cfg.client_id if cfg else None,
})
return api_response(
data={"providers": result},
message="OAuth providers retrieved successfully",
)
@api_v1_bp.route("/admin/oauth/providers/<provider>", methods=["PUT"])
@login_required
def admin_configure_app_provider(provider: str):
"""Create or update an application-level OAuth provider config (admin only).
Args:
provider: Provider type (google, github, microsoft)
Request body:
client_id: OAuth client ID
client_secret: OAuth client secret (optional — omit to keep existing)
is_enabled: Whether the provider is enabled (default: true)
Returns:
200: Provider configuration updated
400: Validation error
401: Not authenticated
403: Not an admin
"""
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
from gatehouse_app.models import OrganizationMember
from gatehouse_app.utils.constants import OrganizationRole
from gatehouse_app.extensions import db
SUPPORTED = ["google", "github", "microsoft"]
if provider not in SUPPORTED:
return api_response(
success=False,
message=f"Unsupported provider. Must be one of: {', '.join(SUPPORTED)}",
status=400,
error_type="VALIDATION_ERROR",
)
# Verify caller is admin in any org
admin_memberships = OrganizationMember.query.filter(
OrganizationMember.user_id == g.current_user.id,
OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN]),
).all()
if not admin_memberships:
return api_response(
success=False,
message="Admin access required",
status=403,
error_type="FORBIDDEN",
)
data = request.json or {}
client_id = (data.get("client_id") or "").strip()
client_secret = (data.get("client_secret") or "").strip()
is_enabled = data.get("is_enabled", True)
if not client_id:
return api_response(
success=False,
message="client_id is required",
status=400,
error_type="VALIDATION_ERROR",
)
cfg = ApplicationProviderConfig.query.filter_by(provider_type=provider).first()
if cfg:
cfg.client_id = client_id
if client_secret:
cfg.set_client_secret(client_secret)
cfg.is_enabled = bool(is_enabled)
db.session.commit()
else:
cfg = ApplicationProviderConfig(
provider_type=provider,
client_id=client_id,
is_enabled=bool(is_enabled),
)
if client_secret:
cfg.set_client_secret(client_secret)
db.session.add(cfg)
db.session.commit()
return api_response(
data={
"provider": {
"id": provider,
"client_id": cfg.client_id,
"is_enabled": cfg.is_enabled,
}
},
message=f"{provider.capitalize()} OAuth provider configured successfully",
)
@api_v1_bp.route("/admin/oauth/providers/<provider>", methods=["DELETE"])
@login_required
def admin_delete_app_provider(provider: str):
"""Delete an application-level OAuth provider config (admin only).
Args:
provider: Provider type (google, github, microsoft)
Returns:
200: Provider configuration deleted
404: Provider not found
401: Not authenticated
403: Not an admin
"""
from gatehouse_app.models.auth.authentication_method import ApplicationProviderConfig
from gatehouse_app.models import OrganizationMember
from gatehouse_app.utils.constants import OrganizationRole
from gatehouse_app.extensions import db
# Verify caller is admin in any org
admin_memberships = OrganizationMember.query.filter(
OrganizationMember.user_id == g.current_user.id,
OrganizationMember.role.in_([OrganizationRole.OWNER, OrganizationRole.ADMIN]),
).all()
if not admin_memberships:
return api_response(
success=False,
message="Admin access required",
status=403,
error_type="FORBIDDEN",
)
cfg = ApplicationProviderConfig.query.filter_by(provider_type=provider).first()
if not cfg:
return api_response(
success=False,
message=f"Provider '{provider}' is not configured",
status=404,
error_type="NOT_FOUND",
)
db.session.delete(cfg)
db.session.commit()
return api_response(
message=f"{provider.capitalize()} OAuth provider configuration removed",
)