diff --git a/turnstone/api/console_spec.py b/turnstone/api/console_spec.py
index 38343273..65642311 100644
--- a/turnstone/api/console_spec.py
+++ b/turnstone/api/console_spec.py
@@ -294,6 +294,20 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
error_codes=[404],
tags=["Admin"],
),
+ # --- OIDC Identities ---
+ EndpointSpec(
+ "/v1/api/admin/users/{user_id}/oidc-identities",
+ "GET",
+ "List OIDC identities linked to a user",
+ tags=["Admin"],
+ ),
+ EndpointSpec(
+ "/v1/api/admin/oidc-identities",
+ "DELETE",
+ "Unlink an OIDC identity (issuer + subject as query params)",
+ error_codes=[400, 404],
+ tags=["Admin"],
+ ),
# --- Schedules ---
EndpointSpec(
"/v1/api/admin/schedules",
diff --git a/turnstone/console/server.py b/turnstone/console/server.py
index 7eb6fcb5..0988558d 100644
--- a/turnstone/console/server.py
+++ b/turnstone/console/server.py
@@ -1098,6 +1098,67 @@ async def admin_delete_channel(request: Request) -> JSONResponse:
return JSONResponse({"error": "Channel link not found"}, status_code=404)
+# ---------------------------------------------------------------------------
+# Admin API endpoints — OIDC identities
+# ---------------------------------------------------------------------------
+
+
+async def admin_list_oidc_identities(request: Request) -> JSONResponse:
+ """GET /v1/api/admin/users/{user_id}/oidc-identities — list OIDC links for a user."""
+ from turnstone.core.auth import require_permission
+ from turnstone.core.web_helpers import require_storage_or_503
+
+ storage, err = require_storage_or_503(request)
+ if err:
+ return err
+ err = require_permission(request, "admin.users")
+ if err:
+ return err
+
+ user_id = request.path_params["user_id"]
+ identities = storage.list_oidc_identities_for_user(user_id)
+ return JSONResponse({"oidc_identities": identities})
+
+
+async def admin_delete_oidc_identity(request: Request) -> JSONResponse:
+ """DELETE /v1/api/admin/oidc-identities?issuer=...&subject=... — unlink OIDC identity."""
+ from turnstone.core.audit import record_audit
+ from turnstone.core.auth import require_permission
+ from turnstone.core.web_helpers import require_storage_or_503
+
+ storage, err = require_storage_or_503(request)
+ if err:
+ return err
+ err = require_permission(request, "admin.users")
+ if err:
+ return err
+
+ issuer = request.query_params.get("issuer", "")
+ subject = request.query_params.get("subject", "")
+ if not issuer or not subject:
+ return JSONResponse({"error": "issuer and subject required"}, status_code=400)
+
+ # Look up before delete so audit captures which user was affected
+ identity = storage.get_oidc_identity(issuer, subject)
+ if not identity:
+ return JSONResponse({"error": "Identity not found"}, status_code=404)
+
+ storage.delete_oidc_identity(issuer, subject)
+
+ audit_uid, ip = _audit_context(request)
+ record_audit(
+ storage,
+ audit_uid,
+ "oidc_identity.delete",
+ "oidc_identity",
+ f"{issuer}:{subject}",
+ {"user_id": identity["user_id"]},
+ ip,
+ )
+
+ return JSONResponse({"status": "ok"})
+
+
# ---------------------------------------------------------------------------
# Admin API endpoints — scheduled tasks
# ---------------------------------------------------------------------------
@@ -3628,6 +3689,15 @@ def create_app(
admin_delete_channel,
methods=["DELETE"],
),
+ Route(
+ "/api/admin/users/{user_id}/oidc-identities",
+ admin_list_oidc_identities,
+ ),
+ Route(
+ "/api/admin/oidc-identities",
+ admin_delete_oidc_identity,
+ methods=["DELETE"],
+ ),
Route("/api/admin/schedules", admin_list_schedules),
Route("/api/admin/schedules", admin_create_schedule, methods=["POST"]),
Route("/api/admin/schedules/{task_id}", admin_get_schedule),
diff --git a/turnstone/console/static/admin.js b/turnstone/console/static/admin.js
index 00ce7683..56404cb9 100644
--- a/turnstone/console/static/admin.js
+++ b/turnstone/console/static/admin.js
@@ -267,8 +267,13 @@ function _renderUsers(users) {
for (var i = 0; i < users.length; i++) {
var u = users[i];
html +=
- '
' +
+ '
' +
'
' +
+ '\u25b8' +
escapeHtml(u.username) +
"" +
'
' +
@@ -307,6 +312,31 @@ function _renderUsers(users) {
);
});
}
+ // Bind expandable row click + keyboard handlers for OIDC detail panel
+ var rows = container.querySelectorAll(".admin-row[data-expandable]");
+ for (var k = 0; k < rows.length; k++) {
+ (function (row) {
+ var _expand = function () {
+ var uid = row.getAttribute("data-user-id");
+ var uname = row.getAttribute("data-username");
+ _toggleOidcPanel(uid, uname, row);
+ };
+ row.addEventListener("click", function (e) {
+ if (
+ e.target.closest(".admin-btn-danger") ||
+ e.target.closest(".admin-btn-action")
+ )
+ return;
+ _expand();
+ });
+ row.addEventListener("keydown", function (e) {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ _expand();
+ }
+ });
+ })(rows[k]);
+ }
}
function confirmDeleteUser(userId, username) {
@@ -332,6 +362,253 @@ function confirmDeleteUser(userId, username) {
);
}
+// ---------------------------------------------------------------------------
+// OIDC identity expansion in Users tab
+// ---------------------------------------------------------------------------
+
+function _toggleOidcPanel(userId, username, rowEl) {
+ var existing = rowEl.nextElementSibling;
+ if (existing && existing.classList.contains("oidc-detail-panel")) {
+ // Collapse
+ existing.style.maxHeight = "0";
+ var indicator = rowEl.querySelector(".admin-expand-indicator");
+ if (indicator) indicator.classList.remove("expanded");
+ rowEl.setAttribute("aria-expanded", "false");
+ setTimeout(function () {
+ if (existing.parentNode) existing.remove();
+ }, 160);
+ return;
+ }
+ // Collapse any other open panel first
+ var openPanels = document.querySelectorAll(
+ "#admin-users-table .oidc-detail-panel",
+ );
+ for (var i = 0; i < openPanels.length; i++) {
+ openPanels[i].style.maxHeight = "0";
+ var prevRow = openPanels[i].previousElementSibling;
+ if (prevRow) {
+ var ind = prevRow.querySelector(".admin-expand-indicator");
+ if (ind) ind.classList.remove("expanded");
+ prevRow.setAttribute("aria-expanded", "false");
+ }
+ (function (panel) {
+ setTimeout(function () {
+ if (panel.parentNode) panel.remove();
+ }, 160);
+ })(openPanels[i]);
+ }
+ // Mark expanded
+ var indicator = rowEl.querySelector(".admin-expand-indicator");
+ if (indicator) indicator.classList.add("expanded");
+ rowEl.setAttribute("aria-expanded", "true");
+ // Create panel (role="none" so it doesn't break the parent role="list")
+ var panel = document.createElement("div");
+ panel.className = "oidc-detail-panel";
+ panel.setAttribute("role", "none");
+ panel.innerHTML =
+ '' +
+ '' +
+ '
Loading\u2026
' +
+ "
";
+ rowEl.after(panel);
+ // Animate open
+ requestAnimationFrame(function () {
+ panel.style.maxHeight = panel.scrollHeight + "px";
+ });
+ // Fetch identities
+ authFetch(
+ "/v1/api/admin/users/" + encodeURIComponent(userId) + "/oidc-identities",
+ )
+ .then(function (r) {
+ if (!r.ok) throw new Error("Failed");
+ return r.json();
+ })
+ .then(function (data) {
+ _renderOidcDetail(panel, data.oidc_identities || [], userId, username);
+ })
+ .catch(function () {
+ var body = panel.querySelector(".oidc-detail-body");
+ if (body)
+ body.innerHTML =
+ 'Failed to load';
+ });
+}
+
+function _renderOidcDetail(panel, identities, userId, username) {
+ var body = panel.querySelector(".oidc-detail-body");
+ if (!body) return;
+ if (!identities.length) {
+ body.innerHTML =
+ 'No OIDC identities linked';
+ panel.style.maxHeight = panel.scrollHeight + "px";
+ return;
+ }
+ var html = "";
+ for (var i = 0; i < identities.length; i++) {
+ var oid = identities[i];
+ var shortIssuer = _issuerShortName(oid.issuer || "");
+ var shortSubject =
+ (oid.subject || "").length > 12
+ ? (oid.subject || "").slice(0, 12) + "\u2026"
+ : oid.subject || "";
+ var lastLogin = oid.last_login ? _relativeTime(oid.last_login) : "never";
+ html +=
+ '' +
+ '' +
+ escapeHtml(shortIssuer) +
+ "" +
+ '' +
+ escapeHtml(shortSubject) +
+ "" +
+ '' +
+ escapeHtml(oid.email || "\u2014") +
+ "" +
+ '' +
+ escapeHtml(lastLogin) +
+ "" +
+ '' +
+ '' +
+ "
";
+ }
+ body.innerHTML = html;
+ // Update panel height for animation
+ panel.style.maxHeight = panel.scrollHeight + "px";
+ // Bind unlink buttons
+ var btns = body.querySelectorAll("[data-oidc-issuer]");
+ for (var j = 0; j < btns.length; j++) {
+ btns[j].addEventListener("click", function (e) {
+ e.stopPropagation();
+ var issuer = this.getAttribute("data-oidc-issuer");
+ var subject = this.getAttribute("data-oidc-subject");
+ var uname = this.getAttribute("data-oidc-username");
+ var uid = this.getAttribute("data-oidc-user-id");
+ _confirmUnlinkOidc(issuer, subject, uname, uid);
+ });
+ }
+}
+
+function _confirmUnlinkOidc(issuer, subject, username, userId) {
+ var shortIssuer = _issuerShortName(issuer);
+ var shortSubject =
+ subject.length > 16 ? subject.slice(0, 16) + "\u2026" : subject;
+ showConfirmModal(
+ "Unlink OIDC Identity",
+ "Unlink " +
+ shortIssuer +
+ " identity \u2018" +
+ shortSubject +
+ "\u2019 from user " +
+ username +
+ "?\n\nThe user will need to log in via OIDC again to re-link.",
+ "Unlink",
+ function () {
+ authFetch(
+ "/v1/api/admin/oidc-identities?issuer=" +
+ encodeURIComponent(issuer) +
+ "&subject=" +
+ encodeURIComponent(subject),
+ { method: "DELETE" },
+ )
+ .then(function (r) {
+ if (!r.ok) throw new Error("Unlink failed");
+ showToast("OIDC identity unlinked");
+ // Refresh the panel content in place (no close/reopen flicker)
+ var allRows = document.querySelectorAll(
+ "#admin-users-table .admin-row[data-expandable]",
+ );
+ var targetRow = null;
+ for (var ri = 0; ri < allRows.length; ri++) {
+ if (allRows[ri].getAttribute("data-user-id") === userId) {
+ targetRow = allRows[ri];
+ break;
+ }
+ }
+ if (targetRow) {
+ var panel = targetRow.nextElementSibling;
+ if (panel && panel.classList.contains("oidc-detail-panel")) {
+ var body = panel.querySelector(".oidc-detail-body");
+ if (body)
+ body.innerHTML =
+ 'Loading\u2026';
+ authFetch(
+ "/v1/api/admin/users/" +
+ encodeURIComponent(userId) +
+ "/oidc-identities",
+ )
+ .then(function (r2) {
+ if (!r2.ok) throw new Error("Failed");
+ return r2.json();
+ })
+ .then(function (data) {
+ _renderOidcDetail(
+ panel,
+ data.oidc_identities || [],
+ userId,
+ username,
+ );
+ })
+ .catch(function () {
+ if (body)
+ body.innerHTML =
+ 'Failed to load';
+ });
+ }
+ }
+ })
+ .catch(function () {
+ showToast("Failed to unlink OIDC identity");
+ });
+ },
+ );
+}
+
+function _issuerShortName(issuer) {
+ try {
+ var host = new URL(issuer).hostname;
+ if (host.includes("google")) return "google";
+ if (host.includes("microsoftonline") || host.includes("azure"))
+ return "azure";
+ if (host.includes("okta")) return "okta";
+ if (host.includes("auth0")) return "auth0";
+ if (host.includes("keycloak")) return "keycloak";
+ return host.replace(/^(login|accounts|auth|id|sso)\./, "");
+ } catch (e) {
+ return issuer || "unknown";
+ }
+}
+
+function _relativeTime(isoStr) {
+ try {
+ var then = new Date(
+ isoStr + (isoStr.includes("Z") || isoStr.includes("+") ? "" : "Z"),
+ );
+ var diff = (Date.now() - then.getTime()) / 1000;
+ if (diff < 60) return "just now";
+ if (diff < 3600) return Math.floor(diff / 60) + "m ago";
+ if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
+ if (diff < 2592000) return Math.floor(diff / 86400) + "d ago";
+ return isoStr.slice(0, 10);
+ } catch (e) {
+ return isoStr || "unknown";
+ }
+}
+
// ---------------------------------------------------------------------------
// Tokens
// ---------------------------------------------------------------------------
diff --git a/turnstone/console/static/style.css b/turnstone/console/static/style.css
index 094fa202..26e1f744 100644
--- a/turnstone/console/static/style.css
+++ b/turnstone/console/static/style.css
@@ -1891,6 +1891,104 @@
.admin-action-btn-ghost{background:transparent;color:var(--fg-dim);border:1px solid var(--border-strong)}
.admin-action-btn-ghost:hover{color:var(--fg);background:var(--bg-highlight)}
+/* ==========================================================================
+ OIDC detail panel (inline expansion below user row)
+ ========================================================================== */
+.oidc-detail-panel {
+ max-height: 0;
+ overflow: hidden;
+ transition: max-height 150ms ease;
+ margin: 0 8px 0 24px;
+}
+.oidc-detail-inner {
+ border: 1px dashed var(--border);
+ border-radius: var(--radius-sm);
+ padding: 12px 16px;
+ margin-bottom: 8px;
+ background: var(--row-alt);
+}
+.oidc-detail-header {
+ font-family: var(--font-display);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--fg-dim);
+ margin-bottom: 8px;
+}
+.oidc-detail-header::before {
+ content: "\25c6 ";
+ color: var(--accent);
+}
+.oidc-identity-row {
+ display: grid;
+ grid-template-columns: 70px 100px 1fr 60px 50px;
+ gap: 8px;
+ padding: 5px 0;
+ font-size: 12px;
+ align-items: center;
+}
+.oidc-identity-row + .oidc-identity-row {
+ border-top: 1px solid var(--border);
+}
+.oidc-identity-issuer {
+ overflow: hidden;
+}
+.oidc-identity-issuer .scope-badge {
+ font-size: 10px;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.oidc-identity-subject {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--fg-dim);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.oidc-identity-email {
+ color: var(--fg-dim);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.oidc-identity-time {
+ color: var(--fg-dim);
+ font-size: 11px;
+}
+.oidc-identity-actions .admin-btn-danger { font-size: 11px; }
+.oidc-detail-empty {
+ color: var(--fg-dim);
+ font-size: 12px;
+ font-style: italic;
+}
+
+/* Expand indicator on user rows */
+.admin-row[data-expandable] { cursor: pointer; }
+.admin-row[data-expandable]:hover { background: var(--bg-highlight); }
+.admin-row[data-expandable]:hover .admin-expand-indicator { color: var(--fg); }
+.admin-row[data-expandable]:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+.admin-expand-indicator {
+ display: inline-block;
+ width: 14px;
+ font-size: 10px;
+ color: var(--fg-dim);
+ transition: transform 150ms ease;
+ transform-origin: center;
+}
+.admin-expand-indicator.expanded { transform: rotate(90deg); }
+
+@media (max-width: 700px) {
+ .oidc-identity-row { grid-template-columns: 70px 1fr 50px; }
+ .oidc-identity-email, .oidc-identity-time { display: none; }
+ .oidc-detail-panel { margin-left: 8px; }
+}
+
/* ==========================================================================
Reduced motion — console-specific
========================================================================== */
@@ -1909,4 +2007,5 @@
.admin-action-btn, .modal-cancel, .modal-submit { transition: none; }
.admin-modal input, .admin-modal select { transition: none; }
.mcp-status-dot.connecting { animation: none; }
+ .oidc-detail-panel, .admin-expand-indicator { transition: none; }
}