diff --git a/CHANGELOG.md b/CHANGELOG.md index 5788e42..7ac3353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to Chat Switchboard. +## [0.24.1] — 2026-03-07 + +### Added +- **mTLS auth provider.** `server/auth/mtls.go` — authenticates via reverse proxy headers (`X-SSL-Client-DN`, `X-SSL-Client-Verify`, `X-SSL-Client-Fingerprint`). Parses RFC 2253 distinguished names, auto-provisions users from cert CN, uses fingerprint as stable external identity. Configurable via `MTLS_HEADER_DN`, `MTLS_HEADER_VERIFY`, `MTLS_DEFAULT_ROLE` env vars. +- **OIDC auth provider.** `server/auth/oidc.go` — OpenID Connect with authorization code flow. JWKS key caching with auto-refresh on rotation, RSA token validation, split-horizon issuer support (`OIDC_ISSUER_URL` for backend, `OIDC_EXTERNAL_ISSUER_URL` for browser). Claim extraction for `preferred_username`, `email`, `name`, `groups`, `realm_access.roles`. Role mapping via configurable `OIDC_ADMIN_ROLE`. Group sync: adds/removes OIDC-sourced group memberships on every login based on IdP groups claim. +- **OIDC login flow.** `GET /api/v1/auth/oidc/login` redirects to IdP. `GET /api/v1/auth/oidc/callback` exchanges code for tokens, validates, auto-provisions user, issues internal JWT, redirects to login page with base64-encoded token fragment. Login page JS picks up `#oidc_result=...`, saves to localStorage, redirects to app. +- **Login page SSO button.** Template adapts by `AUTH_MODE`: builtin shows username/password form, OIDC shows "Sign in with SSO" button, mTLS shows certificate prompt. +- **Keycloak integration test environment.** `docker-compose-keycloak.yml` extends base compose with Keycloak 26 + pre-configured realm. `ci/keycloak-realm.json` includes switchboard client, group mapper, two test users (alice/user, bob/admin), two groups (engineering, leads). One command: `docker compose -f docker-compose.yml -f docker-compose-keycloak.yml up`. +- **`QArgs()` dialect adapter.** Handles Postgres `$N` placeholder reuse for SQLite — expands args to match positional `?` placeholders. Wrapper functions `database.QueryRow()`, `database.Query()`, `database.Exec()` (+ Context variants) replace `database.DB.Query(database.Q(...))` pattern with automatic arg expansion. +- **`QArgs` unit tests.** 5 tests covering reused placeholders, no-reuse passthrough, ILIKE rewrite, high placeholder numbers, Postgres no-op. +- **Channel list regression test.** `TestIntegration_ChannelListWithTypeFilter` exercises `types=direct,group` query path that previously returned 500 on SQLite. + +### Fixed +- **SQLite `_time_format` DSN parameter.** Removed `_time_format=2006-01-02T15:04:05Z` from `modernc.org/sqlite` connection string — this parameter is `mattn/go-sqlite3`-only and caused `database ping: unknown _time_format` on startup. +- **SQLite store wiring.** `main.go` store initialization now uses `sqliteStore.NewStores()` when `DB_DRIVER=sqlite` instead of always using `postgres.NewStores()`. Fixes nil pointer panic in health accumulator goroutine. +- **nginx resolver for localhost.** Changed `set $backend http://localhost:8080` to `http://127.0.0.1:8080` — IP literal doesn't require DNS resolution inside container. +- **nginx extension asset routing.** Changed `location /api/` to `location ^~ /api/` — prevents `location ~* \.(js)$` regex from intercepting `/api/v1/extensions/.../main.js` requests. +- **Channel list 500 on SQLite.** Count query uses `user_id = $1 OR ... participant_id = $1` — Postgres allows reusing `$1` but SQLite's `?` is positional. Fixed with `QArgs` arg expansion. +- **OIDC group sync FK constraint.** `findOrCreateOIDCGroup` now receives the provisioning user's ID as `created_by`, satisfying the `groups.created_by REFERENCES users(id)` foreign key. + +### Migration Notes +- **Migration 019:** `oidc_auth_state` table (ephemeral OIDC flow state), `groups.source` column (`manual`/`oidc`). +- **New env vars:** 10 mTLS (`MTLS_*`) + 11 OIDC (`OIDC_*`) + `AUTH_MODE` extended to accept `mtls`/`oidc`. + ## [0.24.0] — 2026-03-06 ### Added diff --git a/VERSION b/VERSION index 286d5b0..48b91fd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.24.0 \ No newline at end of file +0.24.1 diff --git a/ci/keycloak-realm.json b/ci/keycloak-realm.json new file mode 100644 index 0000000..e384528 --- /dev/null +++ b/ci/keycloak-realm.json @@ -0,0 +1,117 @@ +{ + "realm": "switchboard", + "enabled": true, + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "sslRequired": "none", + "roles": { + "realm": [ + { + "name": "sb-admin", + "description": "Switchboard admin role" + }, + { + "name": "sb-user", + "description": "Switchboard regular user role" + } + ] + }, + "groups": [ + { + "name": "engineering", + "path": "/engineering" + }, + { + "name": "leads", + "path": "/leads" + } + ], + "clients": [ + { + "clientId": "switchboard", + "name": "Chat Switchboard", + "enabled": true, + "publicClient": false, + "secret": "switchboard-secret", + "redirectUris": [ + "http://localhost:3000/*", + "http://localhost:8080/*" + ], + "webOrigins": [ + "http://localhost:3000", + "http://localhost:8080" + ], + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "protocol": "openid-connect", + "fullScopeAllowed": true, + "protocolMappers": [ + { + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "consentRequired": false, + "config": { + "full.path": "false", + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + } + ], + "users": [ + { + "username": "alice", + "email": "alice@switchboard.test", + "firstName": "Alice", + "lastName": "Engineer", + "enabled": true, + "emailVerified": true, + "credentials": [ + { + "type": "password", + "value": "alice123", + "temporary": false + } + ], + "realmRoles": [ + "sb-user", + "default-roles-switchboard" + ], + "groups": [ + "/engineering" + ] + }, + { + "username": "bob", + "email": "bob@switchboard.test", + "firstName": "Bob", + "lastName": "Lead", + "enabled": true, + "emailVerified": true, + "credentials": [ + { + "type": "password", + "value": "bob123", + "temporary": false + } + ], + "realmRoles": [ + "sb-admin", + "sb-user", + "default-roles-switchboard" + ], + "groups": [ + "/engineering", + "/leads" + ] + } + ] +} diff --git a/docker-compose-keycloak.yml b/docker-compose-keycloak.yml new file mode 100644 index 0000000..5c58a8a --- /dev/null +++ b/docker-compose-keycloak.yml @@ -0,0 +1,59 @@ +# docker-compose-keycloak.yml +# ============================================ +# Keycloak integration test environment. +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose-keycloak.yml up +# +# This extends the base docker-compose.yml to add: +# - Keycloak IdP with a pre-configured "switchboard" realm +# - Switchboard configured with AUTH_MODE=oidc pointing at Keycloak +# +# After startup: +# Switchboard: http://localhost:3000 +# Keycloak: http://localhost:8180 (admin / admin) +# +# Pre-configured test users in Keycloak: +# alice / alice123 (role: user, groups: [engineering]) +# bob / bob123 (role: admin, groups: [engineering, leads]) +# +# The realm is imported from ci/keycloak-realm.json on first startup. +# ============================================ + +services: + # Override switchboard to use OIDC + switchboard: + environment: + AUTH_MODE: oidc + OIDC_ISSUER_URL: http://keycloak:8080/realms/switchboard + OIDC_EXTERNAL_ISSUER_URL: http://localhost:8180/realms/switchboard + OIDC_CLIENT_ID: switchboard + OIDC_CLIENT_SECRET: switchboard-secret + OIDC_REDIRECT_URL: http://localhost:3000/api/v1/auth/oidc/callback + OIDC_AUTO_ACTIVATE: "true" + OIDC_ADMIN_ROLE: sb-admin + depends_on: + keycloak: + condition: service_healthy + + keycloak: + image: quay.io/keycloak/keycloak:26.0 + container_name: switchboard-keycloak + command: + - start-dev + - --import-realm + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + KC_HTTP_PORT: "8080" + KC_HEALTH_ENABLED: "true" + volumes: + - ./ci/keycloak-realm.json:/opt/keycloak/data/import/switchboard-realm.json:ro + ports: + - "8180:8080" + healthcheck: + test: ["CMD-SHELL", "cat < /dev/null > /dev/tcp/localhost/9000"] + interval: 10s + timeout: 5s + retries: 15 + start_period: 30s diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index cfc28a5..f05e518 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1351,7 +1351,7 @@ Depends on: v0.23.1 sidebar and conversation taxonomy. --- -## v0.24.0 — Auth Abstraction + User Identity ← active +## v0.24.0 — Auth Abstraction + User Identity ✅ Auth provider interface decoupling builtin auth from the handler/middleware layer. User model extended with `auth_source`, `external_id`, `handle`. @@ -1394,24 +1394,31 @@ Depends on: v0.23.0 (channel_participants), v0.16.0 (groups), v0.9.4 (vault). - [x] Frontend autocomplete matches on handle, uses handle as @mention token **Migration:** -- [ ] 018_auth_abstraction: `auth_source`, `external_id`, `handle` columns + unique indexes + backfill +- [x] 018_auth_abstraction: `auth_source`, `external_id`, `handle` columns + unique indexes + backfill --- -## v0.24.1 — mTLS + OIDC Providers +## v0.24.1 — mTLS + OIDC Providers ✅ Two external auth implementations plugging into v0.24.0's provider interface. +Keycloak integration test environment via docker-compose overlay. Depends on: auth abstraction (v0.24.0). See [DESIGN-0.24.0.md](DESIGN-0.24.0.md) §v0.24.1 for full spec. -- [ ] mTLS provider: trusted header extraction, cert DN parsing, auto-provision -- [ ] OIDC provider: well-known discovery, token validation, claim extraction -- [ ] Per-source auto-activate policy (global_config keys) -- [ ] OIDC claim → group mapping (external groups sync to internal groups) -- [ ] Login page adapts by `AUTH_MODE` (form / cert status / SSO button) -- [ ] mTLS users: no personal vault (`vault_set=false`), team/global providers only -- [ ] Migration 019: `oidc_auth_state` table, `groups.source` column +- [x] mTLS provider: trusted header extraction, cert DN parsing, auto-provision +- [x] OIDC provider: well-known discovery, JWKS caching, token validation, claim extraction +- [x] OIDC authorization code flow: login redirect, code exchange, token handoff via fragment +- [x] Split-horizon issuer support (`OIDC_EXTERNAL_ISSUER_URL` for Docker/K8s) +- [x] OIDC claim → group mapping (external groups sync to internal groups with `source=oidc`) +- [x] Login page adapts by `AUTH_MODE` (form / cert status / SSO button) +- [x] Keycloak docker-compose overlay with pre-configured realm (`ci/keycloak-realm.json`) +- [x] `QArgs()` dialect adapter: handles Postgres `$N` reuse for SQLite arg expansion +- [x] `database.QueryRow/Query/Exec` wrappers with automatic arg expansion +- [x] Migration 019: `oidc_auth_state` table, `groups.source` column +- [x] Unit tests: mTLS (ParseDN, config), OIDC (discovery, auth URL, mode), QArgs (5 tests) +- [x] Regression test: `TestIntegration_ChannelListWithTypeFilter` +- [x] SQLite local dev fixes: `_time_format` DSN, store wiring, nginx resolver, extension assets --- diff --git a/docs/TODO-0.23.2.md b/docs/TODO-0.23.2.md deleted file mode 100644 index cbfee6b..0000000 --- a/docs/TODO-0.23.2.md +++ /dev/null @@ -1,399 +0,0 @@ -# TODO — v0.23.2 - -Working checklist. Covers remaining v0.23.1 multi-user scope plus -deferred v0.22.8 surface integration and the channel persistence bug. -ROADMAP and CHANGELOG updated when work is complete. - ---- - -## Already Shipped (in 0.23.0 / 0.23.1 changesets) - -Backend foundation is in place. These are done and should not be -re-implemented. - -- [x] Migration 016: `ai_mode`, `topic` on channels; `user_presence` - table; channel type constraint extended to `dm`, `channel` -- [x] `ai_mode` guard in `completion.go` — `off` returns 403, - `mention_only` without @mention returns `{status: delivered}` -- [x] `resolveMention()` resolves users (steps 3–4: exact + prefix - on `users.username`, self-mention blocked) -- [x] User @mention in completion handler — skips AI, attempts - `NotifyUserMention` via interface cast (hub method not yet - implemented — silently no-ops) -- [x] Presence endpoints: `POST /presence/heartbeat` (upsert), - `GET /presence?users=...` (90s threshold query) -- [x] Folders: full CRUD handler (`handlers/folders.go`), wired routes -- [x] `CreateChannel` accepts `type` field (default `direct`) -- [x] `ListChannels` supports `?types=dm,channel` multi-filter -- [x] Three-section sidebar in `chat.html` (Projects / Channels / Chats) -- [x] `renderChannelsSection()` in `ui-core.js` (# / person icons, - unread badges, online dots, active state) -- [x] `selectChannel()`, `newChannelOrDM()`, `loadChannels()` in - `projects-ui.js` -- [x] Folder UI: create, rename, delete, context menu, drag chats -- [x] Chat list filters out `type=channel` and `type=dm` -- [x] API methods: `createChannel(title,model,sp,type)`, - `listSidebarChannels()`, `presenceHeartbeat()` -- [x] `persona_groups` + `persona_group_members` tables (migration 004) -- [x] `PersonaGroup` / `PersonaGroupMember` model structs -- [x] Channel participants: CRUD handler, auto-created on channel create - ---- - -## Bug Fixes - -- [x] **Channel persistence through refresh.** `loadChannels()` read - `resp.channels` but `ListChannels` returns a `paginatedResponse` - with `data` key. Channels created in-session appeared (pushed to - `App.channels` client-side) but vanished on reload because the - API response was always parsed as empty. - Fix: `resp.channels` → `resp.data` in `projects-ui.js`. - -- [x] **Folder drag-and-drop completely broken.** Three compounding - issues: - (a) `loadChats()` never mapped `c.folder` → `folderId` — all - chats rendered as unfiled regardless of DB state. - (b) Folder groups rendered "Drop chats here" but had zero drag - event handlers (`ondragover`/`ondrop` missing). - (c) No drop zone existed for unfiling a chat or removing from - a project — the Chats section body had no drag handlers. - Fixes: added `folderId: c.folder || null` to `loadChats()` map; - added `ondragover`/`ondragleave`/`ondrop` to `.sb-folder-group` - divs in `renderChatList()`; added `onChatSectionDrop` handler to - `#sbBodyChats` (handles both unfile-from-folder and remove-from- - project); added `onFolderDrop` and `moveChatToFolder` functions; - added "Move to folder" / "Remove from folder" to chat context - menu; added drag-over CSS for folders and section body. - -- [x] **DM creation 404 on `/api/v1/users`.** Route didn't exist. - Added `GET /api/v1/users/search?q=` endpoint returning id, - username, display_name for approved users (max 20, excludes - caller). Registered on `protected` group (any authed user). - Rewrote DM creation UI from text input to lazy search modal — - shows all users on open, filters as you type with 200ms debounce, - click to select and create DM. - -- [x] **No unfiled drop zone when folders exist.** When folders were - present, folder groups consumed all space in `#sbBodyChats`, - leaving no bare target to drag a chat out of a folder. Added - `.sb-unfiled-zone` wrapper around unfiled chats with its own - drag handlers. Shows "Drop here to unfile" hint when zone is - empty. `onUnfiledDrop` handles both unfile-from-folder and - remove-from-project. - -- [x] **No channel CRUD.** Channel sidebar items had no context menu - or management affordances. Added: right-click context menu with - Rename / Set topic (channels only) / Delete. Inline ✕ delete - button on hover (same pattern as chat items). `renameChannel()` - uses `_showCreationDialog` with pre-filled value. `deleteChannel()` - with confirm dialog removes from `App.channels` + `App.chats`, - clears active if deleted channel was selected. - -- [x] **Drag-over highlight persists.** `onChatDragEnd` only cleared - `.project-group.drag-over` — missed folders, unfiled zone, and - section body. Fixed to `document.querySelectorAll('.drag-over')` - so all drop targets are cleaned up when a drag ends anywhere. - -- [x] **Channel ⋯ menu inconsistency.** Channels used ✕ delete button - + right-click only, while folders used ⋯ hover button. Replaced - channel ✕ with `.sb-ch-menu` ⋯ button matching the folder - pattern (hidden, shown on hover, triggers context menu on click). - Both channels and folders now have ⋯ hover + right-click. - -- [x] **Folder delete silently spills chats.** `deleteFolder()` always - unfiled contained chats with no user input. Replaced with a - three-button modal when the folder has chats: "Keep chats" - (unfile and preserve), "Delete chats too" (hard-delete all - contained chats), or Cancel. Empty folders get a simple confirm. - Delete-all path iterates chats, calls `API.deleteChannel()` for - each, clears active state if needed, then removes the folder. - -- [x] **Folder ⋯ button shifts collapse arrow.** The ⋯ button used - `display:none/block` which caused reflow on hover, shifting the - arrow left. Fixed: swapped DOM order (⋯ before arrow), changed - to `visibility:hidden/visible` so space is always reserved. - Applied same fix to channel ⋯ button. Arrow gets fixed width - with `flex-shrink:0`. - -- [x] **Channels STILL not surviving refresh (round 2).** The unread - count subquery in `ListChannels` referenced - `cp.last_read_message_id` — a column added by migration 017. - If 017 hasn't been applied (fresh DB only has through 016), the - query fails and returns zero channels. Additionally, the `$1` - parameter was reused in Postgres (subquery + WHERE) which would - break SQLite's positional `?` binding. - Fix: rewrote unread subquery to use `last_read_at` (exists since - migration 005). Split `$1` reuse into `$1` (subquery) and `$2` - (WHERE), with `args` providing `userID` twice. `MarkRead` also - fixed to use `last_read_at` as primary, `last_read_message_id` - as best-effort (silently fails if 017 not applied). - -- [x] **Settings Models section empty.** Template `models` section - fell through to the generic `settingsDynamic` div, but - `loadUserModels()` wrote to `#userModelList` which didn't exist. - Added dedicated template sections for `models` and `teams`. - -- [x] **CI: TestIntegration_Messages_CRUD 500.** `ListMessages` JOIN - used `u.avatar` but the column is `avatar_url` in the users - table. Query failed on SQLite (Postgres would fail too). Same - bug in `treepath/path.go` `resolveSenderInfo()`. - Fix: `u.avatar` → `u.avatar_url` in both locations. - ---- - -## Design Decisions (resolved) - -**Unified active conversation.** `App.currentChatId` and -`App.currentChannelId` merge into `App.activeConversation = { id, type }` -where type is `direct|dm|group|channel`. The send path, model bar, -input state, streaming, session restore, and URL sync all key off -`activeConversation.id`. The type discriminator drives ai_mode checks, -context banner visibility, and participant rendering. Refactor touches -`selectChat()`, `selectChannel()`, `sendMessage()`, session restore in -`app.js`, and active-highlight in sidebar renderers. - -**DM deduplication.** One channel per participant pair. `CreateChannel` -for `type=dm` checks for an existing DM with the same two participants -before creating. Display name is the other party's name (from the -caller's perspective). For future 3+ human DMs: comma-join up to 2 -names + "and N others". - -**No LLMs in DMs.** DMs are human-to-human by definition. `ai_mode` -is `mention_only` — you can @mention a persona for a one-off response, -but personas are not participants. If you want persistent AI in a -conversation with another human, that's a Group Chat (`type=group`). -The taxonomy stays clean. - -**Post-creation participant mutation.** Users and personas can be added -or removed from any channel/group after creation. Participant CRUD -endpoints already exist. Constraints: DMs cannot drop below 2 human -participants; groups cannot drop their last persona (becomes a DM at -that point — block with error, not silent type coercion). - -**Channel deletion vs archival.** Governed by `channel_retention.mode`: -- `flexible` (default): delete or archive freely, user's choice. -- `retain`: archive only. Delete button becomes "Archive" everywhere. - `DELETE /channels/:id` returns 403 for non-admins. Admins purge - from the admin panel, subject to retention age floor. - -See "Retention Policy" section below for the full lifecycle. - ---- - -## 1 · Unified Active Conversation - -Prerequisite refactor. Do this first — everything else builds on it. - -- [x] **`App.activeConversation` object.** Replace `App.currentChatId` - and `App.currentChannelId` with `App.activeConversation = { id, - type }` (null when nothing selected). Update `app-state.js`. - Added `activeId` getter, `activeType` getter, `setActive(id,type)` - method, and `getActiveChat()` helper. -- [x] **`selectChat()` migration.** Sets - `App.activeConversation = { id: chatId, type: chat.type }`. - Session restore reads/writes `cs-active-conversation` (JSON). -- [x] **`selectChannel()` migration.** Sets - `App.activeConversation = { id, type: ch.type }`. Same session - storage key. Now also ensures conversation object in `App.chats` - for message caching and maps messages into standard shape. -- [x] **Send path.** `sendMessage()` reads `App.activeId` - for the channel ID. No behavior change — the backend already - handles all channel types on the same completion endpoint. -- [x] **Model bar, input state, streaming.** All `currentChatId` - references → `App.activeId`. All `App.chats.find(...)` for - active chat → `App.getActiveChat()`. 12 files updated. -- [x] **Sidebar active highlight.** Both `renderChatList()` and - `renderChannelsSection()` check `App.activeId` for the - active class. Selecting either clears the other. -- [x] **URL sync.** `history.replaceState` uses - `/chat/${activeConversation.id}` regardless of type. The Go - route handler already loads any channel by ID. -- [x] **Session restore.** Reads `cs-active-conversation` JSON, - checks both `App.chats` and `App.channels` arrays, calls - `selectChat()` or `selectChannel()` as appropriate. -- [x] **`newChannelOrDM()` integration.** Now pushes to both - `App.channels` (sidebar) and `App.chats` (message cache), - and sets the new channel as active. - ---- - -## 2 · Channel + DM Plumbing - -- [x] **NotifyUserMention on events hub.** Replaced broken gin context - interface cast with direct `h.hub.SendToUser()`. Delivers - `user.mentioned` event with channel_id, from_user, content - preview. Added `user.mentioned` to event route table as - `DirToClient`. Added `truncateContent()` helper. -- [x] **DM creation flow.** Sidebar "+" shows choice dialog (New Channel - / New DM). DM flow uses `_showCreationDialog` with username input, - looks up user by username, creates `type=dm` channel with - `participants: [targetId]`. Sets `ai_mode=mention_only` by default. -- [x] **DM dedup guard.** `CreateChannel` for `type=dm` queries - `channel_participants` for existing DM between the two users. - Returns existing channel (200) instead of creating duplicate (201). - Self-DM blocked with 400. -- [x] **Channel context banner.** `#channelContextBanner` div between - chat header and messages. `UI.updateContextBanner()` shows/hides - based on `App.activeConversation.type`. DM shows partner name + - @mention hint. Group shows leader/all routing hint. Channel shows - ai_mode + topic. CSS in `chat.css`. -- [x] **ai_mode and topic in API response.** Added `AiMode` and `Topic` - fields to `channelResponse`. `ListChannels` SELECT and Scan - updated. `CreateChannel` INSERTs `ai_mode` (defaults to - `mention_only` for DMs, `auto` otherwise). -- [x] **DM participant auto-creation.** `CreateChannel` adds all - `req.Participants` as member participants alongside the creator - (owner). Works for both Postgres and SQLite. -- [x] **Unread counts.** Add `last_read_message_id` and `last_read_at` - columns to `channel_participants`. Backend computes unread count - per channel per user. `listSidebarChannels` response includes - `unread_count`. Mark-read on select. - ---- - -## 3 · @mention UX for Users - -- [x] **Autocomplete includes users.** `channel-models.js` `onInput()` - builds candidates from `App.models` only. Add `App.users` list - (fetched once on startup from `GET /api/v1/users` or lightweight - endpoint). Merge into candidates with a person icon and different - accent color. Resolution order in autocomplete: personas first, - then users, then models (mirrors backend `resolveMention`). -- [x] **User mention pill styling.** `@username` pills render with a - different background color (e.g. blue/teal vs persona accent) - and person icon to distinguish human from AI mentions. -- [x] **Notification on mention.** `user.mentioned` WS event handler - in `chat.js` increments unread badge on channel sidebar item - and shows a toast with content preview. - ---- - -## 4 · Presence - -- [x] **Client heartbeat.** 30s `setInterval` calling - `API.presenceHeartbeat()`. Start on app init, pause on - `visibilitychange` hidden, resume on visible. -- [x] **Presence on channel load.** When rendering the Channels section, - collect DM partner IDs and call `GET /api/v1/presence?users=...` - to populate `App.presence`. Wire into `renderChannelsSection()` - online dot. -- [x] **Presence WebSocket events.** Hub broadcasts - `presence.changed { userId, status }` to sessions in shared - channels. Frontend updates `App.presence` and re-renders the - relevant sidebar item (not full re-render). - ---- - -## 5 · Persona Groups + Group Chat - -- [x] **Persona group CRUD endpoints.** Wire `persona_groups` and - `persona_group_members` to handlers. Routes: - `GET/POST /api/v1/persona-groups`, - `GET/PUT/DELETE /api/v1/persona-groups/:id`, - `POST/DELETE /api/v1/persona-groups/:id/members`. - `is_leader` flag on members. -- [x] **"New Group Chat" flow.** UI in sidebar "+" or separate button. - Option A: pick from saved persona group template. - Option B: ad-hoc persona picker (checkboxes). - Creates `type=group` channel, adds persona participants, sets - leader via `is_leader`. Humans and personas can be added - post-creation via participant CRUD. -- [x] **Group leader default response.** In `completion.go`, when - channel `type=group` and no @mention in content, resolve the - leader persona from `persona_group_members WHERE is_leader` - (via `channel_participants` → group membership). Use leader's - model/config/system prompt for the completion. -- [x] **`@all` fan-out.** When `@all` is detected in message content, - route to every persona participant. Reuse existing - `multiModelStream` path or sequential chain. Depth-1 only - (responses from @all do not trigger further chains). -- [x] **Participant mutation guards.** DMs: block removal if it would - drop below 2 human participants. Groups: block removal of last - persona (return 400 with clear error message, not silent type - coercion). - ---- - -## 6 · Message Attribution - -- [x] **Human sender display.** Messages from other human participants - show their avatar + display name instead of "You". Requires - the message response to include `sender_name`, `sender_avatar` - (or `participant_type=user` + `participant_id` that the frontend - resolves from a user cache). -- [x] **Persona sender display.** Already works for streaming via - avatar resolution. Verify it works for loaded history in - channel context (not just direct chats). - ---- - -## 7 · Channel Lifecycle - -- [x] **Archive action.** Context menu on channel/DM sidebar items: - "Archive". Sets `is_archived = true, archived_at = NOW()` via - `PUT /channels/:id`. Archived channels move to a collapsed - "Archived" sub-section at the bottom of the Channels list - (or hidden entirely with a "Show archived" toggle). -- [x] **Delete action (gated).** When `channel_retention.mode` is - `flexible` (default): context menu shows "Delete". Hard-deletes - the channel and all messages. When `retain`: context menu shows - "Archive" only. No delete option for non-admins. -- [x] **Admin purge.** Admin panel gets a "Channels" section or row - in an existing section showing archived channels with age. - "Purge" button hard-deletes. When `purge_after_days` is set, - purge is blocked for channels archived less than N days ago. -- [x] **`channel_retention` config keys.** Add to `global_config`: - `channel_retention.mode` (`flexible`|`retain`, default `flexible`), - `channel_retention.purge_after_days` (int|null, default null), - `channel_retention.archive_after_days` (int|null, default null). - Admin settings UI: "Retention" section under System. - ---- - -## 8 · Surface Integration (deferred v0.22.8) - -These were on the v0.22.8 checklist and haven't been wired yet. - -- [x] **ChatPane.primary bridge.** `ChatPane.create()` in `startApp()` - from server-rendered mount points. `UI.renderMessages` delegates - to `ChatPane.primary.renderMessages`. Currently `ChatPane.primary` - is defined but never assigned. -- [x] **Theme save/load cycle.** Wire `Theme` (from - `ui-primitives-additions.js`) into settings appearance save/load. - Persist preference to user settings API. CM6 theme sync on - change. -- [x] **Admin hybrid section loaders.** Complete all JS-loaded admin - sections (some still show empty panels). -- [x] **Settings surface completeness.** Models visibility toggles, - User Personas CRUD, BYOK provider CRUD, Teams tab content. - ---- - -## Forward: Retention Policy Automation (post-0.23.2) - -Not in scope for 0.23.2 but documents the planned direction. - -**Lifecycle:** Active → Archived → Purged. Each transition is either -user-initiated or policy-driven. - -**Auto-archive scanner.** Background job (same pattern as compaction -scanner). When `channel_retention.archive_after_days` is set, queries -`channels WHERE is_archived = false AND updated_at < NOW() - interval` -and sets `is_archived = true`. Runs on a configurable schedule (hourly -default). Workflow channels auto-archive when their final stage -completes — the scanner catches anything the workflow engine misses. - -**Auto-purge scanner.** When `channel_retention.purge_after_days` is -set, queries `channels WHERE is_archived = true AND archived_at < -NOW() - interval` and hard-deletes in batches. This is also the floor -for manual admin purge — admins cannot purge channels archived less -than N days ago. - -**Audit trail.** Every archive and purge action (manual or automatic) -gets an audit log entry: `action = "channel.archived"` or -`action = "channel.purged"` with actor (user ID or "system"), channel -metadata snapshot, and timestamp. - -Slots naturally into v0.24.x alongside RBAC (retention policy is -inherently an admin/compliance concern) or as a standalone 0.23.x -minor if it lands before auth work starts. diff --git a/nginx.conf b/nginx.conf index 8984661..45e4e1c 100644 --- a/nginx.conf +++ b/nginx.conf @@ -5,7 +5,7 @@ server { index index.html; # Backend upstream (unified container: localhost:8080) - set $backend http://localhost:8080; + set $backend http://127.0.0.1:8080; # Gzip gzip on; @@ -21,7 +21,7 @@ server { } # ── API + WebSocket → backend ──────────── - location /api/ { + location ^~ /api/ { proxy_pass $backend; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/server/auth/mock_idp_test.go b/server/auth/mock_idp_test.go new file mode 100644 index 0000000..75ae57c --- /dev/null +++ b/server/auth/mock_idp_test.go @@ -0,0 +1,176 @@ +package auth_test + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// mockIdP is a test OIDC identity provider that serves discovery, +// JWKS, and token endpoints. It signs JWTs with a known RSA key +// so the OIDCProvider can validate them in tests. +type mockIdP struct { + server *httptest.Server + privateKey *rsa.PrivateKey + kid string +} + +func newMockIdP() *mockIdP { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic("generate RSA key: " + err.Error()) + } + + m := &mockIdP{ + privateKey: key, + kid: "test-key-1", + } + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", m.handleDiscovery) + mux.HandleFunc("/protocol/openid-connect/certs", m.handleJWKS) + mux.HandleFunc("/protocol/openid-connect/token", m.handleToken) + + m.server = httptest.NewServer(mux) + return m +} + +func (m *mockIdP) Close() { + m.server.Close() +} + +func (m *mockIdP) IssuerURL() string { + return m.server.URL +} + +func (m *mockIdP) handleDiscovery(w http.ResponseWriter, r *http.Request) { + base := m.server.URL + disc := map[string]string{ + "issuer": base, + "authorization_endpoint": base + "/protocol/openid-connect/auth", + "token_endpoint": base + "/protocol/openid-connect/token", + "jwks_uri": base + "/protocol/openid-connect/certs", + "userinfo_endpoint": base + "/protocol/openid-connect/userinfo", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(disc) +} + +func (m *mockIdP) handleJWKS(w http.ResponseWriter, r *http.Request) { + pub := m.privateKey.PublicKey + jwks := map[string]interface{}{ + "keys": []map[string]string{ + { + "kid": m.kid, + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()), + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(jwks) +} + +func (m *mockIdP) handleToken(w http.ResponseWriter, r *http.Request) { + // Simple password grant for testing + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", 400) + return + } + + username := r.FormValue("username") + if username == "" { + username = "testuser" + } + + token := m.issueToken(username, username+"@test.local", username, nil, nil) + resp := map[string]interface{}{ + "access_token": token, + "id_token": token, + "token_type": "Bearer", + "expires_in": 3600, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// issueToken creates a signed JWT with the given claims. +func (m *mockIdP) issueToken(sub, email, name string, groups []string, roles []string) string { + claims := jwt.MapClaims{ + "iss": m.server.URL, + "sub": sub, + "aud": "switchboard", + "exp": time.Now().Add(1 * time.Hour).Unix(), + "iat": time.Now().Unix(), + "email": email, + "preferred_username": name, + "name": name, + } + if groups != nil { + claims["groups"] = groups + } + if roles != nil { + claims["realm_access"] = map[string]interface{}{ + "roles": roles, + } + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = m.kid + + signed, err := token.SignedString(m.privateKey) + if err != nil { + panic("sign token: " + err.Error()) + } + return signed +} + +// issueExpiredToken creates a token that's already expired. +func (m *mockIdP) issueExpiredToken(sub string) string { + claims := jwt.MapClaims{ + "iss": m.server.URL, + "sub": sub, + "aud": "switchboard", + "exp": time.Now().Add(-1 * time.Hour).Unix(), + "iat": time.Now().Add(-2 * time.Hour).Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = m.kid + + signed, err := token.SignedString(m.privateKey) + if err != nil { + panic("sign expired token: " + err.Error()) + } + return signed +} + +// issueTokenBadIssuer creates a token with a mismatched issuer. +func (m *mockIdP) issueTokenBadIssuer(sub string) string { + claims := jwt.MapClaims{ + "iss": "https://evil.example.com", + "sub": sub, + "aud": "switchboard", + "exp": time.Now().Add(1 * time.Hour).Unix(), + "iat": time.Now().Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = m.kid + + signed, err := token.SignedString(m.privateKey) + if err != nil { + panic("sign bad issuer token: " + err.Error()) + } + return signed +} diff --git a/server/auth/mtls.go b/server/auth/mtls.go new file mode 100644 index 0000000..53fc7d5 --- /dev/null +++ b/server/auth/mtls.go @@ -0,0 +1,184 @@ +package auth + +import ( + "context" + "fmt" + "log" + "strings" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// MTLSConfig holds mTLS-specific configuration. +type MTLSConfig struct { + HeaderDN string // header carrying cert DN (default "X-SSL-Client-DN") + HeaderVerify string // header carrying verify status (default "X-SSL-Client-Verify") + HeaderFingerprint string // header carrying cert fingerprint (default "X-SSL-Client-Fingerprint") + AutoActivate bool // auto-activate new users (default true) + DefaultTeam string // team ID for auto-provisioned users (optional) + DefaultRole string // "user" (default) or "admin" +} + +// MTLSProvider authenticates via client certificate headers injected +// by the TLS-terminating reverse proxy (nginx, Traefik, Istio). +// +// The backend never sees the actual TLS handshake — it trusts headers +// injected by the proxy after cert validation. After reading and parsing +// the DN, the provider resolves an existing user or auto-provisions a +// new one, then returns a Result. The auth handler issues an internal JWT. +// +// Header flow: +// +// Client cert → nginx ssl_verify_client → injects X-SSL-Client-DN, +// X-SSL-Client-Verify, X-SSL-Client-Fingerprint → backend reads headers. +type MTLSProvider struct { + cfg MTLSConfig +} + +func NewMTLSProvider(cfg MTLSConfig) *MTLSProvider { + if cfg.HeaderDN == "" { + cfg.HeaderDN = "X-SSL-Client-DN" + } + if cfg.HeaderVerify == "" { + cfg.HeaderVerify = "X-SSL-Client-Verify" + } + if cfg.HeaderFingerprint == "" { + cfg.HeaderFingerprint = "X-SSL-Client-Fingerprint" + } + if cfg.DefaultRole == "" { + cfg.DefaultRole = models.UserRoleUser + } + return &MTLSProvider{cfg: cfg} +} + +func (p *MTLSProvider) Mode() Mode { return ModeMTLS } + +func (p *MTLSProvider) SupportsRegistration() bool { return false } + +func (p *MTLSProvider) Register(_ *gin.Context, _ store.Stores) (*Result, error) { + return nil, ErrNotSupported +} + +// Authenticate reads the cert DN and verify status from proxy headers, +// parses the DN fields, and resolves or auto-provisions the user. +// +// Returns ErrInvalidCreds when headers are missing or verify fails. +// Returns ErrInactive when user exists but is deactivated. +func (p *MTLSProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) { + // ── Validate headers ─────────────────────────────────────────── + verify := c.GetHeader(p.cfg.HeaderVerify) + if verify == "" { + return nil, fmt.Errorf("%w: missing %s header", ErrInvalidCreds, p.cfg.HeaderVerify) + } + // nginx: "SUCCESS", Traefik: "0" (both mean valid cert) + if verify != "SUCCESS" && verify != "0" { + return nil, fmt.Errorf("%w: cert verify=%s", ErrInvalidCreds, verify) + } + + dn := c.GetHeader(p.cfg.HeaderDN) + if dn == "" { + return nil, fmt.Errorf("%w: missing %s header", ErrInvalidCreds, p.cfg.HeaderDN) + } + + fields := ParseDN(dn) + cn := fields["CN"] + if cn == "" { + return nil, fmt.Errorf("%w: cert DN has no CN field", ErrInvalidCreds) + } + + // Stable external identity: fingerprint if available, else full DN + fingerprint := c.GetHeader(p.cfg.HeaderFingerprint) + if fingerprint == "" { + fingerprint = dn + } + + ctx := c.Request.Context() + + // ── Look up existing user ────────────────────────────────────── + user, err := stores.Users.GetByExternalID(ctx, string(ModeMTLS), fingerprint) + if err == nil && user != nil { + if !user.IsActive { + return nil, ErrInactive + } + log.Printf("[auth/mtls] existing user %s (%s)", user.Username, cn) + return &Result{User: user, IsNewUser: false, VaultHint: ""}, nil + } + + // ── Auto-provision ───────────────────────────────────────────── + if !p.cfg.AutoActivate { + return nil, fmt.Errorf("%w: auto-provision disabled", ErrInvalidCreds) + } + + return p.autoProvision(ctx, fields, fingerprint, stores) +} + +func (p *MTLSProvider) autoProvision( + ctx context.Context, + dn map[string]string, + fingerprint string, + stores store.Stores, +) (*Result, error) { + cn := dn["CN"] + + email := dn["emailAddress"] + if email == "" { + email = models.HandleFromName(cn) + "@mtls.local" + } + + handle := UniqueHandle(ctx, stores.Users, models.HandleFromName(cn)) + + user := &models.User{ + Username: handle, + Email: email, + DisplayName: cn, + Role: p.cfg.DefaultRole, + IsActive: true, + AuthSource: string(ModeMTLS), + ExternalID: &fingerprint, + Handle: handle, + } + + if err := stores.Users.Create(ctx, user); err != nil { + return nil, fmt.Errorf("auto-provision failed: %w", err) + } + + log.Printf("[auth/mtls] auto-provisioned user %s from CN=%s", user.ID, cn) + + // Auto-add to default team if configured + if p.cfg.DefaultTeam != "" { + if err := stores.Teams.AddMember(ctx, p.cfg.DefaultTeam, user.ID, "member"); err != nil { + log.Printf("[auth/mtls] warn: could not add %s to default team: %v", user.ID, err) + } + } + + return &Result{User: user, IsNewUser: true, VaultHint: ""}, nil +} + +// ParseDN parses an RFC 2253 / RFC 4514 distinguished name into key-value pairs. +// +// Examples: +// +// "CN=Jeff Smith,O=Acme Corp,OU=Engineering" +// "CN=Jane Doe,emailAddress=jane@acme.com,O=Acme Corp" +// +// Handles simple comma-separated key=value pairs. Does NOT handle +// escaped commas in values (\,) or multi-valued RDNs (+). Sufficient +// for typical X.509 client cert DNs. +func ParseDN(dn string) map[string]string { + result := make(map[string]string) + parts := strings.Split(dn, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + idx := strings.Index(part, "=") + if idx < 1 { + continue + } + key := strings.TrimSpace(part[:idx]) + val := strings.TrimSpace(part[idx+1:]) + result[key] = val + } + return result +} diff --git a/server/auth/mtls_test.go b/server/auth/mtls_test.go new file mode 100644 index 0000000..9de1691 --- /dev/null +++ b/server/auth/mtls_test.go @@ -0,0 +1,135 @@ +package auth + +import ( + "testing" + + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +func TestParseDN(t *testing.T) { + tests := []struct { + name string + dn string + expect map[string]string + }{ + { + name: "standard cert DN", + dn: "CN=Jeff Smith,O=Acme Corp,OU=Engineering", + expect: map[string]string{ + "CN": "Jeff Smith", + "O": "Acme Corp", + "OU": "Engineering", + }, + }, + { + name: "with email", + dn: "CN=Jane Doe,emailAddress=jane@acme.com,O=Acme Corp", + expect: map[string]string{ + "CN": "Jane Doe", + "emailAddress": "jane@acme.com", + "O": "Acme Corp", + }, + }, + { + name: "spaces around equals", + dn: "CN = Test User , O = Test Org", + expect: map[string]string{ + "CN": "Test User", + "O": "Test Org", + }, + }, + { + name: "single field", + dn: "CN=Solo", + expect: map[string]string{ + "CN": "Solo", + }, + }, + { + name: "empty string", + dn: "", + expect: map[string]string{}, + }, + { + name: "no equals sign", + dn: "garbage,data", + expect: map[string]string{}, + }, + { + name: "country and state", + dn: "CN=Server,C=US,ST=Maryland,L=Laurel,O=DoD", + expect: map[string]string{ + "CN": "Server", + "C": "US", + "ST": "Maryland", + "L": "Laurel", + "O": "DoD", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseDN(tt.dn) + for k, want := range tt.expect { + got := result[k] + if got != want { + t.Errorf("ParseDN(%q)[%q] = %q, want %q", tt.dn, k, got, want) + } + } + if len(result) != len(tt.expect) { + t.Errorf("ParseDN(%q) returned %d fields, want %d", tt.dn, len(result), len(tt.expect)) + } + }) + } +} + +func TestMTLSConfig_Defaults(t *testing.T) { + p := NewMTLSProvider(MTLSConfig{}) + + if p.cfg.HeaderDN != "X-SSL-Client-DN" { + t.Errorf("HeaderDN = %q, want X-SSL-Client-DN", p.cfg.HeaderDN) + } + if p.cfg.HeaderVerify != "X-SSL-Client-Verify" { + t.Errorf("HeaderVerify = %q, want X-SSL-Client-Verify", p.cfg.HeaderVerify) + } + if p.cfg.HeaderFingerprint != "X-SSL-Client-Fingerprint" { + t.Errorf("HeaderFingerprint = %q, want X-SSL-Client-Fingerprint", p.cfg.HeaderFingerprint) + } + if p.cfg.DefaultRole != "user" { + t.Errorf("DefaultRole = %q, want user", p.cfg.DefaultRole) + } +} + +func TestMTLSConfig_Custom(t *testing.T) { + p := NewMTLSProvider(MTLSConfig{ + HeaderDN: "X-Client-Cert-DN", + HeaderVerify: "X-Client-Cert-Verify", + DefaultRole: "admin", + }) + + if p.cfg.HeaderDN != "X-Client-Cert-DN" { + t.Errorf("HeaderDN = %q, want X-Client-Cert-DN", p.cfg.HeaderDN) + } + if p.cfg.DefaultRole != "admin" { + t.Errorf("DefaultRole = %q, want admin", p.cfg.DefaultRole) + } +} + +func TestMTLSProvider_Mode(t *testing.T) { + p := NewMTLSProvider(MTLSConfig{}) + if p.Mode() != ModeMTLS { + t.Errorf("Mode() = %q, want %q", p.Mode(), ModeMTLS) + } +} + +func TestMTLSProvider_NoRegistration(t *testing.T) { + p := NewMTLSProvider(MTLSConfig{}) + if p.SupportsRegistration() { + t.Error("mTLS should not support registration") + } + _, err := p.Register(nil, store.Stores{}) + if err != ErrNotSupported { + t.Errorf("Register() = %v, want ErrNotSupported", err) + } +} diff --git a/server/auth/oidc.go b/server/auth/oidc.go new file mode 100644 index 0000000..5c7f50d --- /dev/null +++ b/server/auth/oidc.go @@ -0,0 +1,587 @@ +package auth + +import ( + "context" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "math/big" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// OIDCConfig holds OIDC-specific configuration from env vars. +type OIDCConfig struct { + IssuerURL string // e.g. "https://keycloak.corp/realms/switchboard" + ExternalIssuerURL string // browser-facing URL (optional, defaults to IssuerURL) + ClientID string + ClientSecret string + RedirectURL string // e.g. "https://switchboard.corp/api/v1/auth/oidc/callback" + AutoActivate bool // auto-activate new users (default true) + DefaultTeam string // team ID for auto-provisioned users (optional) + DefaultRole string // "user" (default) or "admin" + RolesClaim string // JWT claim for role mapping (default "realm_access.roles") + GroupsClaim string // JWT claim for group sync (default "groups") + AdminRole string // IdP role value that maps to admin (default "admin") +} + +// OIDCProvider authenticates via OpenID Connect tokens from an external IdP. +// +// Supports two flows: +// 1. Authorization code flow: browser redirects to IdP, callback exchanges +// code for tokens. Used by the login page. +// 2. Direct token validation: API clients present a bearer token from the +// IdP, backend validates signature + claims. Used by programmatic access. +// +// JWKS keys are cached and refreshed periodically. +type OIDCProvider struct { + cfg OIDCConfig + discovery *OIDCDiscovery + keys map[string]*rsa.PublicKey + keysMu sync.RWMutex + keysAt time.Time + client *http.Client +} + +// OIDCDiscovery holds the OIDC well-known configuration. +type OIDCDiscovery struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JWKSUri string `json:"jwks_uri"` + UserinfoEndpoint string `json:"userinfo_endpoint"` +} + +type oidcJWKS struct { + Keys []oidcJWK `json:"keys"` +} + +type oidcJWK struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + Alg string `json:"alg"` + Use string `json:"use"` + N string `json:"n"` + E string `json:"e"` +} + +type oidcTokenResponse struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` +} + +func NewOIDCProvider(cfg OIDCConfig) (*OIDCProvider, error) { + if cfg.IssuerURL == "" { + return nil, fmt.Errorf("OIDC_ISSUER_URL is required") + } + if cfg.ClientID == "" { + return nil, fmt.Errorf("OIDC_CLIENT_ID is required") + } + if cfg.DefaultRole == "" { + cfg.DefaultRole = models.UserRoleUser + } + if cfg.RolesClaim == "" { + cfg.RolesClaim = "realm_access.roles" + } + if cfg.GroupsClaim == "" { + cfg.GroupsClaim = "groups" + } + if cfg.AdminRole == "" { + cfg.AdminRole = "admin" + } + + p := &OIDCProvider{ + cfg: cfg, + keys: make(map[string]*rsa.PublicKey), + client: &http.Client{Timeout: 10 * time.Second}, + } + + // Fetch discovery document on startup + if err := p.fetchDiscovery(); err != nil { + return nil, fmt.Errorf("OIDC discovery failed: %w", err) + } + + // Pre-fetch JWKS + if err := p.refreshKeys(); err != nil { + log.Printf("[auth/oidc] warn: initial JWKS fetch failed: %v (will retry on first request)", err) + } + + return p, nil +} + +func (p *OIDCProvider) Mode() Mode { return ModeOIDC } + +func (p *OIDCProvider) SupportsRegistration() bool { return false } + +func (p *OIDCProvider) Register(_ *gin.Context, _ store.Stores) (*Result, error) { + return nil, ErrNotSupported +} + +// Authenticate validates a bearer token from the OIDC IdP. +// +// For the authorization code callback flow, use ExchangeCode() first +// to get the token, then call Authenticate with the token in the +// Authorization header. +func (p *OIDCProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) { + // Extract token from Authorization header + header := c.GetHeader("Authorization") + if header == "" { + return nil, fmt.Errorf("%w: missing Authorization header", ErrInvalidCreds) + } + tokenString := strings.TrimPrefix(header, "Bearer ") + if tokenString == header { + return nil, fmt.Errorf("%w: invalid authorization format", ErrInvalidCreds) + } + + // Validate and parse the token + claims, err := p.validateToken(tokenString) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidCreds, err) + } + + ctx := c.Request.Context() + sub := claims.Subject + + // Look up existing user by external ID + user, err := stores.Users.GetByExternalID(ctx, string(ModeOIDC), sub) + if err == nil && user != nil { + if !user.IsActive { + return nil, ErrInactive + } + + // Re-evaluate role on every login + role := p.resolveRole(claims) + if role != user.Role { + stores.Users.Update(ctx, user.ID, map[string]interface{}{"role": role}) + user.Role = role + } + + // Sync groups + p.syncGroups(ctx, user.ID, claims, stores) + + log.Printf("[auth/oidc] existing user %s (sub=%s)", user.Username, sub) + return &Result{User: user, IsNewUser: false, VaultHint: ""}, nil + } + + // Auto-provision + if !p.cfg.AutoActivate { + return nil, fmt.Errorf("%w: auto-provision disabled", ErrInvalidCreds) + } + + return p.autoProvision(ctx, claims, stores) +} + +// ExchangeCode exchanges an authorization code for tokens. +// Called by the /auth/oidc/callback handler. +func (p *OIDCProvider) ExchangeCode(ctx context.Context, code, redirectURI string) (*oidcTokenResponse, error) { + if p.discovery == nil { + return nil, fmt.Errorf("OIDC discovery not loaded") + } + + data := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {redirectURI}, + "client_id": {p.cfg.ClientID}, + } + if p.cfg.ClientSecret != "" { + data.Set("client_secret", p.cfg.ClientSecret) + } + + resp, err := p.client.PostForm(p.discovery.TokenEndpoint, data) + if err != nil { + return nil, fmt.Errorf("token exchange failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body)) + } + + var tokenResp oidcTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return nil, fmt.Errorf("decode token response: %w", err) + } + + return &tokenResp, nil +} + +// AuthorizationURL returns the IdP login URL for the authorization code flow. +func (p *OIDCProvider) AuthorizationURL(state, nonce, redirectURI string) string { + if p.discovery == nil { + return "" + } + params := url.Values{ + "response_type": {"code"}, + "client_id": {p.cfg.ClientID}, + "redirect_uri": {redirectURI}, + "scope": {"openid profile email"}, + "state": {state}, + "nonce": {nonce}, + } + authEndpoint := p.discovery.AuthorizationEndpoint + if p.cfg.ExternalIssuerURL != "" { + // Rewrite internal hostname to external for browser redirect + authEndpoint = strings.Replace(authEndpoint, + strings.TrimRight(p.cfg.IssuerURL, "/"), + strings.TrimRight(p.cfg.ExternalIssuerURL, "/"), 1) + } + return authEndpoint + "?" + params.Encode() +} + +// ── Token Validation ──────────────────────────────────────────────── + +type oidcClaims struct { + jwt.RegisteredClaims + Email string `json:"email"` + PreferredUsername string `json:"preferred_username"` + Name string `json:"name"` + Groups []string `json:"groups"` + RealmAccess realmAccess `json:"realm_access"` + ResourceAccess interface{} `json:"resource_access"` +} + +type realmAccess struct { + Roles []string `json:"roles"` +} + +func (p *OIDCProvider) validateToken(tokenString string) (*oidcClaims, error) { + claims := &oidcClaims{} + + token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) { + // Ensure RSA signing + if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + + kid, _ := t.Header["kid"].(string) + if kid == "" { + return nil, fmt.Errorf("token has no kid header") + } + + key := p.getKey(kid) + if key == nil { + // Key not found — try refreshing JWKS (key rotation) + if err := p.refreshKeys(); err != nil { + return nil, fmt.Errorf("JWKS refresh failed: %w", err) + } + key = p.getKey(kid) + } + if key == nil { + return nil, fmt.Errorf("unknown signing key: %s", kid) + } + + return key, nil + }) + + if err != nil { + return nil, err + } + if !token.Valid { + return nil, fmt.Errorf("token is invalid") + } + + // Verify issuer — accept both internal and external (split-horizon). + // In Docker/K8s, the backend reaches Keycloak via internal hostname + // but the browser (and thus the token's iss claim) uses the external URL. + issuer := strings.TrimRight(p.cfg.IssuerURL, "/") + externalIssuer := strings.TrimRight(p.cfg.ExternalIssuerURL, "/") + tokenIssuer := strings.TrimRight(claims.Issuer, "/") + if tokenIssuer != issuer && (externalIssuer == "" || tokenIssuer != externalIssuer) { + return nil, fmt.Errorf("issuer mismatch: got %s, want %s (or %s)", claims.Issuer, issuer, externalIssuer) + } + + return claims, nil +} + +func (p *OIDCProvider) resolveRole(claims *oidcClaims) string { + // Check realm_access.roles for admin role + for _, role := range claims.RealmAccess.Roles { + if role == p.cfg.AdminRole { + return models.UserRoleAdmin + } + } + return p.cfg.DefaultRole +} + +// syncGroups compares IdP groups claim with internal group memberships +// and adds/removes as needed. OIDC-synced groups have source='oidc'. +func (p *OIDCProvider) syncGroups(ctx context.Context, userID string, claims *oidcClaims, stores store.Stores) { + idpGroups := claims.Groups + if len(idpGroups) == 0 { + // Also try extracting from a nested claim path + idpGroups = p.extractGroups(claims) + } + if len(idpGroups) == 0 { + return + } + + // Get user's current group IDs + currentGroupIDs, err := stores.Groups.GetUserGroupIDs(ctx, userID) + if err != nil { + log.Printf("[auth/oidc] warn: could not get user groups: %v", err) + return + } + + // Build set of current OIDC-sourced groups the user belongs to + currentOIDCGroups := make(map[string]string) // group name → group ID + for _, gid := range currentGroupIDs { + g, err := stores.Groups.GetByID(ctx, gid) + if err != nil { + continue + } + // Only track OIDC-sourced groups — manual groups are untouched + if g.Source == "oidc" { + currentOIDCGroups[g.Name] = g.ID + } + } + + // Ensure each IdP group exists and user is a member + idpGroupSet := make(map[string]bool) + for _, gname := range idpGroups { + gname = strings.TrimSpace(gname) + if gname == "" { + continue + } + idpGroupSet[gname] = true + + if _, already := currentOIDCGroups[gname]; already { + continue // already a member + } + + // Find or create the group + groupID := p.findOrCreateOIDCGroup(ctx, gname, userID, stores) + if groupID == "" { + continue + } + + // Add membership + if err := stores.Groups.AddMember(ctx, groupID, userID, userID); err != nil { + log.Printf("[auth/oidc] warn: could not add %s to group %s: %v", userID, gname, err) + } else { + log.Printf("[auth/oidc] added user %s to OIDC group %s", userID, gname) + } + } + + // Remove from OIDC groups no longer in the IdP claim + for gname, gid := range currentOIDCGroups { + if !idpGroupSet[gname] { + if err := stores.Groups.RemoveMember(ctx, gid, userID); err != nil { + log.Printf("[auth/oidc] warn: could not remove %s from group %s: %v", userID, gname, err) + } else { + log.Printf("[auth/oidc] removed user %s from OIDC group %s (no longer in IdP)", userID, gname) + } + } + } +} + +func (p *OIDCProvider) findOrCreateOIDCGroup(ctx context.Context, name, createdBy string, stores store.Stores) string { + // Search existing OIDC-sourced groups by name + groups, err := stores.Groups.ListAll(ctx) + if err != nil { + return "" + } + for _, g := range groups { + if g.Name == name && g.Source == "oidc" { + return g.ID + } + } + + // Create new OIDC group + g := &models.Group{ + Name: name, + Description: "Synced from OIDC", + Scope: "global", + Source: "oidc", + CreatedBy: createdBy, + } + if err := stores.Groups.Create(ctx, g); err != nil { + log.Printf("[auth/oidc] warn: could not create group %s: %v", name, err) + return "" + } + log.Printf("[auth/oidc] created OIDC group %s (%s)", name, g.ID) + return g.ID +} + +func (p *OIDCProvider) extractGroups(claims *oidcClaims) []string { + // Keycloak puts groups in different places depending on mapper config. + // Try the configured claim path as a fallback. + // The claims.Groups field handles the simple case. This handles + // nested paths like "resource_access.switchboard.roles". + // For now, return empty — the direct Groups field covers the + // standard Keycloak group mapper output. + return nil +} + +func (p *OIDCProvider) autoProvision( + ctx context.Context, + claims *oidcClaims, + stores store.Stores, +) (*Result, error) { + sub := claims.Subject + + username := claims.PreferredUsername + if username == "" { + username = claims.Email + } + if username == "" { + username = sub + } + + email := claims.Email + if email == "" { + email = models.HandleFromName(username) + "@oidc.local" + } + + displayName := claims.Name + if displayName == "" { + displayName = username + } + + role := p.resolveRole(claims) + handle := UniqueHandle(ctx, stores.Users, models.HandleFromName(username)) + + user := &models.User{ + Username: strings.ToLower(username), + Email: strings.ToLower(email), + DisplayName: displayName, + Role: role, + IsActive: true, + AuthSource: string(ModeOIDC), + ExternalID: &sub, + Handle: handle, + } + + if err := stores.Users.Create(ctx, user); err != nil { + return nil, fmt.Errorf("auto-provision failed: %w", err) + } + + log.Printf("[auth/oidc] auto-provisioned user %s (sub=%s, email=%s)", user.ID, sub, email) + + // Auto-add to default team + if p.cfg.DefaultTeam != "" { + if err := stores.Teams.AddMember(ctx, p.cfg.DefaultTeam, user.ID, "member"); err != nil { + log.Printf("[auth/oidc] warn: could not add %s to default team: %v", user.ID, err) + } + } + + // Sync groups + p.syncGroups(ctx, user.ID, claims, stores) + + return &Result{User: user, IsNewUser: true, VaultHint: ""}, nil +} + +// ── JWKS Management ───────────────────────────────────────────────── + +func (p *OIDCProvider) fetchDiscovery() error { + discoveryURL := strings.TrimRight(p.cfg.IssuerURL, "/") + "/.well-known/openid-configuration" + + resp, err := p.client.Get(discoveryURL) + if err != nil { + return fmt.Errorf("fetch %s: %w", discoveryURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("discovery returned %d: %s", resp.StatusCode, string(body)) + } + + var disc OIDCDiscovery + if err := json.NewDecoder(resp.Body).Decode(&disc); err != nil { + return fmt.Errorf("decode discovery: %w", err) + } + + p.discovery = &disc + log.Printf("[auth/oidc] discovery loaded: issuer=%s, jwks=%s", disc.Issuer, disc.JWKSUri) + return nil +} + +func (p *OIDCProvider) refreshKeys() error { + if p.discovery == nil || p.discovery.JWKSUri == "" { + return fmt.Errorf("no JWKS URI (discovery not loaded)") + } + + resp, err := p.client.Get(p.discovery.JWKSUri) + if err != nil { + return fmt.Errorf("fetch JWKS: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("JWKS returned %d", resp.StatusCode) + } + + var jwks oidcJWKS + if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { + return fmt.Errorf("decode JWKS: %w", err) + } + + newKeys := make(map[string]*rsa.PublicKey) + for _, k := range jwks.Keys { + if k.Kty != "RSA" || k.Use != "sig" { + continue + } + pub, err := parseRSAPublicKey(k.N, k.E) + if err != nil { + log.Printf("[auth/oidc] warn: skip key %s: %v", k.Kid, err) + continue + } + newKeys[k.Kid] = pub + } + + p.keysMu.Lock() + p.keys = newKeys + p.keysAt = time.Now() + p.keysMu.Unlock() + + log.Printf("[auth/oidc] JWKS refreshed: %d signing keys", len(newKeys)) + return nil +} + +func (p *OIDCProvider) getKey(kid string) *rsa.PublicKey { + p.keysMu.RLock() + defer p.keysMu.RUnlock() + return p.keys[kid] +} + +// parseRSAPublicKey builds an RSA public key from base64url-encoded N and E. +func parseRSAPublicKey(nB64, eB64 string) (*rsa.PublicKey, error) { + nBytes, err := base64.RawURLEncoding.DecodeString(nB64) + if err != nil { + return nil, fmt.Errorf("decode N: %w", err) + } + eBytes, err := base64.RawURLEncoding.DecodeString(eB64) + if err != nil { + return nil, fmt.Errorf("decode E: %w", err) + } + + n := new(big.Int).SetBytes(nBytes) + e := new(big.Int).SetBytes(eBytes) + + return &rsa.PublicKey{ + N: n, + E: int(e.Int64()), + }, nil +} + +// Discovery exposes the cached discovery document for route handlers. +func (p *OIDCProvider) Discovery() *OIDCDiscovery { + return p.discovery +} diff --git a/server/auth/oidc_test.go b/server/auth/oidc_test.go new file mode 100644 index 0000000..6f89cac --- /dev/null +++ b/server/auth/oidc_test.go @@ -0,0 +1,136 @@ +package auth_test + +import ( + "testing" + + "git.gobha.me/xcaliber/chat-switchboard/auth" +) + +func TestOIDCProvider_Discovery(t *testing.T) { + idp := newMockIdP() + defer idp.Close() + + p, err := auth.NewOIDCProvider(auth.OIDCConfig{ + IssuerURL: idp.IssuerURL(), + ClientID: "switchboard", + ClientSecret: "secret", + AutoActivate: true, + }) + if err != nil { + t.Fatalf("NewOIDCProvider: %v", err) + } + + disc := p.Discovery() + if disc == nil { + t.Fatal("discovery should not be nil") + } + if disc.JWKSUri == "" { + t.Error("JWKS URI should not be empty") + } + if disc.TokenEndpoint == "" { + t.Error("token endpoint should not be empty") + } +} + +func TestOIDCProvider_Mode(t *testing.T) { + idp := newMockIdP() + defer idp.Close() + + p, err := auth.NewOIDCProvider(auth.OIDCConfig{ + IssuerURL: idp.IssuerURL(), + ClientID: "switchboard", + }) + if err != nil { + t.Fatalf("NewOIDCProvider: %v", err) + } + + if p.Mode() != auth.ModeOIDC { + t.Errorf("Mode() = %q, want %q", p.Mode(), auth.ModeOIDC) + } +} + +func TestOIDCProvider_NoRegistration(t *testing.T) { + idp := newMockIdP() + defer idp.Close() + + p, err := auth.NewOIDCProvider(auth.OIDCConfig{ + IssuerURL: idp.IssuerURL(), + ClientID: "switchboard", + }) + if err != nil { + t.Fatalf("NewOIDCProvider: %v", err) + } + + if p.SupportsRegistration() { + t.Error("OIDC should not support registration") + } +} + +func TestOIDCProvider_MissingIssuer(t *testing.T) { + _, err := auth.NewOIDCProvider(auth.OIDCConfig{ + ClientID: "switchboard", + }) + if err == nil { + t.Error("expected error for missing issuer URL") + } +} + +func TestOIDCProvider_MissingClientID(t *testing.T) { + idp := newMockIdP() + defer idp.Close() + + _, err := auth.NewOIDCProvider(auth.OIDCConfig{ + IssuerURL: idp.IssuerURL(), + }) + if err == nil { + t.Error("expected error for missing client ID") + } +} + +func TestOIDCProvider_AuthorizationURL(t *testing.T) { + idp := newMockIdP() + defer idp.Close() + + p, err := auth.NewOIDCProvider(auth.OIDCConfig{ + IssuerURL: idp.IssuerURL(), + ClientID: "switchboard", + }) + if err != nil { + t.Fatalf("NewOIDCProvider: %v", err) + } + + url := p.AuthorizationURL("test-state", "test-nonce", "http://localhost/callback") + if url == "" { + t.Fatal("AuthorizationURL should not be empty") + } + + // Should contain the required OIDC parameters + for _, want := range []string{"response_type=code", "client_id=switchboard", "state=test-state", "nonce=test-nonce"} { + if !containsStr(url, want) { + t.Errorf("AuthorizationURL missing %q in %q", want, url) + } + } +} + +func TestOIDCProvider_UnreachableIssuer(t *testing.T) { + _, err := auth.NewOIDCProvider(auth.OIDCConfig{ + IssuerURL: "http://127.0.0.1:1/unreachable", + ClientID: "switchboard", + }) + if err == nil { + t.Error("expected error for unreachable issuer") + } +} + +func containsStr(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr)) +} + +func containsSubstring(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/server/config/config.go b/server/config/config.go index 280172f..6567bce 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -70,6 +70,28 @@ type Config struct { // Auth mode (v0.24.0): "builtin" (default) | "mtls" | "oidc" AuthMode string + + // mTLS (v0.24.1) + // Headers injected by TLS-terminating reverse proxy. + MTLSHeaderDN string // default "X-SSL-Client-DN" + MTLSHeaderVerify string // default "X-SSL-Client-Verify" + MTLSHeaderFingerprint string // default "X-SSL-Client-Fingerprint" + MTLSAutoActivate bool // auto-activate new users (default true) + MTLSDefaultTeam string // team ID for auto-provisioned users (optional) + MTLSDefaultRole string // "user" (default) or "admin" + + // OIDC (v0.24.1) + OIDCIssuerURL string // e.g. "https://keycloak.corp/realms/switchboard" + OIDCExternalIssuerURL string // OIDC_EXTERNAL_ISSUER_URL + OIDCClientID string + OIDCClientSecret string + OIDCRedirectURL string // e.g. "https://switchboard.corp/api/v1/auth/oidc/callback" + OIDCAutoActivate bool // auto-activate new users (default true) + OIDCDefaultTeam string // team ID for auto-provisioned users (optional) + OIDCDefaultRole string // "user" (default) or "admin" + OIDCRolesClaim string // JWT claim for role mapping (default "realm_access.roles") + OIDCGroupsClaim string // JWT claim for group sync (default "groups") + OIDCAdminRole string // IdP role value that maps to admin (default "admin") } // Load reads configuration from environment variables. @@ -110,6 +132,27 @@ func Load() *Config { ProviderAutoDisableThreshold: getEnvInt("PROVIDER_AUTO_DISABLE_THRESHOLD", 3), AuthMode: getEnv("AUTH_MODE", "builtin"), + + // mTLS + MTLSHeaderDN: getEnv("MTLS_HEADER_DN", "X-SSL-Client-DN"), + MTLSHeaderVerify: getEnv("MTLS_HEADER_VERIFY", "X-SSL-Client-Verify"), + MTLSHeaderFingerprint: getEnv("MTLS_HEADER_FINGERPRINT", "X-SSL-Client-Fingerprint"), + MTLSAutoActivate: getEnvBool("MTLS_AUTO_ACTIVATE", true), + MTLSDefaultTeam: getEnv("MTLS_DEFAULT_TEAM", ""), + MTLSDefaultRole: getEnv("MTLS_DEFAULT_ROLE", "user"), + + // OIDC + OIDCIssuerURL: getEnv("OIDC_ISSUER_URL", ""), + OIDCExternalIssuerURL: getEnv("OIDC_EXTERNAL_ISSUER_URL", ""), + OIDCClientID: getEnv("OIDC_CLIENT_ID", ""), + OIDCClientSecret: getEnv("OIDC_CLIENT_SECRET", ""), + OIDCRedirectURL: getEnv("OIDC_REDIRECT_URL", ""), + OIDCAutoActivate: getEnvBool("OIDC_AUTO_ACTIVATE", true), + OIDCDefaultTeam: getEnv("OIDC_DEFAULT_TEAM", ""), + OIDCDefaultRole: getEnv("OIDC_DEFAULT_ROLE", "user"), + OIDCRolesClaim: getEnv("OIDC_ROLES_CLAIM", "realm_access.roles"), + OIDCGroupsClaim: getEnv("OIDC_GROUPS_CLAIM", "groups"), + OIDCAdminRole: getEnv("OIDC_ADMIN_ROLE", "admin"), } } diff --git a/server/database/compat.go b/server/database/compat.go index a97a1a4..39d22b0 100644 --- a/server/database/compat.go +++ b/server/database/compat.go @@ -1,6 +1,8 @@ package database import ( + "context" + "database/sql" "fmt" "strings" "time" @@ -52,6 +54,112 @@ func Q(query string) string { return q } +// QArgs adapts a Postgres query AND its args for the current dialect. +// +// Unlike Q() which only rewrites the query string, QArgs also expands the +// args slice to match positional ? placeholders. In Postgres, $1 can appear +// multiple times and references the same arg. In SQLite, each ? is +// positional — if $1 appears twice, the arg must appear twice. +// +// Use QArgs for any handler query that references $N more than once +// (typically subqueries like "WHERE user_id = $1 OR ... participant_id = $1"). +// +// On Postgres, returns query and args unchanged. +func QArgs(query string, args ...interface{}) (string, []interface{}) { + if !IsSQLite() { + return query, args + } + + // First, apply the non-placeholder rewrites + q := query + q = strings.ReplaceAll(q, "::jsonb", "") + q = strings.ReplaceAll(q, "::text", "") + q = strings.ReplaceAll(q, "NOW()", "datetime('now')") + q = strings.ReplaceAll(q, "= true", "= 1") + q = strings.ReplaceAll(q, "= false", "= 0") + q = strings.ReplaceAll(q, "COALESCE(is_private, false)", "COALESCE(is_private, 0)") + q = strings.ReplaceAll(q, "NULLS LAST", "") + q = strings.ReplaceAll(q, "ILIKE", "LIKE") + + // Walk the query left-to-right, find each $N, record which arg index + // it maps to, replace with ?, and build a new args list in order. + var newArgs []interface{} + var result strings.Builder + i := 0 + for i < len(q) { + if q[i] == '$' && i+1 < len(q) && q[i+1] >= '1' && q[i+1] <= '9' { + // Parse the number after $ + j := i + 1 + for j < len(q) && q[j] >= '0' && q[j] <= '9' { + j++ + } + numStr := q[i+1 : j] + n := 0 + for _, c := range numStr { + n = n*10 + int(c-'0') + } + // $N is 1-based, args is 0-based + if n >= 1 && n <= len(args) { + newArgs = append(newArgs, args[n-1]) + } + result.WriteByte('?') + i = j + } else { + result.WriteByte(q[i]) + i++ + } + } + + return result.String(), newArgs +} + +// ── Safe Query Wrappers ───────────────────── +// +// These wrappers replace the pattern: +// database.DB.QueryRow(database.Q(sql), args...) +// with: +// database.QueryRow(sql, args...) +// +// They call QArgs internally, handling $N→? conversion AND arg expansion +// for repeated placeholder references. Use these for ALL handler-level +// SQL that uses $N placeholders. + +// QueryRow executes a query with dialect adaptation and arg expansion. +func QueryRow(query string, args ...interface{}) *sql.Row { + q, expanded := QArgs(query, args...) + return DB.QueryRow(q, expanded...) +} + +// QueryRowContext is the context-aware variant of QueryRow. +func QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row { + q, expanded := QArgs(query, args...) + return DB.QueryRowContext(ctx, q, expanded...) +} + +// Query executes a query returning rows, with dialect adaptation. +func Query(query string, args ...interface{}) (*sql.Rows, error) { + q, expanded := QArgs(query, args...) + return DB.Query(q, expanded...) +} + +// QueryContext is the context-aware variant of Query. +func QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) { + q, expanded := QArgs(query, args...) + return DB.QueryContext(ctx, q, expanded...) +} + +// Exec executes a statement with dialect adaptation. +func Exec(query string, args ...interface{}) (sql.Result, error) { + q, expanded := QArgs(query, args...) + return DB.Exec(q, expanded...) +} + +// ExecContext is the context-aware variant of Exec. +func ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { + q, expanded := QArgs(query, args...) + return DB.ExecContext(ctx, q, expanded...) +} + // InsertReturningID executes an INSERT … RETURNING id query. // On Postgres it uses RETURNING directly. // On SQLite it strips RETURNING, generates a UUID, prepends it to the args, diff --git a/server/database/compat_test.go b/server/database/compat_test.go new file mode 100644 index 0000000..29742e0 --- /dev/null +++ b/server/database/compat_test.go @@ -0,0 +1,129 @@ +package database + +import ( + "testing" +) + +func TestQArgs_Postgres(t *testing.T) { + // Force Postgres dialect for test + CurrentDialect = DialectPostgres + defer func() { CurrentDialect = DialectSQLite }() + + q, args := QArgs("SELECT * FROM t WHERE a = $1 AND b = $1", "val1") + if q != "SELECT * FROM t WHERE a = $1 AND b = $1" { + t.Errorf("Postgres: query should be unchanged, got %q", q) + } + if len(args) != 1 { + t.Errorf("Postgres: args should be unchanged, got %d", len(args)) + } +} + +func TestQArgs_SQLite_ReusedPlaceholder(t *testing.T) { + CurrentDialect = DialectSQLite + defer func() { CurrentDialect = DialectPostgres }() + + // $1 appears twice — must be expanded to two ? with the same arg value + q, args := QArgs( + "SELECT COUNT(*) FROM channels c WHERE c.user_id = $1 OR c.id IN (SELECT channel_id FROM cp WHERE cp.user_id = $1) AND c.is_archived = $2", + "user-123", false, + ) + + // Should have 3 ? placeholders + qCount := 0 + for _, c := range q { + if c == '?' { + qCount++ + } + } + if qCount != 3 { + t.Errorf("expected 3 ? placeholders, got %d in %q", qCount, q) + } + + // Should have 3 args: user-123, user-123, false + if len(args) != 3 { + t.Fatalf("expected 3 args, got %d: %v", len(args), args) + } + if args[0] != "user-123" { + t.Errorf("args[0] = %v, want user-123", args[0]) + } + if args[1] != "user-123" { + t.Errorf("args[1] = %v, want user-123 (expanded from reused $1)", args[1]) + } + if args[2] != false { + t.Errorf("args[2] = %v, want false", args[2]) + } + + // Should not contain $N + if contains(q, "$") { + t.Errorf("query still contains $: %q", q) + } +} + +func TestQArgs_SQLite_NoReuse(t *testing.T) { + CurrentDialect = DialectSQLite + defer func() { CurrentDialect = DialectPostgres }() + + q, args := QArgs("SELECT * FROM t WHERE a = $1 AND b = $2", "a", "b") + + qCount := 0 + for _, c := range q { + if c == '?' { + qCount++ + } + } + if qCount != 2 { + t.Errorf("expected 2 ? placeholders, got %d", qCount) + } + if len(args) != 2 { + t.Errorf("expected 2 args, got %d", len(args)) + } +} + +func TestQArgs_SQLite_ILIKE(t *testing.T) { + CurrentDialect = DialectSQLite + defer func() { CurrentDialect = DialectPostgres }() + + q, _ := QArgs("SELECT * FROM t WHERE name ILIKE $1", "%test%") + if contains(q, "ILIKE") { + t.Errorf("ILIKE should be rewritten to LIKE: %q", q) + } + if !contains(q, "LIKE") { + t.Errorf("should contain LIKE: %q", q) + } +} + +func TestQArgs_SQLite_HighPlaceholders(t *testing.T) { + CurrentDialect = DialectSQLite + defer func() { CurrentDialect = DialectPostgres }() + + q, args := QArgs( + "INSERT INTO t (a,b,c,d,e,f,g,h,i,j,k) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, + ) + + qCount := 0 + for _, c := range q { + if c == '?' { + qCount++ + } + } + if qCount != 11 { + t.Errorf("expected 11 ?, got %d in %q", qCount, q) + } + if len(args) != 11 { + t.Errorf("expected 11 args, got %d", len(args)) + } + // $10 and $11 should not be mangled + if contains(q, "$") { + t.Errorf("still contains $: %q", q) + } +} + +func contains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/server/database/database.go b/server/database/database.go index ea79b7e..5e5495a 100644 --- a/server/database/database.go +++ b/server/database/database.go @@ -95,9 +95,9 @@ func connectSQLite(dsn string) error { // _time_format tells the driver how to convert between Go time.Time and SQLite TEXT timestamps. connStr := dsn if dsn != ":memory:" && !strings.Contains(dsn, "?") { - connStr = fmt.Sprintf("file:%s?_pragma=journal_mode%%3Dwal&_pragma=busy_timeout%%3D5000&_pragma=foreign_keys%%3Don&_time_format=2006-01-02T15%%3A04%%3A05Z", dsn) + connStr = fmt.Sprintf("file:%s?_pragma=journal_mode%%3Dwal&_pragma=busy_timeout%%3D5000&_pragma=foreign_keys%%3Don", dsn) } else if dsn == ":memory:" { - connStr = "file::memory:?_pragma=foreign_keys%%3Don&_time_format=2006-01-02T15%%3A04%%3A05Z" + connStr = "file::memory:?_pragma=foreign_keys%%3Don" } var err error diff --git a/server/database/migrations/019_auth_providers.sql b/server/database/migrations/019_auth_providers.sql new file mode 100644 index 0000000..e5178e9 --- /dev/null +++ b/server/database/migrations/019_auth_providers.sql @@ -0,0 +1,30 @@ +-- ========================================== +-- Chat Switchboard — 019 Auth Providers +-- ========================================== +-- OIDC authorization state tracking. +-- Group source column for OIDC-synced groups. +-- ICD §1 (Users), §2 (Groups) — v0.24.1 +-- ========================================== + +-- ── OIDC authorization state (short-lived) ─────────────────────── +-- Stores state + nonce for the authorization code flow. +-- Rows deleted after callback completes. Stale rows cleaned by age. +CREATE TABLE IF NOT EXISTS oidc_auth_state ( + state TEXT PRIMARY KEY, + nonce TEXT NOT NULL, + redirect_to TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_oidc_state_created + ON oidc_auth_state(created_at); + +-- ── Group source tracking ──────────────────────────────────────── +-- Distinguishes manually-created groups from OIDC-synced groups. +-- OIDC groups are managed exclusively by the sync process. +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS source VARCHAR(20) NOT NULL DEFAULT 'manual' + CHECK (source IN ('manual', 'oidc')); + +COMMENT ON TABLE oidc_auth_state IS 'Ephemeral OIDC authorization code flow state. Rows deleted after callback.'; +COMMENT ON COLUMN groups.source IS 'manual=admin-created, oidc=synced from IdP groups claim'; diff --git a/server/database/migrations/sqlite/019_auth_providers.sql b/server/database/migrations/sqlite/019_auth_providers.sql new file mode 100644 index 0000000..9e6a01b --- /dev/null +++ b/server/database/migrations/sqlite/019_auth_providers.sql @@ -0,0 +1,13 @@ +-- Chat Switchboard — 019 Auth Providers (SQLite) (v0.24.1) + +CREATE TABLE IF NOT EXISTS oidc_auth_state ( + state TEXT PRIMARY KEY, + nonce TEXT NOT NULL, + redirect_to TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_oidc_state_created + ON oidc_auth_state(created_at); + +ALTER TABLE groups ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'; diff --git a/server/handlers/auth.go b/server/handlers/auth.go index e9b5960..1d45690 100644 --- a/server/handlers/auth.go +++ b/server/handlers/auth.go @@ -3,7 +3,10 @@ package handlers import ( "context" "crypto/sha256" + "encoding/base64" "encoding/hex" + "encoding/json" + "fmt" "log" "net/http" "strings" @@ -155,6 +158,153 @@ func (h *AuthHandler) Logout(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "logged out"}) } +// ── OIDC Flow ────────────────────────────── + +// OIDCLogin initiates the authorization code flow by redirecting to the IdP. +// GET /api/v1/auth/oidc/login +func (h *AuthHandler) OIDCLogin(c *gin.Context) { + oidcProv, ok := h.provider.(*auth.OIDCProvider) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "OIDC not configured"}) + return + } + + state := uuid.New().String() + nonce := uuid.New().String() + + // Determine redirect URI + redirectURI := h.cfg.OIDCRedirectURL + if redirectURI == "" { + // Auto-derive from request + scheme := "https" + if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" { + scheme = "http" + } + redirectURI = scheme + "://" + c.Request.Host + h.cfg.BasePath + "/api/v1/auth/oidc/callback" + } + + // Store state for callback verification + _, err := database.DB.ExecContext(c.Request.Context(), database.Q(` + INSERT INTO oidc_auth_state (state, nonce, redirect_to) VALUES ($1, $2, $3) + `), state, nonce, c.Query("redirect")) + if err != nil { + log.Printf("[auth/oidc] warn: could not store state: %v", err) + } + + authURL := oidcProv.AuthorizationURL(state, nonce, redirectURI) + c.Redirect(http.StatusFound, authURL) +} + +// OIDCCallback handles the IdP redirect after successful authentication. +// GET /api/v1/auth/oidc/callback?code=...&state=... +func (h *AuthHandler) OIDCCallback(c *gin.Context) { + oidcProv, ok := h.provider.(*auth.OIDCProvider) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "OIDC not configured"}) + return + } + + code := c.Query("code") + state := c.Query("state") + + // Check for IdP error response (Keycloak returns ?error=...&error_description=...) + if errCode := c.Query("error"); errCode != "" { + errDesc := c.Query("error_description") + log.Printf("[auth/oidc] IdP returned error: %s — %s", errCode, errDesc) + c.JSON(http.StatusUnauthorized, gin.H{"error": "IdP error: " + errCode, "description": errDesc}) + return + } + + if code == "" || state == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing code or state"}) + return + } + + // Verify state + var nonce, redirectTo string + err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` + SELECT nonce, COALESCE(redirect_to, '') FROM oidc_auth_state WHERE state = $1 + `), state).Scan(&nonce, &redirectTo) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or expired state"}) + return + } + + // Clean up state (one-time use) + database.DB.ExecContext(c.Request.Context(), database.Q(` + DELETE FROM oidc_auth_state WHERE state = $1 + `), state) + + // Also clean up stale states (> 10 minutes old) + if database.IsSQLite() { + database.DB.ExecContext(c.Request.Context(), + `DELETE FROM oidc_auth_state WHERE created_at < datetime('now', '-10 minutes')`) + } else { + database.DB.ExecContext(c.Request.Context(), + `DELETE FROM oidc_auth_state WHERE created_at < NOW() - INTERVAL '10 minutes'`) + } + + // Determine redirect URI (must match what was sent in the login request) + redirectURI := h.cfg.OIDCRedirectURL + if redirectURI == "" { + scheme := "https" + if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" { + scheme = "http" + } + redirectURI = scheme + "://" + c.Request.Host + h.cfg.BasePath + "/api/v1/auth/oidc/callback" + } + + // Exchange code for tokens + tokenResp, err := oidcProv.ExchangeCode(c.Request.Context(), code, redirectURI) + if err != nil { + log.Printf("[auth/oidc] code exchange failed: %v", err) + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"}) + return + } + + // Use the ID token (or access token) to authenticate via the provider + // Temporarily set the Authorization header so Authenticate() can read it + tokenToValidate := tokenResp.IDToken + if tokenToValidate == "" { + tokenToValidate = tokenResp.AccessToken + } + c.Request.Header.Set("Authorization", "Bearer "+tokenToValidate) + + result, err := oidcProv.Authenticate(c, h.stores) + if err != nil { + log.Printf("[auth/oidc] authenticate after code exchange failed: %v", err) + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"}) + return + } + + // Issue internal JWT + tokens, err := h.generateTokens(result.User) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"}) + return + } + + h.stores.Users.UpdateLastLogin(c.Request.Context(), result.User.ID) + + // For browser flow: encode tokens into a fragment that the login page JS + // can pick up, store in localStorage, and redirect to the app. + // This avoids httpOnly cookie issues — JS needs the token in localStorage. + accessToken, _ := tokens["access_token"].(string) + refreshToken, _ := tokens["refresh_token"].(string) + userJSON, _ := json.Marshal(tokens["user"]) + + // Set page-auth cookie too (for SSR middleware) + c.SetCookie("sb_token", accessToken, 900, "/", "", false, false) + + // Base64-encode the token payload for the fragment + payload := fmt.Sprintf(`{"access_token":"%s","refresh_token":"%s","user":%s}`, + accessToken, refreshToken, string(userJSON)) + encoded := base64.StdEncoding.EncodeToString([]byte(payload)) + + dest := h.cfg.BasePath + "/login#oidc_result=" + encoded + c.Redirect(http.StatusFound, dest) +} + func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) { // Access token (15 min) accessClaims := Claims{ diff --git a/server/handlers/channels.go b/server/handlers/channels.go index 7d2686b..a69fcdc 100644 --- a/server/handlers/channels.go +++ b/server/handlers/channels.go @@ -201,7 +201,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { } var total int - if err := database.DB.QueryRow(database.Q(countQuery), countArgs...).Scan(&total); err != nil { + if err := database.QueryRow(countQuery, countArgs...).Scan(&total); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count channels"}) return } @@ -260,7 +260,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { query += ` ORDER BY c.is_pinned DESC, c.updated_at DESC LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1) args = append(args, perPage, offset) - rows, err := database.DB.Query(database.Q(query), args...) + rows, err := database.Query(query, args...) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"}) return diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go index 259ffc0..cda7297 100644 --- a/server/handlers/integration_test.go +++ b/server/handlers/integration_test.go @@ -3299,3 +3299,45 @@ func TestIntegration_Messages_TreePath(t *testing.T) { t.Fatalf("last in path should be follow-up, got %q", last["content"]) } } + +// ── Channel List with Type Filter (regression: $1 reuse in SQLite) ── + +func TestIntegration_ChannelListWithTypeFilter(t *testing.T) { + h := setupHarness(t) + _, token := h.createAdminUser("admin", "admin@test.com") + + // Create a direct chat + w := h.request("POST", "/api/v1/channels", token, map[string]interface{}{ + "title": "Test Direct Chat", + "type": "direct", + }) + if w.Code != http.StatusCreated { + t.Fatalf("create channel: want 201, got %d: %s", w.Code, w.Body.String()) + } + + // List without type filter — should succeed + w = h.request("GET", "/api/v1/channels?page=1&per_page=100", token, nil) + if w.Code != http.StatusOK { + t.Fatalf("list channels (no filter): want 200, got %d: %s", w.Code, w.Body.String()) + } + + // List with single type filter + w = h.request("GET", "/api/v1/channels?page=1&per_page=100&type=direct", token, nil) + if w.Code != http.StatusOK { + t.Fatalf("list channels (type=direct): want 200, got %d: %s", w.Code, w.Body.String()) + } + + // List with multi-value types filter — this triggers the $1 reuse in the + // count subquery. Before the QArgs fix, this returned 500 on SQLite. + w = h.request("GET", "/api/v1/channels?page=1&per_page=100&types=direct,group", token, nil) + if w.Code != http.StatusOK { + t.Fatalf("list channels (types=direct,group): want 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify the response is valid + var resp map[string]interface{} + decode(w, &resp) + // The response may use "channels" or "data" depending on the handler. + // Verify the request succeeded (200) — that's the regression test. + // Channel content verification is covered by other tests. +} diff --git a/server/main.go b/server/main.go index 2d3506a..f30eab6 100644 --- a/server/main.go +++ b/server/main.go @@ -94,7 +94,11 @@ func main() { keyResolver = crypto.NewKeyResolver(envKey, uekCache) // Initialize store layer - stores = postgres.NewStores(database.DB) + if database.IsSQLite() { + stores = sqliteStore.NewStores(database.DB) + } else { + stores = postgres.NewStores(database.DB) + } // Provider health accumulator (v0.22.0) if database.IsSQLite() { @@ -339,9 +343,32 @@ func main() { case auth.ModeBuiltin: authProvider = auth.NewBuiltinProvider() case auth.ModeMTLS: - log.Fatal("❌ AUTH_MODE=mtls is not yet implemented (planned for v0.24.1)") + authProvider = auth.NewMTLSProvider(auth.MTLSConfig{ + HeaderDN: cfg.MTLSHeaderDN, + HeaderVerify: cfg.MTLSHeaderVerify, + HeaderFingerprint: cfg.MTLSHeaderFingerprint, + AutoActivate: cfg.MTLSAutoActivate, + DefaultTeam: cfg.MTLSDefaultTeam, + DefaultRole: cfg.MTLSDefaultRole, + }) case auth.ModeOIDC: - log.Fatal("❌ AUTH_MODE=oidc is not yet implemented (planned for v0.24.1)") + var err error + authProvider, err = auth.NewOIDCProvider(auth.OIDCConfig{ + IssuerURL: cfg.OIDCIssuerURL, + ExternalIssuerURL: cfg.OIDCExternalIssuerURL, + ClientID: cfg.OIDCClientID, + ClientSecret: cfg.OIDCClientSecret, + RedirectURL: cfg.OIDCRedirectURL, + AutoActivate: cfg.OIDCAutoActivate, + DefaultTeam: cfg.OIDCDefaultTeam, + DefaultRole: cfg.OIDCDefaultRole, + RolesClaim: cfg.OIDCRolesClaim, + GroupsClaim: cfg.OIDCGroupsClaim, + AdminRole: cfg.OIDCAdminRole, + }) + if err != nil { + log.Fatalf("❌ OIDC provider init failed: %v", err) + } } log.Printf(" 🔑 Auth mode: %s", authMode) @@ -373,6 +400,10 @@ func main() { authGroup.POST("/login", authH.Login) authGroup.POST("/refresh", authH.Refresh) authGroup.POST("/logout", authH.Logout) + + // OIDC routes (only active when AUTH_MODE=oidc) + authGroup.GET("/oidc/login", authH.OIDCLogin) + authGroup.GET("/oidc/callback", authH.OIDCCallback) } // ── Public extension assets ──────────────── diff --git a/server/models/models.go b/server/models/models.go index c56f3c9..a41854f 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -864,6 +864,7 @@ type Group struct { Scope string `json:"scope" db:"scope"` // global, team TeamID *string `json:"team_id,omitempty" db:"team_id"` CreatedBy string `json:"created_by" db:"created_by"` + Source string `json:"source" db:"source"` // manual (default), oidc MemberCount int `json:"member_count,omitempty"` // computed, not a DB column } diff --git a/server/pages/pages.go b/server/pages/pages.go index a1e1404..67e3f48 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -71,6 +71,7 @@ type PageData struct { LogoURL string // branding: custom logo URL Tagline string // branding: tagline under instance name RegistrationOpen bool // whether self-registration is enabled + AuthMode string // v0.24.1: "builtin", "mtls", "oidc" } // UserContext is the authenticated user's info available to templates. @@ -211,6 +212,7 @@ func (e *Engine) RenderLogin() gin.HandlerFunc { LogoURL: logoURL, Tagline: tagline, RegistrationOpen: regOpen, + AuthMode: e.cfg.AuthMode, }) } } diff --git a/server/pages/templates/login.html b/server/pages/templates/login.html index 67a4018..4cf7fe7 100644 --- a/server/pages/templates/login.html +++ b/server/pages/templates/login.html @@ -209,6 +209,28 @@
Sign in to your Switchboard instance
+ {{if eq .AuthMode "oidc"}} + ++ This instance uses Single Sign-On. Click below to authenticate with your identity provider. +
+ + Sign in with SSO + ++ This instance uses client certificate authentication. Ensure your browser has a valid certificate installed. +
+ + Continue with Certificate + +