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:
@@ -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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user