From 1de7e83f0b496e7d09017fa3fbfffd3e2aca419a Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Tue, 8 Sep 2026 00:19:08 +0200 Subject: [PATCH 1/4] Add verified OIDC self-service linking --- app/account_step_up_routes.py | 7 +- app/auth_assurance.py | 3 + app/models.py | 22 ++ app/oidc_routes.py | 289 +++++++++++++++++++++-- app/oidc_service.py | 94 +++++++- app/security_features.py | 2 +- app/step_up.py | 1 + app/step_up_routes.py | 6 +- docs/wiki/OpenID-Connect.md | 32 ++- static/js/i18n-auth.js | 30 +++ static/js/i18n.js | 36 +++ static/js/webauthn.js | 69 ++++++ templates/security.html | 6 + tests/test_database_init.py | 3 + tests/test_i18n_parity.py | 1 + tests/test_oidc_routes.py | 401 +++++++++++++++++++++++++++++++- tests/test_oidc_service.py | 91 ++++++++ tests/test_security_features.py | 2 + tests/test_security_ui.py | 21 ++ 19 files changed, 1081 insertions(+), 35 deletions(-) diff --git a/app/account_step_up_routes.py b/app/account_step_up_routes.py index 77bfa77a..fdba0544 100644 --- a/app/account_step_up_routes.py +++ b/app/account_step_up_routes.py @@ -95,6 +95,7 @@ def _action_target(action, data): "totp.enroll": "totp", "totp.delete": "totp", "recovery.rotate": "recovery", + "oidc.self_link": "oidc", }.get(action) if feature is not None and not feature_is_active(feature): raise StepUpError("step-up request is invalid") @@ -553,15 +554,13 @@ def oidc_step_up_start(): from .oidc_routes import begin_oidc_account_step_up try: - response = begin_oidc_account_step_up( + return begin_oidc_account_step_up( intent=intent, continuation=data.get("continuation") or "/security", + return_authorization_url=True, ) except (OIDCStateError, StepUpError): return _error("step_up_failed", 403) - if isinstance(response, tuple): - return response - return jsonify({"authorization_url": response.headers["Location"]}) @account_step_up_blueprint.post("/api/account/step-up/status") diff --git a/app/auth_assurance.py b/app/auth_assurance.py index fa838ee8..ad042a99 100644 --- a/app/auth_assurance.py +++ b/app/auth_assurance.py @@ -347,6 +347,9 @@ def invalidate_user_authentication( GitHubOAuthState.query.filter_by(user_id=user.id).delete( synchronize_session=False ) + OIDCLoginState.query.filter_by(user_id=user.id).delete( + synchronize_session=False + ) if step_up_intent_ids: GitHubOAuthState.query.filter( GitHubOAuthState.step_up_intent_id.in_(step_up_intent_ids) diff --git a/app/models.py b/app/models.py index 2b480613..a1b572fb 100644 --- a/app/models.py +++ b/app/models.py @@ -231,6 +231,19 @@ def ensure_security_columns(): "ALTER TABLE oidc_login_states ADD COLUMN step_up_intent_id " "INTEGER" ) + if 'user_id' not in existing: + additions.append( + "ALTER TABLE oidc_login_states ADD COLUMN user_id INTEGER" + ) + if 'auth_generation' not in existing: + additions.append( + "ALTER TABLE oidc_login_states ADD COLUMN auth_generation INTEGER" + ) + if 'authentication_session_id' not in existing: + additions.append( + "ALTER TABLE oidc_login_states ADD COLUMN " + "authentication_session_id INTEGER" + ) for statement in additions: db.session.execute(text(statement)) if additions: @@ -712,6 +725,15 @@ class OIDCLoginState(db.Model): default='login', server_default='login', ) + user_id = db.Column( + db.Integer, + db.ForeignKey('users.id'), + nullable=True, + ) + auth_generation = db.Column(db.Integer, nullable=True) + # No foreign key: an in-flight provider redirect must never block logout + # or deletion of the server-side authentication session it references. + authentication_session_id = db.Column(db.Integer, nullable=True) continuation = db.Column( db.String(512), nullable=False, diff --git a/app/oidc_routes.py b/app/oidc_routes.py index 660809b8..91705407 100644 --- a/app/oidc_routes.py +++ b/app/oidc_routes.py @@ -6,6 +6,7 @@ import logging from datetime import datetime, timezone from pathlib import Path +from urllib.parse import parse_qs, urlsplit from authlib.integrations.flask_client import OAuth from flask import ( @@ -18,6 +19,7 @@ session, ) from flask_login import current_user, login_required +from sqlalchemy import insert, literal, select from sqlalchemy.exc import IntegrityError import config @@ -29,7 +31,14 @@ ) from .auth import check_rate_limit from .decorators import admin_required, step_up_required -from .models import OIDCIdentity, User, db +from .models import ( + AuthenticationSession, + GitHubIdentity, + OIDCIdentity, + User, + as_naive_utc, + db, +) from .oidc_service import ( OIDCStateError, consume_login_state, @@ -99,11 +108,15 @@ def init_oidc(app): def _authorization_redirect( *, purpose, + user_id=None, + auth_generation=None, + authentication_session_id=None, continuation="/", requested_acr=None, step_up_action=None, step_up_target_hash=None, step_up_intent_id=None, + return_authorization_url=False, ): state = secrets.token_urlsafe(32) nonce = secrets.token_urlsafe(32) @@ -114,6 +127,9 @@ def _authorization_redirect( session_binding=_binding(), code_verifier=verifier, purpose=purpose, + user_id=user_id, + auth_generation=auth_generation, + authentication_session_id=authentication_session_id, continuation=continuation, requested_acr=requested_acr, step_up_action=step_up_action, @@ -129,15 +145,33 @@ def _authorization_redirect( "code_challenge": challenge, "code_challenge_method": "S256", } + if purpose == "link": + authorization["prompt"] = "login" if purpose == "step_up": authorization.update({"prompt": "login", "max_age": 0}) if requested_acr: authorization["acr_values"] = requested_acr try: - return _client().authorize_redirect( + response = _client().authorize_redirect( config.OIDC_REDIRECT_URI, **authorization, ) + if return_authorization_url: + location = response.headers.get("Location", "") + parsed_location = urlsplit(location) + returned_states = parse_qs(parsed_location.query).get("state", []) + if ( + not 300 <= response.status_code < 400 + or parsed_location.scheme != "https" + or not parsed_location.hostname + or parsed_location.username is not None + or parsed_location.password is not None + or parsed_location.fragment + or returned_states != [state] + ): + raise RuntimeError("OIDC authorization URL is unavailable") + return jsonify({"authorization_url": location}) + return response except Exception as exc: discard_login_state( state=state, @@ -147,7 +181,142 @@ def _authorization_redirect( return jsonify({"error": "Identity provider unavailable"}), 503 -def begin_oidc_step_up(*, action, target_hash, continuation="/admin"): +def _current_oidc_link_target(intent): + if ( + not current_user.is_authenticated + or current_user.id != intent.user_id + ): + return None + from .auth_assurance import current_authentication_session + + auth_session = current_authentication_session() + target = db.session.get(User, current_user.id, populate_existing=True) + if ( + target is None + or target.is_locked + or auth_session is None + or auth_session.id != intent.authentication_session_id + or auth_session.user_id != target.id + or auth_session.auth_generation != intent.auth_generation + or int(target.auth_generation or 0) != intent.auth_generation + ): + return None + return target + + +def _complete_oidc_self_link(intent, issuer, subject): + target = _current_oidc_link_target(intent) + if target is None: + log_security_event( + "OIDC_IDENTITY_LINK_REJECTED", + level=logging.WARNING, + issuer=issuer, + reason="account_session_changed", + ) + return jsonify({"error": "OIDC identity linking failed"}), 403 + if target.is_ldap_managed or target.is_github_managed: + return jsonify({ + "error": "OIDC cannot be linked to this account" + }), 409 + try: + existing = OIDCIdentity.query.filter_by( + issuer=issuer, + subject=subject, + ).first() + if existing is not None: + if existing.user_id == target.id: + log_security_event( + "OIDC_IDENTITY_LINK_CONFIRMED", + user=target.username, + issuer=issuer, + identity_id=existing.id, + ) + return redirect(intent.continuation) + log_security_event( + "OIDC_IDENTITY_LINK_COLLISION", + level=logging.WARNING, + user=target.username, + issuer=issuer, + ) + return jsonify({ + "error": "OIDC identity is already linked" + }), 409 + now = as_naive_utc(datetime.now(timezone.utc)) + eligible_identity = ( + select( + User.id, + literal(issuer), + literal(subject), + literal(now), + ) + .select_from(User) + .join( + AuthenticationSession, + AuthenticationSession.user_id == User.id, + ) + .where( + User.id == target.id, + User.auth_generation == intent.auth_generation, + User.is_locked.is_(False), + ~User.ldap_identity.has(), + ~User.github_identity.has( + GitHubIdentity.provisioned_by_github.is_(True) + ), + AuthenticationSession.id + == intent.authentication_session_id, + AuthenticationSession.auth_generation + == intent.auth_generation, + AuthenticationSession.expires_at > now, + ) + ) + result = db.session.execute( + insert(OIDCIdentity).from_select( + ["user_id", "issuer", "subject", "created_at"], + eligible_identity, + ) + ) + if result.rowcount != 1: + db.session.rollback() + log_security_event( + "OIDC_IDENTITY_LINK_REJECTED", + level=logging.WARNING, + user=target.username, + issuer=issuer, + reason="account_session_changed", + ) + return jsonify({"error": "OIDC identity linking failed"}), 403 + db.session.commit() + except IntegrityError: + db.session.rollback() + return jsonify({"error": "OIDC identity is already linked"}), 409 + except Exception as exc: + db.session.rollback() + log_security_event( + "OIDC_IDENTITY_STORAGE_FAILED", + level=logging.ERROR, + user=target.username, + issuer=issuer, + error=type(exc).__name__, + ) + return jsonify({ + "error": "OIDC identity storage is temporarily unavailable" + }), 503 + log_security_event( + "OIDC_IDENTITY_LINKED", + user=target.username, + issuer=issuer, + source="self_service", + ) + return redirect(intent.continuation) + + +def begin_oidc_step_up( + *, + action, + target_hash, + continuation="/admin", + return_authorization_url=False, +): """Start a provider reauthentication intent for the step-up subsystem.""" requested_acr = " ".join(sorted(config.OIDC_STEP_UP_ACR_VALUES)) or None return _authorization_redirect( @@ -156,10 +325,16 @@ def begin_oidc_step_up(*, action, target_hash, continuation="/admin"): requested_acr=requested_acr, step_up_action=action, step_up_target_hash=target_hash, + return_authorization_url=return_authorization_url, ) -def begin_oidc_account_step_up(*, intent, continuation="/security"): +def begin_oidc_account_step_up( + *, + intent, + continuation="/security", + return_authorization_url=False, +): """Start provider reauthentication for one persistent account intent.""" from .auth_assurance import AssuranceLevel from .models import StepUpIntent @@ -176,6 +351,7 @@ def begin_oidc_account_step_up(*, intent, continuation="/security"): continuation=continuation, requested_acr=requested_acr, step_up_intent_id=intent.id, + return_authorization_url=return_authorization_url, ) @@ -196,6 +372,52 @@ def oidc_login(): ) +@oidc_blueprint.get("/api/account/oidc") +@login_required +def oidc_account_status(): + _require_enabled() + rows = ( + OIDCIdentity.query + .filter_by(user_id=current_user.id) + .order_by(OIDCIdentity.created_at.asc(), OIDCIdentity.id.asc()) + .all() + ) + return jsonify({ + "identities": [{ + "id": row.id, + "issuer": row.issuer, + "created_at": row.created_at.isoformat(), + } for row in rows] + }) + + +@oidc_blueprint.post("/api/account/oidc/link/start") +@login_required +@step_up_required("oidc.self_link", lambda: current_user.id) +def oidc_self_link_start(): + _require_enabled() + target = db.session.get(User, current_user.id, populate_existing=True) + if target is None or target.is_locked: + return jsonify({"error": "OIDC identity linking failed"}), 403 + if target.is_ldap_managed or target.is_github_managed: + return jsonify({ + "error": "OIDC cannot be linked to this account" + }), 409 + from .auth_assurance import current_authentication_session + + auth_session = current_authentication_session() + if auth_session is None: + return jsonify({"error": "OIDC identity linking failed"}), 403 + return _authorization_redirect( + purpose="link", + user_id=target.id, + auth_generation=int(target.auth_generation or 0), + authentication_session_id=auth_session.id, + continuation="/security", + return_authorization_url=True, + ) + + @oidc_blueprint.get("/oidc/callback") def oidc_callback(): _require_enabled() @@ -212,6 +434,17 @@ def oidc_callback(): state=state, session_binding=_binding(), ) + if ( + intent.purpose == "link" + and _current_oidc_link_target(intent) is None + ): + log_security_event( + "OIDC_IDENTITY_LINK_REJECTED", + level=logging.WARNING, + ip=client_ip, + reason="account_binding_mismatch", + ) + return jsonify({"error": "OIDC identity linking failed"}), 403 if intent.purpose == "step_up" and ( not current_user.is_authenticated or ( @@ -239,9 +472,19 @@ def oidc_callback(): if claims is None: claims = client.parse_id_token(token, nonce=intent.nonce) signed_claims = claims + if signed_claims is not None and profile_claims is not None: + signed_subject = str(signed_claims.get("sub") or "") + profile_subject = str(profile_claims.get("sub") or "") + if not signed_subject or signed_subject != profile_subject: + raise OIDCStateError("OIDC subject claims do not match") issuer = str(claims.get("iss") or _issuer()).rstrip("/") subject = str(claims.get("sub") or "") - if issuer != _issuer() or not subject: + if ( + issuer != _issuer() + or not subject + or len(issuer) > 512 + or len(subject) > 512 + ): raise OIDCStateError("OIDC issuer or subject is invalid") if ( config.OIDC_ALLOWED_SUBJECTS @@ -259,19 +502,27 @@ def oidc_callback(): ) ): raise OIDCStateError("OIDC email domain is not allowed") - user = resolve_identity(issuer, subject) - if user is None or user.is_locked or user.is_ldap_managed: - log_security_event( - "OIDC_IDENTITY_REJECTED", - level=logging.WARNING, - issuer=issuer, - ip=client_ip, - reason="unlinked_or_locked", - ) - return jsonify({ - "error": "External identity is not linked to an active account" - }), 403 - assurance = evaluate_oidc_assurance(signed_claims or {}, config) + if intent.purpose != "link": + user = resolve_identity(issuer, subject) + rejection_reason = None + if user is None: + rejection_reason = "unlinked" + elif user.is_locked: + rejection_reason = "account_locked" + elif user.is_ldap_managed: + rejection_reason = "externally_managed" + if rejection_reason is not None: + log_security_event( + "OIDC_IDENTITY_REJECTED", + level=logging.WARNING, + issuer=issuer, + ip=client_ip, + reason=rejection_reason, + ) + return jsonify({ + "error": "External identity is not linked to an active account" + }), 403 + assurance = evaluate_oidc_assurance(signed_claims or {}, config) except OIDCStateError as exc: log_security_event( "OIDC_STATE_REJECTED", @@ -288,6 +539,8 @@ def oidc_callback(): error=type(exc).__name__, ) return jsonify({"error": "Identity provider unavailable"}), 503 + if intent.purpose == "link": + return _complete_oidc_self_link(intent, issuer, subject) log_security_event( "OIDC_ASSURANCE_EVALUATED", user=user.username, diff --git a/app/oidc_service.py b/app/oidc_service.py index a1dcea1b..ea441e7d 100644 --- a/app/oidc_service.py +++ b/app/oidc_service.py @@ -17,7 +17,7 @@ class OIDCStateError(ValueError): _oidc_state_lock = Lock() -_STATE_PURPOSES = frozenset({"login", "step_up"}) +_STATE_PURPOSES = frozenset({"login", "link", "step_up"}) _ACTION_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_.:-]{0,95}$") _TARGET_HASH_PATTERN = re.compile(r"^[0-9a-f]{64}$") _MAX_AMR_VALUES = 16 @@ -34,6 +34,9 @@ class OIDCLoginIntent: nonce: str code_verifier: str purpose: str + user_id: int | None + auth_generation: int | None + authentication_session_id: int | None continuation: str requested_acr: str | None step_up_action: str | None @@ -86,6 +89,9 @@ def _normalize_requested_acr(value): def _normalize_state_intent( *, purpose, + user_id, + auth_generation, + authentication_session_id, continuation, requested_acr, step_up_action, @@ -99,12 +105,56 @@ def _normalize_state_intent( requested_acr = _normalize_requested_acr(requested_acr) if purpose == "login": if ( - step_up_action is not None + user_id is not None + or auth_generation is not None + or authentication_session_id is not None + or step_up_action is not None or step_up_target_hash is not None or step_up_intent_id is not None ): raise OIDCStateError("login state contains step-up context") - return purpose, continuation, requested_acr, None, None, None + return ( + purpose, + None, + None, + None, + continuation, + requested_acr, + None, + None, + None, + ) + if purpose == "link": + if ( + type(user_id) is not int + or user_id < 1 + or type(auth_generation) is not int + or auth_generation < 0 + or type(authentication_session_id) is not int + or authentication_session_id < 1 + or requested_acr is not None + or step_up_action is not None + or step_up_target_hash is not None + or step_up_intent_id is not None + ): + raise OIDCStateError("link state context is invalid") + return ( + purpose, + user_id, + auth_generation, + authentication_session_id, + continuation, + None, + None, + None, + None, + ) + if ( + user_id is not None + or auth_generation is not None + or authentication_session_id is not None + ): + raise OIDCStateError("step-up state contains link context") if step_up_intent_id is not None: if ( not isinstance(step_up_intent_id, int) @@ -116,6 +166,9 @@ def _normalize_state_intent( raise OIDCStateError("step-up intent reference is invalid") return ( purpose, + None, + None, + None, continuation, requested_acr, None, @@ -128,7 +181,17 @@ def _normalize_state_intent( raise OIDCStateError("step-up action is invalid") if not _TARGET_HASH_PATTERN.fullmatch(target_hash): raise OIDCStateError("step-up target is invalid") - return purpose, continuation, requested_acr, action, target_hash, None + return ( + purpose, + None, + None, + None, + continuation, + requested_acr, + action, + target_hash, + None, + ) def create_login_state( @@ -138,6 +201,9 @@ def create_login_state( session_binding, code_verifier, purpose="login", + user_id=None, + auth_generation=None, + authentication_session_id=None, continuation="/", requested_acr=None, step_up_action=None, @@ -160,6 +226,9 @@ def create_login_state( raise OIDCStateError("OIDC state values are too long") ( purpose, + user_id, + auth_generation, + authentication_session_id, continuation, requested_acr, step_up_action, @@ -167,6 +236,9 @@ def create_login_state( step_up_intent_id, ) = _normalize_state_intent( purpose=purpose, + user_id=user_id, + auth_generation=auth_generation, + authentication_session_id=authentication_session_id, continuation=continuation, requested_acr=requested_acr, step_up_action=step_up_action, @@ -193,6 +265,9 @@ def create_login_state( nonce=nonce, code_verifier=code_verifier, purpose=purpose, + user_id=user_id, + auth_generation=auth_generation, + authentication_session_id=authentication_session_id, continuation=continuation, requested_acr=requested_acr, step_up_action=step_up_action, @@ -218,6 +293,9 @@ def consume_login_state(*, state, session_binding, now=None): "nonce": row.nonce, "code_verifier": row.code_verifier, "purpose": row.purpose, + "user_id": row.user_id, + "auth_generation": row.auth_generation, + "authentication_session_id": row.authentication_session_id, "continuation": row.continuation, "requested_acr": row.requested_acr, "step_up_action": row.step_up_action, @@ -231,6 +309,9 @@ def consume_login_state(*, state, session_binding, now=None): raise OIDCStateError("OIDC login state has expired") ( intent_values["purpose"], + intent_values["user_id"], + intent_values["auth_generation"], + intent_values["authentication_session_id"], intent_values["continuation"], intent_values["requested_acr"], intent_values["step_up_action"], @@ -238,6 +319,11 @@ def consume_login_state(*, state, session_binding, now=None): intent_values["step_up_intent_id"], ) = _normalize_state_intent( purpose=intent_values["purpose"], + user_id=intent_values["user_id"], + auth_generation=intent_values["auth_generation"], + authentication_session_id=( + intent_values["authentication_session_id"] + ), continuation=intent_values["continuation"], requested_acr=intent_values["requested_acr"], step_up_action=intent_values["step_up_action"], diff --git a/app/security_features.py b/app/security_features.py index 717a52d9..b7371751 100644 --- a/app/security_features.py +++ b/app/security_features.py @@ -197,7 +197,7 @@ def all_feature_statuses(): def request_feature_name(path): """Return the feature protecting an authentication endpoint, if any.""" path = str(path or '') - if path.startswith('/oidc/') or ( + if path.startswith('/oidc/') or path.startswith('/api/account/oidc') or ( path.startswith('/admin/api/users/') and ('/oidc-' in path or path.endswith('/oidc-identities')) ): diff --git a/app/step_up.py b/app/step_up.py index 02c5a8b4..916773d7 100644 --- a/app/step_up.py +++ b/app/step_up.py @@ -48,6 +48,7 @@ "recovery.rotate", "github.link", "github.unlink", + "oidc.self_link", }) diff --git a/app/step_up_routes.py b/app/step_up_routes.py index d98fd2d7..dda43b8d 100644 --- a/app/step_up_routes.py +++ b/app/step_up_routes.py @@ -332,14 +332,12 @@ def oidc_step_up_start(): continuation = str(data.get("continuation") or "/admin") from .oidc_routes import begin_oidc_step_up - response = begin_oidc_step_up( + return begin_oidc_step_up( action=action, target_hash=hash_step_up_target(target), continuation=continuation, + return_authorization_url=True, ) - if isinstance(response, tuple): - return response - return jsonify({"authorization_url": response.headers["Location"]}) @step_up_blueprint.get("/api/step-up/oidc/result") diff --git a/docs/wiki/OpenID-Connect.md b/docs/wiki/OpenID-Connect.md index c6250847..904d0d7c 100644 --- a/docs/wiki/OpenID-Connect.md +++ b/docs/wiki/OpenID-Connect.md @@ -5,7 +5,8 @@ is disabled by default and never auto-provisions a WebSSH account. ## Identity model -An administrator links an exact provider identity to an existing local account: +A signed-in user or administrator links an exact provider identity to an +existing local account: ```text (normalized issuer, subject) -> WebSSH user @@ -124,6 +125,23 @@ or secret details. ## Link an identity +The recommended path does not require copying a provider subject: + +1. Enable and activate OIDC. +2. Sign in to the target WebSSH account with an existing local method. +3. Open **Settings → Security methods → Identity provider**. +4. Choose **Connect identity provider** and complete the action-bound WebSSH + confirmation. +5. Sign in to the configured provider. + +The callback is bound to the same browser session and local account. WebSSH +stores only the provider-verified issuer and subject after the state, nonce, +PKCE, allowlist, and domain checks succeed. It never searches for a matching +username or email address. Repeating the flow can attach another identity from +the same provider to the account. + +### Administrator fallback + 1. Enable OIDC and restart WebSSH. 2. Sign in as a local administrator. 3. Create or select the target local account. @@ -133,7 +151,15 @@ or secret details. 7. Confirm the exact target username. 8. Sign out and test OIDC login with the target identity. -The mapping is unique. A provider identity cannot be attached to multiple users. +Manual entry is intended for recovery or providers where the target user cannot +complete the self-link flow. Obtain the exact subject from trusted provider +documentation or operator tooling; an email address or display name is not a +substitute. The mapping is unique, so a provider identity cannot be attached to +multiple users. + +For providers such as Tinyauth whose subject depends on the provider username +and OIDC client ID, prefer the self-link flow. It captures the signed subject +directly and avoids version-specific subject reconstruction. ## Unlink an identity @@ -165,7 +191,7 @@ and subject. | Admin toggle disabled | Deployment flag or provider readiness failed; fix Compose/secret/discovery and recreate the container | | Provider unavailable | discovery URL, DNS, TLS, secret file, egress | | Callback rejected | exact callback, state cookie, proxy origin, system time | -| Identity not linked | Admin mapping for issuer and subject | +| Identity not linked | Sign in locally and use **Settings → Security methods → Identity provider**, or verify the administrator mapping for the exact issuer and subject | | Domain rejected | email claim and `OIDC_ALLOWED_DOMAINS` | | User rejected after link | locked account or LDAP-managed state | diff --git a/static/js/i18n-auth.js b/static/js/i18n-auth.js index a26bc326..f5a508c9 100644 --- a/static/js/i18n-auth.js +++ b/static/js/i18n-auth.js @@ -106,6 +106,7 @@ const translations = { "navigation.sshWorkspaces": "SSH Workspaces", "navigation.websshWorkspaces": "WebSSH workspaces", "security.accountIdentityUnavailable": "Account identity is unavailable", + "security.addOidcIdentity": "Link another identity", "security.authenticatorCode": "Authenticator code", "security.authenticatorDefaultName": "Authenticator app", "security.authenticatorDeleted": "Authenticator app deleted", @@ -128,6 +129,7 @@ const translations = { "security.confirmWithDirectory": "Confirm with the password you use for directory sign-in.", "security.confirmWithTotp": "Enter a current code from your authenticator app.", "security.connectGithub": "Connect GitHub", + "security.connectOidc": "Connect identity provider", "security.deleteAuthority": "Delete authority", "security.deleteTrust": "Delete trust", "security.directoryPassword": "Directory password", @@ -155,6 +157,9 @@ const translations = { "security.mfaOptional": "MFA optional", "security.noPasskeys": "No passkey is registered.", "security.noTotpAuthenticators": "No authenticator app is enrolled.", + "security.oidcConnectedMany": "{count} OIDC identities linked", + "security.oidcConnectedOne": "1 OIDC identity linked", + "security.oidcNotConnected": "Not connected", "security.passkeyAdded": "Passkey added", "security.passkeyDefaultName": "My passkey", "security.passkeyName": "Passkey name", @@ -269,6 +274,7 @@ const translations = { "navigation.sshWorkspaces": "Không gian làm việc SSH", "navigation.websshWorkspaces": "Không gian làm việc WebSSH", "security.accountIdentityUnavailable": "Không có danh tính tài khoản", + "security.addOidcIdentity": "Liên kết danh tính khác", "security.authenticatorCode": "Mã xác thực", "security.authenticatorDefaultName": "Ứng dụng xác thực", "security.authenticatorDeleted": "Đã xóa ứng dụng xác thực", @@ -291,6 +297,7 @@ const translations = { "security.confirmWithDirectory": "Xác nhận bằng mật khẩu bạn dùng để đăng nhập thư mục.", "security.confirmWithTotp": "Nhập mã hiện tại từ ứng dụng xác thực.", "security.connectGithub": "Kết nối GitHub", + "security.connectOidc": "Kết nối nhà cung cấp danh tính", "security.deleteAuthority": "Xóa tổ chức chứng thực", "security.deleteTrust": "Xóa tin cậy", "security.directoryPassword": "Mật khẩu thư mục", @@ -318,6 +325,9 @@ const translations = { "security.mfaOptional": "MFA tùy chọn", "security.noPasskeys": "Chưa đăng ký passkey nào.", "security.noTotpAuthenticators": "Chưa đăng ký ứng dụng xác thực.", + "security.oidcConnectedMany": "Đã liên kết {count} danh tính OIDC", + "security.oidcConnectedOne": "Đã liên kết 1 danh tính OIDC", + "security.oidcNotConnected": "Chưa kết nối", "security.passkeyAdded": "Đã thêm passkey", "security.passkeyDefaultName": "Passkey của tôi", "security.passkeyName": "Tên passkey", @@ -432,6 +442,7 @@ const translations = { "navigation.sshWorkspaces": "SSH-Workspaces", "navigation.websshWorkspaces": "WebSSH-Arbeitsbereiche", "security.accountIdentityUnavailable": "Kontoidentität ist nicht verfügbar", + "security.addOidcIdentity": "Weitere Identität verknüpfen", "security.authenticatorCode": "Authenticator-Code", "security.authenticatorDefaultName": "Authenticator-App", "security.authenticatorDeleted": "Authenticator-App gelöscht", @@ -454,6 +465,7 @@ const translations = { "security.confirmWithDirectory": "Bestätige mit dem Kennwort, das du für die Verzeichnisanmeldung verwendest.", "security.confirmWithTotp": "Gib einen aktuellen Code aus deiner Authenticator-App ein.", "security.connectGithub": "GitHub verbinden", + "security.connectOidc": "Identitätsanbieter verbinden", "security.deleteAuthority": "Zertifizierungsstelle löschen", "security.deleteTrust": "Vertrauen löschen", "security.directoryPassword": "Verzeichniskennwort", @@ -481,6 +493,9 @@ const translations = { "security.mfaOptional": "MFA optional", "security.noPasskeys": "Kein Passkey registriert.", "security.noTotpAuthenticators": "Keine Authenticator-App eingerichtet.", + "security.oidcConnectedMany": "{count} OIDC-Identitäten verknüpft", + "security.oidcConnectedOne": "1 OIDC-Identität verknüpft", + "security.oidcNotConnected": "Nicht verbunden", "security.passkeyAdded": "Passkey hinzugefügt", "security.passkeyDefaultName": "Mein Passkey", "security.passkeyName": "Passkey-Name", @@ -595,6 +610,7 @@ const translations = { "navigation.sshWorkspaces": "Espaces de travail SSH", "navigation.websshWorkspaces": "Espaces de travail WebSSH", "security.accountIdentityUnavailable": "L’identité du compte est indisponible", + "security.addOidcIdentity": "Associer une autre identité", "security.authenticatorCode": "Code d’authentification", "security.authenticatorDefaultName": "Application d’authentification", "security.authenticatorDeleted": "Application d’authentification supprimée", @@ -617,6 +633,7 @@ const translations = { "security.confirmWithDirectory": "Confirmez avec le mot de passe utilisé pour la connexion à l’annuaire.", "security.confirmWithTotp": "Saisissez un code actuel de votre application d’authentification.", "security.connectGithub": "Connecter GitHub", + "security.connectOidc": "Connecter le fournisseur d’identité", "security.deleteAuthority": "Supprimer l’autorité", "security.deleteTrust": "Supprimer la confiance", "security.directoryPassword": "Mot de passe de l’annuaire", @@ -644,6 +661,9 @@ const translations = { "security.mfaOptional": "AMF facultative", "security.noPasskeys": "Aucune clé d’accès n’est enregistrée.", "security.noTotpAuthenticators": "Aucune application d’authentification n’est configurée.", + "security.oidcConnectedMany": "{count} identités OIDC associées", + "security.oidcConnectedOne": "1 identité OIDC associée", + "security.oidcNotConnected": "Non connecté", "security.passkeyAdded": "Clé d’accès ajoutée", "security.passkeyDefaultName": "Ma clé d’accès", "security.passkeyName": "Nom de la clé d’accès", @@ -758,6 +778,7 @@ const translations = { "navigation.sshWorkspaces": "Espacios de trabajo SSH", "navigation.websshWorkspaces": "Espacios de trabajo de WebSSH", "security.accountIdentityUnavailable": "La identidad de la cuenta no está disponible", + "security.addOidcIdentity": "Vincular otra identidad", "security.authenticatorCode": "Código de autenticación", "security.authenticatorDefaultName": "Aplicación de autenticación", "security.authenticatorDeleted": "Aplicación de autenticación eliminada", @@ -780,6 +801,7 @@ const translations = { "security.confirmWithDirectory": "Confirma con la contraseña que utilizas para iniciar sesión en el directorio.", "security.confirmWithTotp": "Introduce un código actual de tu aplicación de autenticación.", "security.connectGithub": "Conectar GitHub", + "security.connectOidc": "Conectar proveedor de identidad", "security.deleteAuthority": "Eliminar autoridad", "security.deleteTrust": "Eliminar confianza", "security.directoryPassword": "Contraseña del directorio", @@ -807,6 +829,9 @@ const translations = { "security.mfaOptional": "MFA opcional", "security.noPasskeys": "No hay ninguna passkey registrada.", "security.noTotpAuthenticators": "No hay ninguna aplicación de autenticación configurada.", + "security.oidcConnectedMany": "{count} identidades OIDC vinculadas", + "security.oidcConnectedOne": "1 identidad OIDC vinculada", + "security.oidcNotConnected": "Sin conexión", "security.passkeyAdded": "Passkey añadida", "security.passkeyDefaultName": "Mi passkey", "security.passkeyName": "Nombre de la passkey", @@ -921,6 +946,7 @@ const translations = { "navigation.sshWorkspaces": "SSH 工作区", "navigation.websshWorkspaces": "WebSSH 工作区", "security.accountIdentityUnavailable": "账户身份不可用", + "security.addOidcIdentity": "关联另一个身份", "security.authenticatorCode": "身份验证器代码", "security.authenticatorDefaultName": "身份验证器应用", "security.authenticatorDeleted": "身份验证器应用已删除", @@ -943,6 +969,7 @@ const translations = { "security.confirmWithDirectory": "使用目录登录密码进行确认。", "security.confirmWithTotp": "输入身份验证器应用中的当前代码。", "security.connectGithub": "连接 GitHub", + "security.connectOidc": "连接身份提供商", "security.deleteAuthority": "删除证书颁发机构", "security.deleteTrust": "删除信任", "security.directoryPassword": "目录密码", @@ -970,6 +997,9 @@ const translations = { "security.mfaOptional": "MFA 可选", "security.noPasskeys": "尚未注册通行密钥。", "security.noTotpAuthenticators": "尚未配置身份验证器应用。", + "security.oidcConnectedMany": "已关联 {count} 个 OIDC 身份", + "security.oidcConnectedOne": "已关联 1 个 OIDC 身份", + "security.oidcNotConnected": "未连接", "security.passkeyAdded": "通行密钥已添加", "security.passkeyDefaultName": "我的通行密钥", "security.passkeyName": "通行密钥名称", diff --git a/static/js/i18n.js b/static/js/i18n.js index c178a575..5be7f2fb 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -524,6 +524,11 @@ const translations = { 'auth.signInWithGithub': 'Sign in with GitHub', 'security.disconnectGithub': 'Disconnect GitHub', 'security.connectGithub': 'Connect GitHub', + 'security.connectOidc': 'Connect identity provider', + 'security.addOidcIdentity': 'Link another identity', + 'security.oidcConnectedOne': '1 OIDC identity linked', + 'security.oidcConnectedMany': '{count} OIDC identities linked', + 'security.oidcNotConnected': 'Not connected', 'security.githubConnectedAs': 'Connected as {login}', 'security.githubNotConnected': 'Not connected', 'security.disconnectGithubConfirm': 'Disconnect this GitHub identity from your WebSSH account?', @@ -900,6 +905,7 @@ const translations = { 'security.managePasskeysHint': 'Manage passwordless sign-in credentials.', 'security.manageAuthenticatorHint': 'Manage time-based one-time codes.', 'security.manageGithubHint': 'Connect or disconnect the GitHub identity used with this WebSSH account.', + 'security.manageOidcHint': 'Link an identity by signing in with the configured provider. WebSSH stores only its verified issuer and subject.', 'security.manageRecoveryCodesHint': 'Generate and store emergency sign-in codes.', 'security.githubManagedTitle': 'GitHub sign-in', 'security.githubManagedHint': 'GitHub controls this primary sign-in. WebSSH discards the temporary GitHub token after identity and organization checks.', @@ -1768,6 +1774,11 @@ const translations = { 'auth.signInWithGithub': 'Đăng nhập bằng GitHub', 'security.disconnectGithub': 'Ngắt kết nối GitHub', 'security.connectGithub': 'Kết nối GitHub', + 'security.connectOidc': 'Kết nối nhà cung cấp danh tính', + 'security.addOidcIdentity': 'Liên kết danh tính khác', + 'security.oidcConnectedOne': 'Đã liên kết 1 danh tính OIDC', + 'security.oidcConnectedMany': 'Đã liên kết {count} danh tính OIDC', + 'security.oidcNotConnected': 'Chưa kết nối', 'security.githubConnectedAs': 'Đã kết nối dưới tên {login}', 'security.githubNotConnected': 'Chưa kết nối', 'security.disconnectGithubConfirm': 'Ngắt liên kết danh tính GitHub này khỏi tài khoản WebSSH?', @@ -2181,6 +2192,7 @@ const translations = { 'security.managePasskeysHint': 'Quản lý thông tin đăng nhập không mật khẩu.', 'security.manageAuthenticatorHint': 'Quản lý mã một lần dựa trên thời gian.', 'security.manageGithubHint': 'Kết nối hoặc ngắt kết nối danh tính GitHub dùng với tài khoản WebSSH này.', + 'security.manageOidcHint': 'Liên kết danh tính bằng cách đăng nhập với nhà cung cấp đã cấu hình. WebSSH chỉ lưu nhà phát hành và chủ thể đã được xác minh.', 'security.manageRecoveryCodesHint': 'Tạo và lưu mã đăng nhập khẩn cấp.', 'security.githubManagedTitle': 'Đăng nhập GitHub', 'security.githubManagedHint': 'GitHub kiểm soát đăng nhập chính này. WebSSH hủy token GitHub tạm thời sau khi kiểm tra danh tính và tổ chức.', @@ -3085,6 +3097,11 @@ const translations = { 'auth.signInWithGithub': 'Mit GitHub anmelden', 'security.disconnectGithub': 'GitHub trennen', 'security.connectGithub': 'GitHub verbinden', + 'security.connectOidc': 'Identitätsanbieter verbinden', + 'security.addOidcIdentity': 'Weitere Identität verknüpfen', + 'security.oidcConnectedOne': '1 OIDC-Identität verknüpft', + 'security.oidcConnectedMany': '{count} OIDC-Identitäten verknüpft', + 'security.oidcNotConnected': 'Nicht verbunden', 'security.githubConnectedAs': 'Verbunden als {login}', 'security.githubNotConnected': 'Nicht verbunden', 'security.disconnectGithubConfirm': 'Diese GitHub-Identität vom WebSSH-Konto trennen?', @@ -3479,6 +3496,7 @@ const translations = { 'security.managePasskeysHint': 'Anmeldedaten für die passwortlose Anmeldung verwalten.', 'security.manageAuthenticatorHint': 'Zeitbasierte Einmalcodes verwalten.', 'security.manageGithubHint': 'Die mit diesem WebSSH-Konto verwendete GitHub-Identität verknüpfen oder trennen.', + 'security.manageOidcHint': 'Verknüpfe eine Identität durch Anmeldung beim konfigurierten Anbieter. WebSSH speichert nur den verifizierten Aussteller und Betreff.', 'security.manageRecoveryCodesHint': 'Notfall-Anmeldecodes erzeugen und sicher aufbewahren.', 'security.githubManagedTitle': 'GitHub-Anmeldung', 'security.githubManagedHint': 'GitHub steuert diese primäre Anmeldung. WebSSH verwirft das temporäre GitHub-Token nach der Identitäts- und Organisationsprüfung.', @@ -4327,6 +4345,11 @@ const translations = { 'auth.signInWithGithub': 'Se connecter avec GitHub', 'security.disconnectGithub': 'Déconnecter GitHub', 'security.connectGithub': 'Connecter GitHub', + 'security.connectOidc': "Connecter le fournisseur d’identité", + 'security.addOidcIdentity': 'Associer une autre identité', + 'security.oidcConnectedOne': '1 identité OIDC associée', + 'security.oidcConnectedMany': '{count} identités OIDC associées', + 'security.oidcNotConnected': 'Non connecté', 'security.githubConnectedAs': 'Connecté en tant que {login}', 'security.githubNotConnected': 'Non connecté', 'security.disconnectGithubConfirm': 'Dissocier cette identité GitHub de votre compte WebSSH ?', @@ -4767,6 +4790,7 @@ const translations = { 'security.managePasskeysHint': 'Gérez les identifiants de connexion sans mot de passe.', 'security.manageAuthenticatorHint': 'Gérez les codes à usage unique basés sur le temps.', 'security.manageGithubHint': 'Connectez ou déconnectez l’identité GitHub utilisée avec ce compte WebSSH.', + 'security.manageOidcHint': 'Associez une identité en vous connectant au fournisseur configuré. WebSSH ne conserve que son émetteur et son sujet vérifiés.', 'security.manageRecoveryCodesHint': 'Générez et conservez les codes de connexion d’urgence.', 'security.githubManagedTitle': 'Connexion GitHub', 'security.githubManagedHint': 'GitHub contrôle cette connexion principale. WebSSH supprime le jeton GitHub temporaire après les vérifications d’identité et d’organisation.', @@ -5606,6 +5630,11 @@ const translations = { 'auth.signInWithGithub': 'Iniciar sesión con GitHub', 'security.disconnectGithub': 'Desconectar GitHub', 'security.connectGithub': 'Conectar GitHub', + 'security.connectOidc': 'Conectar proveedor de identidad', + 'security.addOidcIdentity': 'Vincular otra identidad', + 'security.oidcConnectedOne': '1 identidad OIDC vinculada', + 'security.oidcConnectedMany': '{count} identidades OIDC vinculadas', + 'security.oidcNotConnected': 'Sin conexión', 'security.githubConnectedAs': 'Conectado como {login}', 'security.githubNotConnected': 'No conectado', 'security.disconnectGithubConfirm': '¿Desvincular esta identidad de GitHub de tu cuenta WebSSH?', @@ -6046,6 +6075,7 @@ const translations = { 'security.managePasskeysHint': 'Gestiona las credenciales de inicio de sesión sin contraseña.', 'security.manageAuthenticatorHint': 'Gestiona los códigos de un solo uso basados en tiempo.', 'security.manageGithubHint': 'Conecta o desconecta la identidad de GitHub usada con esta cuenta de WebSSH.', + 'security.manageOidcHint': 'Vincula una identidad iniciando sesión con el proveedor configurado. WebSSH solo guarda el emisor y el sujeto verificados.', 'security.manageRecoveryCodesHint': 'Genera y guarda códigos de inicio de sesión de emergencia.', 'security.githubManagedTitle': 'Inicio de sesión con GitHub', 'security.githubManagedHint': 'GitHub controla este inicio de sesión principal. WebSSH descarta el token temporal de GitHub tras comprobar la identidad y la organización.', @@ -6885,6 +6915,11 @@ const translations = { 'auth.signInWithGithub': '使用 GitHub 登录', 'security.disconnectGithub': '断开 GitHub', 'security.connectGithub': '连接 GitHub', + 'security.connectOidc': '连接身份提供商', + 'security.addOidcIdentity': '关联另一个身份', + 'security.oidcConnectedOne': '已关联 1 个 OIDC 身份', + 'security.oidcConnectedMany': '已关联 {count} 个 OIDC 身份', + 'security.oidcNotConnected': '未连接', 'security.githubConnectedAs': '已连接为 {login}', 'security.githubNotConnected': '未连接', 'security.disconnectGithubConfirm': '要从 WebSSH 账户解除此 GitHub 身份吗?', @@ -7316,6 +7351,7 @@ const translations = { 'security.managePasskeysHint': '管理无密码登录凭据。', 'security.manageAuthenticatorHint': '管理基于时间的一次性验证码。', 'security.manageGithubHint': '关联或断开此 WebSSH 账户使用的 GitHub 身份。', + 'security.manageOidcHint': '通过登录已配置的提供商来关联身份。WebSSH 仅存储已验证的颁发者和主体。', 'security.manageRecoveryCodesHint': '生成并保存紧急登录代码。', 'security.githubManagedTitle': 'GitHub 登录', 'security.githubManagedHint': 'GitHub 控制此主要登录。WebSSH 在完成身份和组织检查后会丢弃临时 GitHub 令牌。', diff --git a/static/js/webauthn.js b/static/js/webauthn.js index fb238a5b..1c842aeb 100644 --- a/static/js/webauthn.js +++ b/static/js/webauthn.js @@ -720,6 +720,70 @@ window.location.assign(started.authorization_url); } + function renderOidcIdentity() { + const button = document.getElementById('oidcIdentityAction'); + const status = document.getElementById('oidcIdentityStatus'); + if (!button || !status) { return; } + const identityCount = Number.parseInt( + button.dataset.identityCount || '0', 10 + ); + const actionLabel = button.querySelector('.oidc-identity-action-label'); + const action = identityCount > 0 + ? t('security.addOidcIdentity', 'Link another identity') + : t('security.connectOidc', 'Connect identity provider'); + if (actionLabel) { + actionLabel.textContent = action; + } else { + button.textContent = action; + } + if (identityCount === 1) { + status.textContent = t( + 'security.oidcConnectedOne', + '1 OIDC identity linked' + ); + } else if (identityCount > 1) { + status.textContent = t( + 'security.oidcConnectedMany', + '{count} OIDC identities linked' + ).replace('{count}', String(identityCount)); + } else { + status.textContent = t( + 'security.oidcNotConnected', + 'Not connected' + ); + } + } + + async function loadOidcIdentity() { + const button = document.getElementById('oidcIdentityAction'); + const status = document.getElementById('oidcIdentityStatus'); + if (!button || !status) { return; } + const data = await api('/api/account/oidc'); + button.dataset.identityCount = String(data.identities?.length || 0); + renderOidcIdentity(); + } + + async function linkOidcIdentity() { + const button = document.getElementById('oidcIdentityAction'); + if (!button) { return; } + const userId = Number.parseInt( + document.querySelector('meta[name="current-user-id"]')?.content || '', + 10 + ); + if (!Number.isInteger(userId)) { + throw new Error(t( + 'security.accountIdentityUnavailable', + 'Account identity is unavailable' + )); + } + const headers = await stepUpHeaders('oidc.self_link', userId); + if (headers === null) { return; } + const started = await api('/api/account/oidc/link/start', { + method: 'POST', headers, body: {} + }); + window.location.assign(started.authorization_url); + } + document.addEventListener('DOMContentLoaded', () => { document.getElementById('securityConfirmationForm')?.addEventListener('submit', event => { event.preventDefault(); @@ -744,6 +808,11 @@ }); window.addEventListener('languageChanged', renderGitHubIdentity); loadGitHubIdentity().catch(error => notify(error.message, 'error')); + document.getElementById('oidcIdentityAction')?.addEventListener('click', () => { + linkOidcIdentity().catch(error => notify(error.message, 'error')); + }); + window.addEventListener('languageChanged', renderOidcIdentity); + loadOidcIdentity().catch(error => notify(error.message, 'error')); document.getElementById('recoveryGenerateBtn')?.addEventListener('click', async () => { try { const headers = await stepUpHeaders('recovery.rotate'); diff --git a/templates/security.html b/templates/security.html index abbb26b9..4f0968ed 100644 --- a/templates/security.html +++ b/templates/security.html @@ -214,6 +214,12 @@

H {% endif %} + {% if oidc_enabled and not ldap_managed and not github_managed and not recovery_mode %} +
+
Identity provider

Link an identity by signing in with the configured provider. WebSSH stores only its verified issuer and subject.

Loading linked account…

+ +
+ {% endif %} {% if recovery_codes_enabled %}
Recovery codes

Generate and store emergency sign-in codes.

diff --git a/tests/test_database_init.py b/tests/test_database_init.py index eeb0894d..54920edf 100644 --- a/tests/test_database_init.py +++ b/tests/test_database_init.py @@ -90,4 +90,7 @@ def test_legacy_oidc_state_adds_assurance_intent_columns_idempotently(app): 'step_up_action', 'step_up_target_hash', 'step_up_intent_id', + 'user_id', + 'auth_generation', + 'authentication_session_id', } <= columns diff --git a/tests/test_i18n_parity.py b/tests/test_i18n_parity.py index f3cbf2ac..09298e2a 100644 --- a/tests/test_i18n_parity.py +++ b/tests/test_i18n_parity.py @@ -307,6 +307,7 @@ def test_recent_settings_and_github_surfaces_are_runtime_localized(): 'security.passkeysAndMfa', 'security.githubManagedHint', 'security.manageGithubHint', + 'security.manageOidcHint', ): assert re.search( rf'data-i18n(?:-placeholder|-title|-label|-aria-label|-alt)?="{re.escape(key)}"', diff --git a/tests/test_oidc_routes.py b/tests/test_oidc_routes.py index 506bb0b0..e5027114 100644 --- a/tests/test_oidc_routes.py +++ b/tests/test_oidc_routes.py @@ -1,8 +1,12 @@ """Feature flag, linking, and local-login resilience for OIDC.""" import logging +from urllib.parse import parse_qs, urlsplit -from tests.step_up_helpers import password_step_up_headers +from tests.step_up_helpers import ( + account_password_step_up_headers, + password_step_up_headers, +) def _create_user(app, username, *, is_admin=False): @@ -29,6 +33,17 @@ def _step_up(client, action, target): return password_step_up_headers(client, action, target)[0] +def _authentication_context(app, user_id): + from app.models import AuthenticationSession, User, db + + with app.app_context(): + user = db.session.get(User, user_id) + auth_session = AuthenticationSession.query.filter_by( + user_id=user_id + ).one() + return int(user.auth_generation or 0), auth_session.id + + def _prepare_oidc_callback(app, client, user_id, *, state, subject): from app.models import OIDCIdentity, db from app.oidc_service import create_login_state @@ -71,15 +86,399 @@ def test_oidc_routes_are_hidden_when_disabled_but_local_login_works( _create_user(app, "local_admin", is_admin=True) oidc = client.get("/oidc/login") + oidc_self_link = client.post("/api/account/oidc/link/start", json={}) local = client.post( "/login", data={"username": "local_admin", "password": "password123"}, ) assert oidc.status_code == 404 + assert oidc_self_link.status_code == 404 assert local.status_code == 302 +def test_authenticated_user_can_link_verified_oidc_identity_after_step_up( + app, client, monkeypatch +): + from flask import redirect + import config + import app.oidc_routes as oidc_routes + from app.models import OIDCIdentity + + user_id = _create_user(app, "oidc_self_link_user") + _login(client, "oidc_self_link_user") + monkeypatch.setattr(config, "OIDC_ENABLED", True) + monkeypatch.setattr(config, "OIDC_ISSUER", "https://issuer.example") + monkeypatch.setattr(config, "OIDC_ALLOWED_SUBJECTS", set()) + monkeypatch.setattr(config, "OIDC_ALLOWED_DOMAINS", set()) + authorization = {} + + class Provider: + def authorize_redirect(self, callback, **values): + assert callback == "https://localhost/oidc/callback" + authorization.update(values) + return redirect( + "https://issuer.example/authorize?state=" + values["state"] + ) + + def authorize_access_token(self, *, code_verifier): + assert len(code_verifier) >= 43 + return {"id_token": "validated-id-token"} + + def parse_id_token(self, token, *, nonce): + assert token["id_token"] == "validated-id-token" + assert nonce == authorization["nonce"] + return { + "iss": "https://issuer.example", + "sub": "provider-verified-subject", + } + + monkeypatch.setattr(oidc_routes, "_client", lambda: Provider()) + + denied = client.post("/api/account/oidc/link/start", json={}) + headers = account_password_step_up_headers( + client, "oidc.self_link", user_id + )[0] + started = client.post( + "/api/account/oidc/link/start", json={}, headers=headers + ) + state = parse_qs(urlsplit( + started.get_json()["authorization_url"] + ).query)["state"][0] + callback = client.get(f"/oidc/callback?code=code&state={state}") + status = client.get("/api/account/oidc") + + assert denied.status_code == 403 + assert started.status_code == 200 + assert authorization["prompt"] == "login" + assert "max_age" not in authorization + assert callback.status_code == 302 + assert callback.headers["Location"].endswith("/security") + assert status.status_code == 200 + assert status.get_json()["identities"][0]["issuer"] == ( + "https://issuer.example" + ) + assert "subject" not in status.get_json()["identities"][0] + with app.app_context(): + row = OIDCIdentity.query.filter_by(user_id=user_id).one() + assert row.subject == "provider-verified-subject" + + +def test_oidc_self_link_rejects_an_unsafe_authorization_location( + app, client, monkeypatch +): + from flask import redirect + import config + import app.oidc_routes as oidc_routes + from app.models import OIDCLoginState + + user_id = _create_user(app, "oidc_unsafe_redirect_user") + _login(client, "oidc_unsafe_redirect_user") + monkeypatch.setattr(config, "OIDC_ENABLED", True) + + class UnsafeProvider: + def authorize_redirect(self, _callback, **_values): + return redirect("javascript:alert(document.domain)") + + monkeypatch.setattr(oidc_routes, "_client", lambda: UnsafeProvider()) + headers = account_password_step_up_headers( + client, "oidc.self_link", user_id + )[0] + + response = client.post( + "/api/account/oidc/link/start", json={}, headers=headers + ) + + assert response.status_code == 503 + assert response.get_json()["error"] == "Identity provider unavailable" + with app.app_context(): + assert OIDCLoginState.query.filter_by(user_id=user_id).count() == 0 + + +def test_oidc_self_link_stops_if_account_authentication_is_invalidated( + app, client, monkeypatch +): + from flask import redirect + import config + import app.oidc_routes as oidc_routes + from app.auth_assurance import invalidate_user_authentication + from app.models import OIDCIdentity, User, db + + user_id = _create_user(app, "oidc_revoked_during_link") + _login(client, "oidc_revoked_during_link") + monkeypatch.setattr(config, "OIDC_ENABLED", True) + monkeypatch.setattr(config, "OIDC_ISSUER", "https://issuer.example") + monkeypatch.setattr(config, "OIDC_ALLOWED_SUBJECTS", set()) + monkeypatch.setattr(config, "OIDC_ALLOWED_DOMAINS", set()) + authorization = {} + + class RevokingProvider: + def authorize_redirect(self, _callback, **values): + authorization.update(values) + return redirect( + "https://issuer.example/authorize?state=" + values["state"] + ) + + def authorize_access_token(self, **_kwargs): + target = db.session.get(User, user_id) + invalidate_user_authentication(target) + db.session.commit() + return {"id_token": "validated-before-revocation"} + + def parse_id_token(self, token, *, nonce): + assert token["id_token"] == "validated-before-revocation" + assert nonce == authorization["nonce"] + return { + "iss": "https://issuer.example", + "sub": "revoked-flow-subject", + } + + monkeypatch.setattr( + oidc_routes, "_client", lambda: RevokingProvider() + ) + headers = account_password_step_up_headers( + client, "oidc.self_link", user_id + )[0] + started = client.post( + "/api/account/oidc/link/start", json={}, headers=headers + ) + state = parse_qs(urlsplit( + started.get_json()["authorization_url"] + ).query)["state"][0] + + response = client.get(f"/oidc/callback?code=code&state={state}") + + assert response.status_code == 403 + assert response.get_json()["error"] == "OIDC identity linking failed" + with app.app_context(): + assert OIDCIdentity.query.filter_by(user_id=user_id).count() == 0 + + +def test_oidc_self_link_callback_rejects_account_switch_before_provider( + app, client, monkeypatch +): + import config + import app.oidc_routes as oidc_routes + from app.models import db + from app.oidc_service import create_login_state + + original_user_id = _create_user(app, "oidc_link_original") + _create_user(app, "oidc_link_switched") + _login(client, "oidc_link_original") + auth_generation, auth_session_id = _authentication_context( + app, original_user_id + ) + monkeypatch.setattr(config, "OIDC_ENABLED", True) + binding = "account-switch-binding" + with app.app_context(): + create_login_state( + state="account-switch-state", + nonce="account-switch-nonce", + session_binding=binding, + code_verifier="account-switch-verifier", + purpose="link", + user_id=original_user_id, + auth_generation=auth_generation, + authentication_session_id=auth_session_id, + continuation="/security", + ) + db.session.commit() + assert client.post("/logout").status_code == 302 + _login(client, "oidc_link_switched") + with client.session_transaction() as browser_session: + browser_session["oidc_binding"] = binding + + def provider_must_not_run(): + raise AssertionError("provider must not be contacted") + + monkeypatch.setattr(oidc_routes, "_client", provider_must_not_run) + + response = client.get("/oidc/callback?state=account-switch-state") + + assert response.status_code == 403 + assert response.get_json()["error"] == "OIDC identity linking failed" + + +def test_oidc_self_link_never_moves_an_identity_between_accounts( + app, client, monkeypatch +): + import config + import app.oidc_routes as oidc_routes + from app.models import OIDCIdentity, db + from app.oidc_service import create_login_state + + owner_id = _create_user(app, "oidc_subject_owner") + target_id = _create_user(app, "oidc_subject_target") + _login(client, "oidc_subject_target") + monkeypatch.setattr(config, "OIDC_ENABLED", True) + monkeypatch.setattr(config, "OIDC_ISSUER", "https://issuer.example") + monkeypatch.setattr(config, "OIDC_ALLOWED_SUBJECTS", set()) + monkeypatch.setattr(config, "OIDC_ALLOWED_DOMAINS", set()) + binding = "identity-collision-binding" + auth_generation, auth_session_id = _authentication_context( + app, target_id + ) + with app.app_context(): + db.session.add(OIDCIdentity( + user_id=owner_id, + issuer="https://issuer.example", + subject="already-owned-subject", + )) + create_login_state( + state="identity-collision-state", + nonce="nonce-identity-collision", + session_binding=binding, + code_verifier="verifier-identity-collision", + purpose="link", + user_id=target_id, + auth_generation=auth_generation, + authentication_session_id=auth_session_id, + continuation="/security", + ) + db.session.commit() + with client.session_transaction() as browser_session: + browser_session["oidc_binding"] = binding + monkeypatch.setattr( + oidc_routes, + "_client", + lambda: _signed_provider("identity-collision", { + "iss": "https://issuer.example", + "sub": "already-owned-subject", + }), + ) + + response = client.get( + "/oidc/callback?state=identity-collision-state" + ) + + assert response.status_code == 409 + with app.app_context(): + row = OIDCIdentity.query.filter_by( + issuer="https://issuer.example", + subject="already-owned-subject", + ).one() + assert row.user_id == owner_id + assert OIDCIdentity.query.filter_by(user_id=target_id).count() == 0 + + +def test_unlinked_oidc_login_has_a_distinct_operator_reason( + app, client, monkeypatch, caplog +): + import config + import app.oidc_routes as oidc_routes + from app.models import db + from app.oidc_service import create_login_state + + monkeypatch.setattr(config, "OIDC_ENABLED", True) + monkeypatch.setattr(config, "OIDC_ISSUER", "https://issuer.example") + monkeypatch.setattr(config, "OIDC_ALLOWED_SUBJECTS", set()) + monkeypatch.setattr(config, "OIDC_ALLOWED_DOMAINS", set()) + with app.app_context(): + create_login_state( + state="unlinked-audit-state", + nonce="nonce-unlinked-audit", + session_binding="unlinked-audit-binding", + code_verifier="verifier-unlinked-audit", + ) + db.session.commit() + with client.session_transaction() as browser_session: + browser_session["oidc_binding"] = "unlinked-audit-binding" + monkeypatch.setattr( + oidc_routes, + "_client", + lambda: _signed_provider("unlinked-audit", { + "iss": "https://issuer.example", + "sub": "must-not-appear-in-the-audit-log", + }), + ) + + with caplog.at_level(logging.WARNING, logger="security_audit"): + response = client.get("/oidc/callback?state=unlinked-audit-state") + + messages = [record.getMessage() for record in caplog.records] + assert response.status_code == 403 + assert any( + message.startswith("OIDC_IDENTITY_REJECTED") + and "reason=unlinked" in message + for message in messages + ) + assert all( + "must-not-appear-in-the-audit-log" not in message + for message in messages + ) + + +def test_oidc_login_audits_locked_and_external_accounts_separately( + app, client, monkeypatch, caplog +): + import config + import app.oidc_routes as oidc_routes + from app.models import LDAPIdentity, User, db + + monkeypatch.setattr(config, "OIDC_ENABLED", True) + monkeypatch.setattr(config, "OIDC_ISSUER", "https://issuer.example") + monkeypatch.setattr(config, "OIDC_ALLOWED_SUBJECTS", set()) + monkeypatch.setattr(config, "OIDC_ALLOWED_DOMAINS", set()) + + locked_id = _create_user(app, "oidc_locked_identity") + _prepare_oidc_callback( + app, + client, + locked_id, + state="locked-reason", + subject="locked-subject", + ) + with app.app_context(): + db.session.get(User, locked_id).is_locked = True + db.session.commit() + monkeypatch.setattr( + oidc_routes, + "_client", + lambda: _signed_provider("locked-reason", { + "iss": "https://issuer.example", + "sub": "locked-subject", + }), + ) + with caplog.at_level(logging.WARNING, logger="security_audit"): + locked = client.get("/oidc/callback?state=locked-reason") + + external_id = _create_user(app, "oidc_external_identity") + _prepare_oidc_callback( + app, + client, + external_id, + state="external-reason", + subject="external-subject", + ) + with app.app_context(): + db.session.add(LDAPIdentity( + user_id=external_id, + provider="default", + subject="directory-subject", + directory_username="oidc_external_identity", + distinguished_name=( + "uid=oidc_external_identity,dc=example,dc=com" + ), + )) + db.session.commit() + monkeypatch.setattr( + oidc_routes, + "_client", + lambda: _signed_provider("external-reason", { + "iss": "https://issuer.example", + "sub": "external-subject", + }), + ) + with caplog.at_level(logging.WARNING, logger="security_audit"): + external = client.get("/oidc/callback?state=external-reason") + + messages = [record.getMessage() for record in caplog.records] + assert locked.status_code == 403 + assert external.status_code == 403 + assert any("reason=account_locked" in message for message in messages) + assert any("reason=externally_managed" in message for message in messages) + + def test_admin_link_requires_password_confirmation_and_stable_subject( app, client, monkeypatch ): diff --git a/tests/test_oidc_service.py b/tests/test_oidc_service.py index 26bd4167..1be81700 100644 --- a/tests/test_oidc_service.py +++ b/tests/test_oidc_service.py @@ -268,3 +268,94 @@ def test_oidc_account_step_up_state_binds_only_persistent_intent_id(app): assert intent.step_up_intent_id == 42 assert intent.step_up_action is None assert intent.step_up_target_hash is None + + +def test_oidc_link_state_is_bound_to_one_local_account(app): + from app.oidc_service import consume_login_state, create_login_state + + with app.app_context(): + create_login_state( + state="link-state-token", + nonce="link-nonce-token", + session_binding="link-browser-binding", + code_verifier="link-pkce-verifier", + purpose="link", + user_id=42, + auth_generation=3, + authentication_session_id=99, + continuation="/security", + ) + + intent = consume_login_state( + state="link-state-token", + session_binding="link-browser-binding", + ) + + assert intent.purpose == "link" + assert intent.user_id == 42 + assert intent.auth_generation == 3 + assert intent.authentication_session_id == 99 + assert intent.continuation == "/security" + assert intent.requested_acr is None + assert intent.step_up_action is None + assert intent.step_up_target_hash is None + assert intent.step_up_intent_id is None + + +@pytest.mark.parametrize("values", [ + {"purpose": "link"}, + {"purpose": "link", "user_id": 0}, + {"purpose": "link", "user_id": True}, + { + "purpose": "link", + "user_id": 7, + "auth_generation": 0, + "authentication_session_id": 1, + "requested_acr": "aal2", + }, + {"purpose": "login", "user_id": 7}, + {"purpose": "login", "auth_generation": 0}, + {"purpose": "login", "authentication_session_id": 1}, + { + "purpose": "step_up", + "user_id": 7, + "step_up_action": "user.lock", + "step_up_target_hash": "a" * 64, + }, +]) +def test_oidc_state_rejects_mixed_link_context(app, values): + from app.oidc_service import OIDCStateError, create_login_state + + with app.app_context(), pytest.raises(OIDCStateError): + create_login_state( + state="invalid-link-state", + nonce="invalid-link-nonce", + session_binding="invalid-link-binding", + code_verifier="invalid-link-verifier", + **values, + ) + + +def test_authentication_invalidation_removes_pending_oidc_link_state(app): + from app.auth_assurance import invalidate_user_authentication + from app.models import OIDCLoginState, User, db + from app.oidc_service import create_login_state + + user_id = _create_user(app, "pending_oidc_link_user") + with app.app_context(): + create_login_state( + state="pending-link-state", + nonce="pending-link-nonce", + session_binding="pending-link-binding", + code_verifier="pending-link-verifier", + purpose="link", + user_id=user_id, + auth_generation=0, + authentication_session_id=1, + continuation="/security", + ) + user = db.session.get(User, user_id) + invalidate_user_authentication(user) + db.session.commit() + + assert OIDCLoginState.query.filter_by(user_id=user_id).count() == 0 diff --git a/tests/test_security_features.py b/tests/test_security_features.py index 9a7fba2f..7d3dc2b4 100644 --- a/tests/test_security_features.py +++ b/tests/test_security_features.py @@ -206,8 +206,10 @@ def test_admin_disabled_oidc_blocks_new_provider_login(app, client, monkeypatch) db.session.commit() response = client.get('/oidc/login') + self_link = client.post('/api/account/oidc/link/start', json={}) assert response.status_code == 404 + assert self_link.status_code == 404 def test_admin_disabled_oidc_is_not_advertised_in_templates( diff --git a/tests/test_security_ui.py b/tests/test_security_ui.py index 7bfd2b56..dd82d29f 100644 --- a/tests/test_security_ui.py +++ b/tests/test_security_ui.py @@ -152,6 +152,27 @@ def test_linked_github_identity_is_presented_as_a_security_method(app, client): assert b'class="github-identity-action-label"' in response.data +def test_active_oidc_is_presented_as_a_verified_self_link_method( + app, client, monkeypatch +): + import config + + _create_user(app, "oidc_security_user") + _login(client, "oidc_security_user") + monkeypatch.setattr(config, "OIDC_ENABLED", True) + + response = client.get("/settings") + + assert response.status_code == 200 + methods = response.data.index(b'id="settingsSecurityMethodsTitle"') + oidc = response.data.index(b'id="oidc"') + assert methods < oidc + assert b'id="oidcIdentityStatus"' in response.data + assert b'id="oidcIdentityAction"' in response.data + assert b'data-i18n="security.manageOidcHint"' in response.data + assert b'data-i18n="security.connectOidc"' in response.data + + def test_standard_user_settings_do_not_expose_administration_navigation( app, client ): From d0fc671480689e60d2d50f2e7ed4bd7a7091a81f Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Tue, 8 Sep 2026 00:37:10 +0200 Subject: [PATCH 2/4] Preserve application root after OIDC linking --- app/oidc_routes.py | 3 ++- tests/test_oidc_routes.py | 32 ++++++++++++++++++++++++++------ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/app/oidc_routes.py b/app/oidc_routes.py index 91705407..e630a1e5 100644 --- a/app/oidc_routes.py +++ b/app/oidc_routes.py @@ -17,6 +17,7 @@ render_template, request, session, + url_for, ) from flask_login import current_user, login_required from sqlalchemy import insert, literal, select @@ -413,7 +414,7 @@ def oidc_self_link_start(): user_id=target.id, auth_generation=int(target.auth_generation or 0), authentication_session_id=auth_session.id, - continuation="/security", + continuation=url_for("security_center"), return_authorization_url=True, ) diff --git a/tests/test_oidc_routes.py b/tests/test_oidc_routes.py index e5027114..9432ca4a 100644 --- a/tests/test_oidc_routes.py +++ b/tests/test_oidc_routes.py @@ -3,6 +3,8 @@ import logging from urllib.parse import parse_qs, urlsplit +import pytest + from tests.step_up_helpers import ( account_password_step_up_headers, password_step_up_headers, @@ -97,8 +99,12 @@ def test_oidc_routes_are_hidden_when_disabled_but_local_login_works( assert local.status_code == 302 +@pytest.mark.parametrize(("script_name", "expected_location"), ( + ("", "/settings"), + ("/webssh", "/webssh/settings"), +)) def test_authenticated_user_can_link_verified_oidc_identity_after_step_up( - app, client, monkeypatch + app, client, monkeypatch, script_name, expected_location ): from flask import redirect import config @@ -134,26 +140,40 @@ def parse_id_token(self, token, *, nonce): } monkeypatch.setattr(oidc_routes, "_client", lambda: Provider()) + request_environment = {"SCRIPT_NAME": script_name} - denied = client.post("/api/account/oidc/link/start", json={}) + denied = client.post( + "/api/account/oidc/link/start", + json={}, + environ_overrides=request_environment, + ) headers = account_password_step_up_headers( client, "oidc.self_link", user_id )[0] started = client.post( - "/api/account/oidc/link/start", json={}, headers=headers + "/api/account/oidc/link/start", + json={}, + headers=headers, + environ_overrides=request_environment, ) state = parse_qs(urlsplit( started.get_json()["authorization_url"] ).query)["state"][0] - callback = client.get(f"/oidc/callback?code=code&state={state}") - status = client.get("/api/account/oidc") + callback = client.get( + f"/oidc/callback?code=code&state={state}", + environ_overrides=request_environment, + ) + status = client.get( + "/api/account/oidc", + environ_overrides=request_environment, + ) assert denied.status_code == 403 assert started.status_code == 200 assert authorization["prompt"] == "login" assert "max_age" not in authorization assert callback.status_code == 302 - assert callback.headers["Location"].endswith("/security") + assert callback.headers["Location"] == expected_location assert status.status_code == 200 assert status.get_json()["identities"][0]["issuer"] == ( "https://issuer.example" From e0ce125af4dcd0e485fc44aa24d92415c87c45ec Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Tue, 8 Sep 2026 00:50:54 +0200 Subject: [PATCH 3/4] Cover OIDC self-link security boundaries --- tests/e2e/admin-oidc.spec.js | 16 +++ tests/test_oidc_routes.py | 200 ++++++++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 3 deletions(-) diff --git a/tests/e2e/admin-oidc.spec.js b/tests/e2e/admin-oidc.spec.js index 10c06877..f40ade34 100644 --- a/tests/e2e/admin-oidc.spec.js +++ b/tests/e2e/admin-oidc.spec.js @@ -41,6 +41,22 @@ test('admin can inspect, unlink, and add an OIDC identity', async ({ page }) => await expect(page.locator('#securityActionConfirmation')).toHaveValue(''); }); +test('linked users see the safe OIDC self-link status and action', async ({ page }) => { + await login(page, 'e2e_user'); + await page.goto('/settings'); + + const status = page.locator('#oidcIdentityStatus'); + const action = page.locator('#oidcIdentityAction'); + await expect(status).toHaveText('1 OIDC identity linked'); + await expect(action).toContainText('Link another identity'); + await expect(page.getByText('existing-e2e-subject')).toHaveCount(0); + + await action.click(); + const confirmation = page.locator('#securityConfirmationModal'); + await expect(confirmation).toHaveClass(/show/); + await expect(confirmation).toHaveAttribute('aria-hidden', 'false'); +}); + test('admin navigation and users become readable cards on a mobile viewport', async ({ page }) => { await page.setViewportSize({ width: 360, height: 800 }); await login(page); diff --git a/tests/test_oidc_routes.py b/tests/test_oidc_routes.py index 9432ca4a..b934d012 100644 --- a/tests/test_oidc_routes.py +++ b/tests/test_oidc_routes.py @@ -46,6 +46,36 @@ def _authentication_context(app, user_id): return int(user.auth_generation or 0), auth_session.id +def _prepare_oidc_self_link_callback( + app, + client, + user_id, + *, + state, + continuation="/settings", +): + from app.models import db + from app.oidc_service import create_login_state + + auth_generation, auth_session_id = _authentication_context(app, user_id) + binding = f"binding-{state}" + with app.app_context(): + create_login_state( + state=state, + nonce=f"nonce-{state}", + session_binding=binding, + code_verifier=f"verifier-{state}", + purpose="link", + user_id=user_id, + auth_generation=auth_generation, + authentication_session_id=auth_session_id, + continuation=continuation, + ) + db.session.commit() + with client.session_transaction() as browser_session: + browser_session["oidc_binding"] = binding + + def _prepare_oidc_callback(app, client, user_id, *, state, subject): from app.models import OIDCIdentity, db from app.oidc_service import create_login_state @@ -184,8 +214,16 @@ def parse_id_token(self, token, *, nonce): assert row.subject == "provider-verified-subject" +@pytest.mark.parametrize("authorization_location", ( + "javascript:alert(document.domain)", + "http://issuer.example/authorize?state={state}", + "https://user@issuer.example/authorize?state={state}", + "https://issuer.example/authorize?state={state}#fragment", + "https://issuer.example/authorize?state={state}&state=duplicate", + "https://issuer.example/authorize", +)) def test_oidc_self_link_rejects_an_unsafe_authorization_location( - app, client, monkeypatch + app, client, monkeypatch, authorization_location ): from flask import redirect import config @@ -197,8 +235,10 @@ def test_oidc_self_link_rejects_an_unsafe_authorization_location( monkeypatch.setattr(config, "OIDC_ENABLED", True) class UnsafeProvider: - def authorize_redirect(self, _callback, **_values): - return redirect("javascript:alert(document.domain)") + def authorize_redirect(self, _callback, **values): + return redirect(authorization_location.format( + state=values["state"] + )) monkeypatch.setattr(oidc_routes, "_client", lambda: UnsafeProvider()) headers = account_password_step_up_headers( @@ -215,6 +255,160 @@ def authorize_redirect(self, _callback, **_values): assert OIDCLoginState.query.filter_by(user_id=user_id).count() == 0 +def test_oidc_self_link_rejects_mismatched_subject_sources( + app, client, monkeypatch +): + import config + import app.oidc_routes as oidc_routes + from app.models import OIDCIdentity + + user_id = _create_user(app, "oidc_mismatched_subject_user") + _login(client, "oidc_mismatched_subject_user") + monkeypatch.setattr(config, "OIDC_ENABLED", True) + monkeypatch.setattr(config, "OIDC_ISSUER", "https://issuer.example") + monkeypatch.setattr(config, "OIDC_ALLOWED_SUBJECTS", set()) + monkeypatch.setattr(config, "OIDC_ALLOWED_DOMAINS", set()) + state = "mismatched-subject-sources" + _prepare_oidc_self_link_callback(app, client, user_id, state=state) + + class MismatchedProvider: + def authorize_access_token(self, *, code_verifier): + assert code_verifier == f"verifier-{state}" + return { + "id_token": "validated-id-token", + "userinfo": { + "iss": "https://issuer.example", + "sub": "userinfo-subject", + }, + } + + def parse_id_token(self, token, *, nonce): + assert token["id_token"] == "validated-id-token" + assert nonce == f"nonce-{state}" + return { + "iss": "https://issuer.example", + "sub": "signed-token-subject", + } + + monkeypatch.setattr(oidc_routes, "_client", lambda: MismatchedProvider()) + + response = client.get(f"/oidc/callback?state={state}") + + assert response.status_code == 400 + assert response.get_json()["error"] == "Invalid or expired OIDC login" + with app.app_context(): + assert OIDCIdentity.query.filter_by(user_id=user_id).count() == 0 + + +def test_oidc_self_link_is_idempotent_for_the_same_local_account( + app, client, monkeypatch +): + import config + import app.oidc_routes as oidc_routes + from app.models import OIDCIdentity, db + + user_id = _create_user(app, "oidc_idempotent_link_user") + _login(client, "oidc_idempotent_link_user") + monkeypatch.setattr(config, "OIDC_ENABLED", True) + monkeypatch.setattr(config, "OIDC_ISSUER", "https://issuer.example") + monkeypatch.setattr(config, "OIDC_ALLOWED_SUBJECTS", set()) + monkeypatch.setattr(config, "OIDC_ALLOWED_DOMAINS", set()) + state = "idempotent-self-link" + with app.app_context(): + db.session.add(OIDCIdentity( + user_id=user_id, + issuer="https://issuer.example", + subject="already-linked-subject", + )) + db.session.commit() + _prepare_oidc_self_link_callback(app, client, user_id, state=state) + monkeypatch.setattr( + oidc_routes, + "_client", + lambda: _signed_provider(state, { + "iss": "https://issuer.example", + "sub": "already-linked-subject", + }), + ) + + response = client.get(f"/oidc/callback?state={state}") + + assert response.status_code == 302 + assert response.headers["Location"] == "/settings" + with app.app_context(): + assert OIDCIdentity.query.filter_by( + user_id=user_id, + issuer="https://issuer.example", + subject="already-linked-subject", + ).count() == 1 + + +def test_oidc_self_link_unique_constraint_race_fails_closed( + app, client, monkeypatch +): + import config + import app.oidc_routes as oidc_routes + from app.models import OIDCIdentity, db + from sqlalchemy.exc import IntegrityError + from sqlalchemy.sql.dml import Insert + + user_id = _create_user(app, "oidc_link_race_user") + _login(client, "oidc_link_race_user") + monkeypatch.setattr(config, "OIDC_ENABLED", True) + monkeypatch.setattr(config, "OIDC_ISSUER", "https://issuer.example") + monkeypatch.setattr(config, "OIDC_ALLOWED_SUBJECTS", set()) + monkeypatch.setattr(config, "OIDC_ALLOWED_DOMAINS", set()) + state = "self-link-storage-race" + with app.app_context(): + db.session.add(OIDCIdentity( + user_id=user_id, + issuer="https://issuer.example", + subject="existing-safe-subject", + )) + db.session.commit() + _prepare_oidc_self_link_callback(app, client, user_id, state=state) + original_execute = db.session.execute + + class RacingProvider: + def authorize_access_token(self, *, code_verifier): + assert code_verifier == f"verifier-{state}" + + def fail_identity_insert(statement, *args, **kwargs): + if isinstance(statement, Insert): + raise IntegrityError( + "simulated unique race", + {}, + RuntimeError("unique constraint"), + ) + return original_execute(statement, *args, **kwargs) + + monkeypatch.setattr(db.session, "execute", fail_identity_insert) + return {"id_token": "validated-id-token"} + + def parse_id_token(self, token, *, nonce): + assert token["id_token"] == "validated-id-token" + assert nonce == f"nonce-{state}" + return { + "iss": "https://issuer.example", + "sub": "racing-subject", + } + + monkeypatch.setattr(oidc_routes, "_client", lambda: RacingProvider()) + + response = client.get(f"/oidc/callback?state={state}") + + assert response.status_code == 409 + with app.app_context(): + assert OIDCIdentity.query.filter_by( + issuer="https://issuer.example", + subject="racing-subject", + ).count() == 0 + assert OIDCIdentity.query.filter_by( + user_id=user_id, + subject="existing-safe-subject", + ).count() == 1 + + def test_oidc_self_link_stops_if_account_authentication_is_invalidated( app, client, monkeypatch ): From 484b8d45e1d3dc5f6dd5e0cdf149402a11674c36 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Tue, 8 Sep 2026 07:14:40 +0200 Subject: [PATCH 4/4] Complete OIDC self-link compatibility coverage --- app/account_step_up_routes.py | 4 +- app/models.py | 2 +- app/step_up_routes.py | 5 +- docs/wiki/Authentication-Overview.md | 10 +- docs/wiki/OpenID-Connect.md | 20 +- docs/wiki/Users-and-Account-Management.md | 16 +- static/css/admin.css | 3 +- static/js/admin.js | 2 +- static/js/i18n.js | 2 +- static/js/security-ui.js | 5 +- static/js/webauthn.js | 1 + tests/e2e/admin-oidc.spec.js | 25 +++ tests/js/security-ui.test.js | 5 +- tests/test_account_step_up_routes.py | 25 ++- tests/test_database_init.py | 35 +++ tests/test_oidc_routes.py | 248 ++++++++++++++++++++++ 16 files changed, 372 insertions(+), 36 deletions(-) diff --git a/app/account_step_up_routes.py b/app/account_step_up_routes.py index fdba0544..f88803d2 100644 --- a/app/account_step_up_routes.py +++ b/app/account_step_up_routes.py @@ -6,7 +6,7 @@ import secrets from threading import Lock -from flask import Blueprint, jsonify, request, session +from flask import Blueprint, jsonify, request, session, url_for from flask_login import current_user, login_required from webauthn import ( base64url_to_bytes, @@ -556,7 +556,7 @@ def oidc_step_up_start(): try: return begin_oidc_account_step_up( intent=intent, - continuation=data.get("continuation") or "/security", + continuation=url_for("security_center"), return_authorization_url=True, ) except (OIDCStateError, StepUpError): diff --git a/app/models.py b/app/models.py index a1b572fb..20b6c977 100644 --- a/app/models.py +++ b/app/models.py @@ -682,7 +682,7 @@ def cleanup_expired_security_rows(limit=500, now=None): class OIDCIdentity(db.Model): - """Administrator-approved stable external identity mapping.""" + """Verified stable external identity mapping to a WebSSH account.""" __tablename__ = 'oidc_identities' __table_args__ = ( diff --git a/app/step_up_routes.py b/app/step_up_routes.py index dda43b8d..de6c2449 100644 --- a/app/step_up_routes.py +++ b/app/step_up_routes.py @@ -6,7 +6,7 @@ import time from threading import Lock -from flask import Blueprint, jsonify, request, session +from flask import Blueprint, jsonify, request, session, url_for from flask_login import current_user, login_required from webauthn import ( base64url_to_bytes, @@ -329,13 +329,12 @@ def oidc_step_up_start(): current_authentication_session() ): return _failure() - continuation = str(data.get("continuation") or "/admin") from .oidc_routes import begin_oidc_step_up return begin_oidc_step_up( action=action, target_hash=hash_step_up_target(target), - continuation=continuation, + continuation=url_for("admin_page"), return_authorization_url=True, ) diff --git a/docs/wiki/Authentication-Overview.md b/docs/wiki/Authentication-Overview.md index e50b33f8..0b8a3a4a 100644 --- a/docs/wiki/Authentication-Overview.md +++ b/docs/wiki/Authentication-Overview.md @@ -16,7 +16,7 @@ upgrade a session silently. | Passkey/WebAuthn | Disabled | Credential owned by a local account | Phishing-resistant local sign-in | | Authenticator app (TOTP) | Disabled | Encrypted secret owned by a local account | Optional second factor after password, LDAP, or basic OIDC | | Recovery code | Enabled | One-time code owned by a local account | Second-factor recovery after valid primary login | -| OIDC | Disabled | Exact issuer and subject linked by an admin | Existing OpenID Provider | +| OIDC | Disabled | Exact issuer and subject linked by the user or an admin | Existing OpenID Provider | | GitHub App | Disabled | Immutable numeric GitHub user ID | Linked GitHub identities and optional controlled provisioning | | LDAP/Active Directory | Disabled | Stable directory ID linked by an admin | Lab or organization directory authentication | @@ -122,9 +122,11 @@ the plaintext set offline; it cannot be displayed again. ## OpenID Connect OIDC uses the authorization-code flow with PKCE, nonce, state, and a -session-bound one-use login record. An administrator must link the exact issuer -and subject to a local account. Optional subject and email-domain rules are -additional admission filters, not identity keys. +session-bound one-use state record. An eligible signed-in user can link the +provider-verified issuer and subject to their own local account after +action-bound confirmation. An administrator-managed link remains available as +a recovery fallback. Optional subject and email-domain rules are additional +admission filters, not identity keys. Assurance is conservative: absent, malformed, or unmapped signed claims remain `BASIC`. Provider push can be used only through the provider's own policy and a diff --git a/docs/wiki/OpenID-Connect.md b/docs/wiki/OpenID-Connect.md index 904d0d7c..145c34dd 100644 --- a/docs/wiki/OpenID-Connect.md +++ b/docs/wiki/OpenID-Connect.md @@ -15,7 +15,8 @@ existing local account: Email addresses and usernames are not identity keys. Optional subject and email domain allowlists are additional policy checks. -OIDC identities cannot be linked to LDAP-managed accounts. +OIDC identities cannot be linked to LDAP-managed or GitHub-provisioned +accounts. ## Provider requirements @@ -128,7 +129,7 @@ or secret details. The recommended path does not require copying a provider subject: 1. Enable and activate OIDC. -2. Sign in to the target WebSSH account with an existing local method. +2. Sign in to the target WebSSH account with an existing sign-in method. 3. Open **Settings → Security methods → Identity provider**. 4. Choose **Connect identity provider** and complete the action-bound WebSSH confirmation. @@ -193,7 +194,12 @@ and subject. | Callback rejected | exact callback, state cookie, proxy origin, system time | | Identity not linked | Sign in locally and use **Settings → Security methods → Identity provider**, or verify the administrator mapping for the exact issuer and subject | | Domain rejected | email claim and `OIDC_ALLOWED_DOMAINS` | -| User rejected after link | locked account or LDAP-managed state | +| User rejected after link | locked, LDAP-managed, or GitHub-provisioned account state | + +Operator audit events distinguish an absent mapping (`unlinked`), a locked +target (`account_locked`), and an incompatible externally managed target +(`externally_managed`). Browser responses remain generic so they do not disclose +account state. ## Recovery @@ -205,3 +211,11 @@ Disabling OIDC in the Admin Panel blocks later OIDC starts without forcibly terminating existing browser or SSH sessions. Existing work reaches its normal configured lifetime. Explicit account lock, deletion, and MFA reset still revoke the target account. + +## Upgrade compatibility + +Self-service linking requires no new environment variable or provider +registration. The additive database migration adds only nullable account and +session bindings to the short-lived OIDC state table. Existing users, OIDC +identity mappings, pending login or Step-up states, and the administrator link +and unlink flows remain compatible. diff --git a/docs/wiki/Users-and-Account-Management.md b/docs/wiki/Users-and-Account-Management.md index 46a2804a..65605231 100644 --- a/docs/wiki/Users-and-Account-Management.md +++ b/docs/wiki/Users-and-Account-Management.md @@ -7,8 +7,10 @@ settings, notes, SSH keys, host trust, live sessions, and transfer ownership. ### Standard user -A standard user can manage their own SSH/SFTP data and security factors. They -cannot access the Admin Panel or another user's state. +A standard user can manage their own SSH/SFTP data and security factors. When +OIDC is active, an eligible user can also link a provider identity to their own +account after action-bound confirmation. They cannot access the Admin Panel or +another user's state. ### Administrator @@ -132,10 +134,12 @@ back into the active namespace. ## External identity ownership External identity resolves to a local WebSSH account. OIDC always requires an -administrator-created link. LDAP uses the same controlled link by default; -explicit `LDAP_AUTO_PROVISION=true` can instead create a non-admin account only -after successful directory authentication and only when no local username or -stable identity collides. +explicit verified link: an eligible signed-in user can self-link to their own +account, while an administrator-managed link remains available as a recovery +fallback. LDAP uses a controlled administrator link by default; explicit +`LDAP_AUTO_PROVISION=true` can instead create a non-admin account only after +successful directory authentication and only when no local username or stable +identity collides. - OIDC uses the provider's stable issuer and subject. Email alone is never an identity key. diff --git a/static/css/admin.css b/static/css/admin.css index 0c66fc73..29c329a5 100644 --- a/static/css/admin.css +++ b/static/css/admin.css @@ -654,7 +654,8 @@ content: ''; } -.settings-security-method-row-github:has(#githubIdentityAction[data-connected="true"]) .settings-security-method-status::before { +.settings-security-method-row-github:has(#githubIdentityAction[data-connected="true"]) .settings-security-method-status::before, +.settings-security-method-row-oidc:has(#oidcIdentityAction[data-connected="true"]) .settings-security-method-status::before { background: var(--success-color, #22c55e); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success-color, #22c55e) 16%, transparent); } diff --git a/static/js/admin.js b/static/js/admin.js index 0bee5fbb..83fea8c3 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -173,7 +173,7 @@ async function oidcStepUp(action, target, generation) { const started = await api('/api/step-up/oidc/start', { method: 'POST', - body: { action, target, continuation: '/admin' } + body: { action, target } }); const popup = window.open(started.authorization_url, 'webssh-oidc-step-up', 'popup,width=720,height=760'); if (!popup) { throw new Error('Allow the OIDC authentication popup and try again'); } diff --git a/static/js/i18n.js b/static/js/i18n.js index 5be7f2fb..1ca3b140 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -3496,7 +3496,7 @@ const translations = { 'security.managePasskeysHint': 'Anmeldedaten für die passwortlose Anmeldung verwalten.', 'security.manageAuthenticatorHint': 'Zeitbasierte Einmalcodes verwalten.', 'security.manageGithubHint': 'Die mit diesem WebSSH-Konto verwendete GitHub-Identität verknüpfen oder trennen.', - 'security.manageOidcHint': 'Verknüpfe eine Identität durch Anmeldung beim konfigurierten Anbieter. WebSSH speichert nur den verifizierten Aussteller und Betreff.', + 'security.manageOidcHint': 'Verknüpfe eine Identität durch Anmeldung beim konfigurierten Anbieter. WebSSH speichert nur den verifizierten Aussteller und die Subjektkennung.', 'security.manageRecoveryCodesHint': 'Notfall-Anmeldecodes erzeugen und sicher aufbewahren.', 'security.githubManagedTitle': 'GitHub-Anmeldung', 'security.githubManagedHint': 'GitHub steuert diese primäre Anmeldung. WebSSH verwirft das temporäre GitHub-Token nach der Identitäts- und Organisationsprüfung.', diff --git a/static/js/security-ui.js b/static/js/security-ui.js index 81b292d2..90fb7983 100644 --- a/static/js/security-ui.js +++ b/static/js/security-ui.js @@ -187,10 +187,7 @@ if (method === 'oidc' || method === 'github') { const started = await api(`/api/account/step-up/${method}/start`, { method: 'POST', - body: { - intent: created.intent, - continuation: '/security' - } + body: { intent: created.intent } }); openAuthorization(started.authorization_url); for (let attempt = 0; attempt < 120; attempt += 1) { diff --git a/static/js/webauthn.js b/static/js/webauthn.js index 1c842aeb..3ce363d6 100644 --- a/static/js/webauthn.js +++ b/static/js/webauthn.js @@ -727,6 +727,7 @@ const identityCount = Number.parseInt( button.dataset.identityCount || '0', 10 ); + button.dataset.connected = String(identityCount > 0); const actionLabel = button.querySelector('.oidc-identity-action-label'); const action = identityCount > 0 ? t('security.addOidcIdentity', 'Link another identity') diff --git a/tests/e2e/admin-oidc.spec.js b/tests/e2e/admin-oidc.spec.js index f40ade34..d7d75551 100644 --- a/tests/e2e/admin-oidc.spec.js +++ b/tests/e2e/admin-oidc.spec.js @@ -42,6 +42,24 @@ test('admin can inspect, unlink, and add an OIDC identity', async ({ page }) => }); test('linked users see the safe OIDC self-link status and action', async ({ page }) => { + let linkRequestHeaders; + await page.route('**/api/account/oidc/link/start', async route => { + linkRequestHeaders = route.request().headers(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + authorization_url: 'https://issuer.example/authorize?state=e2e-link', + }), + }); + }); + await page.route('https://issuer.example/authorize?state=e2e-link', route => ( + route.fulfill({ + status: 200, + contentType: 'text/html', + body: 'OIDC authorization', + }) + )); await login(page, 'e2e_user'); await page.goto('/settings'); @@ -49,12 +67,19 @@ test('linked users see the safe OIDC self-link status and action', async ({ page const action = page.locator('#oidcIdentityAction'); await expect(status).toHaveText('1 OIDC identity linked'); await expect(action).toContainText('Link another identity'); + await expect(action).toHaveAttribute('data-connected', 'true'); await expect(page.getByText('existing-e2e-subject')).toHaveCount(0); await action.click(); const confirmation = page.locator('#securityConfirmationModal'); await expect(confirmation).toHaveClass(/show/); await expect(confirmation).toHaveAttribute('aria-hidden', 'false'); + await page.locator('#securityConfirmationPassword').fill('browser-password'); + await page.locator('#securityConfirmationSubmit').click(); + await expect(page).toHaveURL( + 'https://issuer.example/authorize?state=e2e-link' + ); + expect(linkRequestHeaders['x-webssh-step-up']).toBeTruthy(); }); test('admin navigation and users become readable cards on a mobile viewport', async ({ page }) => { diff --git a/tests/js/security-ui.test.js b/tests/js/security-ui.test.js index e7704b9a..fa549371 100644 --- a/tests/js/security-ui.test.js +++ b/tests/js/security-ui.test.js @@ -76,8 +76,9 @@ test('OIDC account step-up never asks for a local password', async () => { let secretRequests = 0; const opened = []; let polls = 0; + let startOptions; const client = createAccountStepUpClient({ - api: async path => { + api: async (path, options) => { if (path.endsWith('/intents')) { return { intent: 'intent-oidc', @@ -86,6 +87,7 @@ test('OIDC account step-up never asks for a local password', async () => { }; } if (path.endsWith('/oidc/start')) { + startOptions = options; return { authorization_url: 'https://idp.example/authorize' }; } polls += 1; @@ -104,6 +106,7 @@ test('OIDC account step-up never asks for a local password', async () => { assert.equal(await client.authorize('recovery.rotate', 17), 'grant-oidc'); assert.equal(secretRequests, 0); assert.deepEqual(opened, ['https://idp.example/authorize']); + assert.deepEqual(startOptions.body, { intent: 'intent-oidc' }); assert.equal(polls, 2); }); diff --git a/tests/test_account_step_up_routes.py b/tests/test_account_step_up_routes.py index 772abc85..aaad6a9d 100644 --- a/tests/test_account_step_up_routes.py +++ b/tests/test_account_step_up_routes.py @@ -247,7 +247,7 @@ def test_oidc_session_never_offers_or_accepts_local_password( assert rejected.get_json()["code"] == "step_up_failed" -def test_oidc_account_starts_are_bound_to_independent_persistent_intents( +def test_oidc_account_starts_use_independent_intents_and_generated_continuation( app, client, monkeypatch, @@ -283,14 +283,20 @@ def authorize_redirect(self, _callback, **kwargs): "target": user_id, }).get_json() - first_started = client.post("/api/account/step-up/oidc/start", json={ - "intent": first["intent"], - "continuation": "/security", - }) - second_started = client.post("/api/account/step-up/oidc/start", json={ - "intent": second["intent"], - "continuation": "/security", - }) + request_environment = {"SCRIPT_NAME": "/webssh"} + first_started = client.post( + "/api/account/step-up/oidc/start", + json={ + "intent": first["intent"], + "continuation": "/client-controlled-path", + }, + environ_overrides=request_environment, + ) + second_started = client.post( + "/api/account/step-up/oidc/start", + json={"intent": second["intent"]}, + environ_overrides=request_environment, + ) assert first_started.status_code == 200 assert second_started.status_code == 200 @@ -300,6 +306,7 @@ def authorize_redirect(self, _callback, **kwargs): assert rows[0].step_up_intent_id != rows[1].step_up_intent_id assert all(row.step_up_action is None for row in rows) assert all(row.step_up_target_hash is None for row in rows) + assert all(row.continuation == "/webssh/settings" for row in rows) def test_ldap_session_uses_fresh_directory_resolution_and_bind( diff --git a/tests/test_database_init.py b/tests/test_database_init.py index 54920edf..9dbbcda1 100644 --- a/tests/test_database_init.py +++ b/tests/test_database_init.py @@ -1,3 +1,6 @@ +from datetime import datetime, timedelta, timezone +import hashlib + from sqlalchemy import inspect, text @@ -74,6 +77,27 @@ def test_legacy_oidc_state_adds_assurance_intent_columns_idempotently(app): 'expires_at DATETIME NOT NULL' ')' )) + db.session.execute( + text( + 'INSERT INTO oidc_login_states ' + '(state_hash, session_binding_hash, nonce, code_verifier, ' + 'expires_at) VALUES ' + '(:state_hash, :binding_hash, :nonce, :verifier, :expires_at)' + ), + { + 'state_hash': hashlib.sha256( + b'legacy-login-state' + ).hexdigest(), + 'binding_hash': hashlib.sha256( + b'legacy-browser-binding' + ).hexdigest(), + 'nonce': 'legacy-nonce', + 'verifier': 'legacy-verifier', + 'expires_at': datetime.now(timezone.utc).replace( + tzinfo=None + ) + timedelta(minutes=5), + }, + ) db.session.commit() ensure_security_columns() @@ -94,3 +118,14 @@ def test_legacy_oidc_state_adds_assurance_intent_columns_idempotently(app): 'auth_generation', 'authentication_session_id', } <= columns + from app.oidc_service import consume_login_state + + intent = consume_login_state( + state='legacy-login-state', + session_binding='legacy-browser-binding', + ) + assert intent.purpose == 'login' + assert intent.continuation == '/' + assert intent.user_id is None + assert intent.auth_generation is None + assert intent.authentication_session_id is None diff --git a/tests/test_oidc_routes.py b/tests/test_oidc_routes.py index b934d012..ad70d43e 100644 --- a/tests/test_oidc_routes.py +++ b/tests/test_oidc_routes.py @@ -300,6 +300,213 @@ def parse_id_token(self, token, *, nonce): assert OIDCIdentity.query.filter_by(user_id=user_id).count() == 0 +@pytest.mark.parametrize(("allowed_subjects", "allowed_domains", "claims"), ( + ( + {"different-subject"}, + set(), + {"iss": "https://issuer.example", "sub": "policy-subject"}, + ), + ( + set(), + {"example.com"}, + { + "iss": "https://issuer.example", + "sub": "policy-subject", + "email": "user@other.example", + "email_verified": True, + }, + ), + ( + set(), + {"example.com"}, + { + "iss": "https://issuer.example", + "sub": "policy-subject", + "email": "user@example.com", + "email_verified": False, + }, + ), +)) +def test_oidc_self_link_enforces_subject_and_domain_policies( + app, + client, + monkeypatch, + allowed_subjects, + allowed_domains, + claims, +): + import config + import app.oidc_routes as oidc_routes + from app.models import OIDCIdentity + + user_id = _create_user(app, "oidc_policy_rejected_user") + _login(client, "oidc_policy_rejected_user") + monkeypatch.setattr(config, "OIDC_ENABLED", True) + monkeypatch.setattr(config, "OIDC_ISSUER", "https://issuer.example") + monkeypatch.setattr(config, "OIDC_ALLOWED_SUBJECTS", allowed_subjects) + monkeypatch.setattr(config, "OIDC_ALLOWED_DOMAINS", allowed_domains) + state = "self-link-policy-rejection" + _prepare_oidc_self_link_callback(app, client, user_id, state=state) + monkeypatch.setattr( + oidc_routes, + "_client", + lambda: _signed_provider(state, claims), + ) + + response = client.get(f"/oidc/callback?state={state}") + + assert response.status_code == 400 + assert response.get_json()["error"] == "Invalid or expired OIDC login" + with app.app_context(): + assert OIDCIdentity.query.filter_by(user_id=user_id).count() == 0 + + +def test_oidc_self_link_accepts_matching_subject_and_domain_policies( + app, client, monkeypatch +): + import config + import app.oidc_routes as oidc_routes + from app.models import OIDCIdentity + + user_id = _create_user(app, "oidc_policy_accepted_user") + _login(client, "oidc_policy_accepted_user") + monkeypatch.setattr(config, "OIDC_ENABLED", True) + monkeypatch.setattr(config, "OIDC_ISSUER", "https://issuer.example") + monkeypatch.setattr(config, "OIDC_ALLOWED_SUBJECTS", {"policy-subject"}) + monkeypatch.setattr(config, "OIDC_ALLOWED_DOMAINS", {"example.com"}) + state = "self-link-policy-accepted" + _prepare_oidc_self_link_callback(app, client, user_id, state=state) + monkeypatch.setattr( + oidc_routes, + "_client", + lambda: _signed_provider(state, { + "iss": "https://issuer.example", + "sub": "policy-subject", + "email": "user@example.com", + "email_verified": True, + }), + ) + + response = client.get(f"/oidc/callback?state={state}") + + assert response.status_code == 302 + with app.app_context(): + assert OIDCIdentity.query.filter_by( + user_id=user_id, + issuer="https://issuer.example", + subject="policy-subject", + ).count() == 1 + + +@pytest.mark.parametrize(("account_state", "expected_status"), ( + ("locked", 403), + ("ldap", 302), + ("github", 409), +)) +def test_oidc_self_link_start_rejects_ineligible_accounts( + app, client, monkeypatch, account_state, expected_status +): + import config + import app.oidc_routes as oidc_routes + from app.models import ( + GitHubIdentity, + LDAPIdentity, + OIDCLoginState, + User, + db, + ) + + user_id = _create_user(app, "oidc_ineligible_link_user") + _login(client, "oidc_ineligible_link_user") + monkeypatch.setattr(config, "OIDC_ENABLED", True) + headers = account_password_step_up_headers( + client, "oidc.self_link", user_id + )[0] + with app.app_context(): + user = db.session.get(User, user_id) + if account_state == "locked": + user.is_locked = True + elif account_state == "ldap": + db.session.add(LDAPIdentity( + user_id=user_id, + provider="default", + subject="ineligible-directory-subject", + directory_username="oidc_ineligible_link_user", + distinguished_name=( + "uid=oidc_ineligible_link_user,dc=example,dc=com" + ), + )) + else: + db.session.add(GitHubIdentity( + user_id=user_id, + github_user_id="424200", + login="oidc-ineligible-link-user", + provisioned_by_github=True, + )) + db.session.commit() + + monkeypatch.setattr( + oidc_routes, + "_client", + lambda: (_ for _ in ()).throw( + AssertionError("provider must not be contacted") + ), + ) + response = client.post( + "/api/account/oidc/link/start", json={}, headers=headers + ) + + assert response.status_code == expected_status + if account_state == "ldap": + assert response.headers["Location"] == "/login" + with app.app_context(): + assert OIDCLoginState.query.count() == 0 + + +def test_oidc_self_link_start_rejects_an_expired_authentication_session( + app, client, monkeypatch +): + from datetime import datetime, timedelta, timezone + + import config + import app.oidc_routes as oidc_routes + from app.models import AuthenticationSession, OIDCLoginState, db + + user_id = _create_user(app, "oidc_expired_link_session") + _login(client, "oidc_expired_link_session") + monkeypatch.setattr(config, "OIDC_ENABLED", True) + headers = account_password_step_up_headers( + client, "oidc.self_link", user_id + )[0] + with app.app_context(): + auth_session = AuthenticationSession.query.filter_by( + user_id=user_id + ).one() + auth_session.expires_at = ( + datetime.now(timezone.utc).replace(tzinfo=None) + - timedelta(seconds=1) + ) + db.session.commit() + + monkeypatch.setattr( + oidc_routes, + "_client", + lambda: (_ for _ in ()).throw( + AssertionError("provider must not be contacted") + ), + ) + response = client.post( + "/api/account/oidc/link/start", json={}, headers=headers + ) + + assert response.status_code == 302 + assert response.headers["Location"] == ( + "/login?next=/api/account/oidc/link/start" + ) + with app.app_context(): + assert OIDCLoginState.query.count() == 0 + + def test_oidc_self_link_is_idempotent_for_the_same_local_account( app, client, monkeypatch ): @@ -1432,6 +1639,47 @@ def authorize_redirect(self, _callback, **kwargs): assert "code_verifier" not in observed +def test_oidc_admin_step_up_uses_generated_application_root_continuation( + app, client, monkeypatch +): + from flask import redirect + + import config + import app.oidc_routes as oidc_routes + from app.models import AuthenticationSession, OIDCLoginState, db + + admin_id = _create_user(app, "subfolder_stepup_admin", is_admin=True) + _login(client, "subfolder_stepup_admin") + monkeypatch.setattr(config, "OIDC_ENABLED", True) + with app.app_context(): + auth_session = AuthenticationSession.query.filter_by( + user_id=admin_id + ).one() + auth_session.methods_json = '["oidc"]' + db.session.commit() + + class Provider: + def authorize_redirect(self, _callback, **values): + return redirect( + "https://issuer.example/authorize?state=" + values["state"] + ) + + monkeypatch.setattr(oidc_routes, "_client", lambda: Provider()) + response = client.post( + "/api/step-up/oidc/start", + json={ + "action": "settings.update", + "target": "global", + "continuation": "/client-controlled-path", + }, + environ_overrides={"SCRIPT_NAME": "/webssh"}, + ) + + assert response.status_code == 200 + with app.app_context(): + assert OIDCLoginState.query.one().continuation == "/webssh/admin" + + def test_oidc_step_up_state_cannot_be_replayed_as_a_login( app, client,