diff --git a/app/account_step_up_routes.py b/app/account_step_up_routes.py index 77bfa77..f88803d 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, @@ -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", + continuation=url_for("security_center"), + 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 fa838ee..ad042a9 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 2b48061..20b6c97 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: @@ -669,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__ = ( @@ -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 660809b..e630a1e 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 ( @@ -16,8 +17,10 @@ render_template, request, session, + url_for, ) from flask_login import current_user, login_required +from sqlalchemy import insert, literal, select from sqlalchemy.exc import IntegrityError import config @@ -29,7 +32,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 +109,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 +128,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 +146,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 +182,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 +326,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 +352,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 +373,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=url_for("security_center"), + return_authorization_url=True, + ) + + @oidc_blueprint.get("/oidc/callback") def oidc_callback(): _require_enabled() @@ -212,6 +435,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 +473,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 +503,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 +540,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 a1dcea1..ea441e7 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 717a52d..b737175 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 02c5a8b..916773d 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 d98fd2d..de6c244 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,17 +329,14 @@ 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 - response = 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, ) - 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/Authentication-Overview.md b/docs/wiki/Authentication-Overview.md index e50b33f..0b8a3a4 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 c625084..145c34d 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 @@ -14,7 +15,8 @@ An administrator links an exact provider identity to an 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 @@ -124,6 +126,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 sign-in 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 +152,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,9 +192,14 @@ 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 | +| 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 @@ -179,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 46a2804..6560523 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 0c66fc7..29c329a 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 0bee5fb..83fea8c 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-auth.js b/static/js/i18n-auth.js index a26bc32..f5a508c 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 60acc23..65c653c 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -529,6 +529,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?', @@ -905,6 +910,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.', @@ -1781,6 +1787,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?', @@ -2194,6 +2205,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.', @@ -3106,6 +3118,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?', @@ -3500,6 +3517,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 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.', @@ -4356,6 +4374,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 ?', @@ -4798,6 +4821,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.', @@ -5643,6 +5667,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?', @@ -6085,6 +6114,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.', @@ -6930,6 +6960,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 身份吗?', @@ -7361,6 +7396,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/security-ui.js b/static/js/security-ui.js index 81b292d..90fb798 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 fb238a5..3ce363d 100644 --- a/static/js/webauthn.js +++ b/static/js/webauthn.js @@ -720,6 +720,71 @@ 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 + ); + button.dataset.connected = String(identityCount > 0); + 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 +809,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 abbb26b..4f0968e 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/e2e/admin-oidc.spec.js b/tests/e2e/admin-oidc.spec.js index 10c0687..d7d7555 100644 --- a/tests/e2e/admin-oidc.spec.js +++ b/tests/e2e/admin-oidc.spec.js @@ -41,6 +41,47 @@ 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 }) => { + 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'); + + 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(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 }) => { await page.setViewportSize({ width: 360, height: 800 }); await login(page); diff --git a/tests/js/security-ui.test.js b/tests/js/security-ui.test.js index e7704b9..fa54937 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 772abc8..aaad6a9 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 eeb0894..9dbbcda 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() @@ -90,4 +114,18 @@ 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 + 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_i18n_parity.py b/tests/test_i18n_parity.py index e2efc4b..7771073 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 506bb0b..ad70d43 100644 --- a/tests/test_oidc_routes.py +++ b/tests/test_oidc_routes.py @@ -1,8 +1,14 @@ """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 +import pytest + +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 +35,47 @@ 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_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 @@ -71,15 +118,788 @@ 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 +@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, script_name, expected_location +): + 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()) + request_environment = {"SCRIPT_NAME": script_name} + + 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, + 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}", + 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"] == expected_location + 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" + + +@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, authorization_location +): + 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(authorization_location.format( + state=values["state"] + )) + + 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_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 + + +@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 +): + 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 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 ): @@ -819,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, diff --git a/tests/test_oidc_service.py b/tests/test_oidc_service.py index 26bd416..1be8170 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 9a7fba2..7d3dc2b 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 7bfd2b5..dd82d29 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 ):