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 @@
Link an identity by signing in with the configured provider. WebSSH stores only its verified issuer and subject.
Loading linked account…
Generate and store emergency sign-in codes.