Changeset 0.24.1 (#157)
This commit is contained in:
24
CHANGELOG.md
24
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
|
||||
|
||||
117
ci/keycloak-realm.json
Normal file
117
ci/keycloak-realm.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
59
docker-compose-keycloak.yml
Normal file
59
docker-compose-keycloak.yml
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
176
server/auth/mock_idp_test.go
Normal file
176
server/auth/mock_idp_test.go
Normal file
@@ -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
|
||||
}
|
||||
184
server/auth/mtls.go
Normal file
184
server/auth/mtls.go
Normal file
@@ -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
|
||||
}
|
||||
135
server/auth/mtls_test.go
Normal file
135
server/auth/mtls_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
587
server/auth/oidc.go
Normal file
587
server/auth/oidc.go
Normal file
@@ -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
|
||||
}
|
||||
136
server/auth/oidc_test.go
Normal file
136
server/auth/oidc_test.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
129
server/database/compat_test.go
Normal file
129
server/database/compat_test.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
30
server/database/migrations/019_auth_providers.sql
Normal file
30
server/database/migrations/019_auth_providers.sql
Normal file
@@ -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';
|
||||
13
server/database/migrations/sqlite/019_auth_providers.sql
Normal file
13
server/database/migrations/sqlite/019_auth_providers.sql
Normal file
@@ -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';
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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 ────────────────
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +209,28 @@
|
||||
<p id="authSubtitle">Sign in to your Switchboard instance</p>
|
||||
</div>
|
||||
|
||||
{{if eq .AuthMode "oidc"}}
|
||||
<!-- SSO LOGIN -->
|
||||
<div class="anim anim-a2">
|
||||
<p style="color: var(--text-2); font-size: 0.9rem; margin-bottom: 1.5rem;">
|
||||
This instance uses Single Sign-On. Click below to authenticate with your identity provider.
|
||||
</p>
|
||||
<a class="btn-login" href="{{.BasePath}}/api/v1/auth/oidc/login" style="display:block; text-align:center; text-decoration:none;">
|
||||
Sign in with SSO
|
||||
</a>
|
||||
</div>
|
||||
{{else if eq .AuthMode "mtls"}}
|
||||
<!-- mTLS LOGIN -->
|
||||
<div class="anim anim-a2">
|
||||
<p style="color: var(--text-2); font-size: 0.9rem; margin-bottom: 1.5rem;">
|
||||
This instance uses client certificate authentication. Ensure your browser has a valid certificate installed.
|
||||
</p>
|
||||
<a class="btn-login" href="{{.BasePath}}/" style="display:block; text-align:center; text-decoration:none;">
|
||||
Continue with Certificate
|
||||
</a>
|
||||
</div>
|
||||
{{else}}
|
||||
<!-- BUILTIN LOGIN -->
|
||||
<div class="auth-tabs anim anim-a2">
|
||||
<button class="auth-tab active" id="tabLogin" onclick="_switchTab('login')">Log In</button>
|
||||
{{if .RegistrationOpen}}<button class="auth-tab" id="tabRegister" onclick="_switchTab('register')">Register</button>{{end}}
|
||||
@@ -251,6 +273,7 @@
|
||||
<button class="btn-login" id="regBtn" onclick="_doRegister()">Create Account</button>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}{{/* end auth mode conditional */}}
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Self-hosted instance · Your data stays on your infrastructure</p>
|
||||
@@ -272,29 +295,60 @@
|
||||
</script>
|
||||
<script src="{{.BasePath}}/js/pages.js?v={{.Version}}"></script>
|
||||
<script>
|
||||
// ── OIDC SSO callback handler ───────────────
|
||||
// The OIDC callback redirects here with #oidc_result=<base64 JSON>.
|
||||
// We decode it, save to localStorage (same format as builtin login),
|
||||
// set the page-auth cookie, and redirect to the app.
|
||||
(function() {
|
||||
const hash = window.location.hash;
|
||||
if (!hash.startsWith('#oidc_result=')) return;
|
||||
try {
|
||||
const encoded = hash.slice('#oidc_result='.length);
|
||||
const json = atob(encoded);
|
||||
const data = JSON.parse(json);
|
||||
const base = window.__BASE__ || '';
|
||||
const storageKey = base ? 'sb_auth_' + base.replace(/\//g, '') : 'sb_auth';
|
||||
localStorage.setItem(storageKey, JSON.stringify({
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
user: data.user,
|
||||
}));
|
||||
document.cookie = 'sb_token=' + data.access_token + '; path=/; max-age=900; SameSite=Strict';
|
||||
window.location.replace(base + '/');
|
||||
} catch (e) {
|
||||
console.error('[oidc] failed to process SSO callback:', e);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Builtin auth handlers — safe to include always; no-op if DOM elements absent.
|
||||
function _switchTab(tab) {
|
||||
document.getElementById('loginForm').style.display = tab === 'login' ? '' : 'none';
|
||||
const lf = document.getElementById('loginForm');
|
||||
if (lf) lf.style.display = tab === 'login' ? '' : 'none';
|
||||
const regForm = document.getElementById('registerForm');
|
||||
if (regForm) regForm.style.display = tab === 'register' ? '' : 'none';
|
||||
document.getElementById('tabLogin').classList.toggle('active', tab === 'login');
|
||||
const regTab = document.getElementById('tabRegister');
|
||||
if (regTab) regTab.classList.toggle('active', tab === 'register');
|
||||
document.getElementById('authTitle').textContent = tab === 'login' ? 'Welcome back' : 'Create account';
|
||||
document.getElementById('authSubtitle').textContent = tab === 'login'
|
||||
document.getElementById('tabLogin')?.classList.toggle('active', tab === 'login');
|
||||
document.getElementById('tabRegister')?.classList.toggle('active', tab === 'register');
|
||||
const title = document.getElementById('authTitle');
|
||||
if (title) title.textContent = tab === 'login' ? 'Welcome back' : 'Create account';
|
||||
const sub = document.getElementById('authSubtitle');
|
||||
if (sub) sub.textContent = tab === 'login'
|
||||
? 'Sign in to your Switchboard instance'
|
||||
: 'Join your team on Switchboard';
|
||||
document.getElementById('loginError').textContent = '';
|
||||
const loginErr = document.getElementById('loginError');
|
||||
if (loginErr) loginErr.textContent = '';
|
||||
const regErr = document.getElementById('regError');
|
||||
if (regErr) { regErr.textContent = ''; regErr.style.display = 'none'; }
|
||||
}
|
||||
|
||||
async function _doRegister() {
|
||||
const username = document.getElementById('regUsername').value.trim();
|
||||
const email = document.getElementById('regEmail').value.trim();
|
||||
const password = document.getElementById('regPassword').value;
|
||||
const confirm = document.getElementById('regConfirm').value;
|
||||
const username = document.getElementById('regUsername')?.value.trim();
|
||||
const email = document.getElementById('regEmail')?.value.trim();
|
||||
const password = document.getElementById('regPassword')?.value;
|
||||
const confirm = document.getElementById('regConfirm')?.value;
|
||||
const errEl = document.getElementById('regError');
|
||||
const btn = document.getElementById('regBtn');
|
||||
if (!errEl || !btn) return;
|
||||
|
||||
if (!username || !password) { errEl.textContent = 'Username and password are required'; errEl.style.display = ''; return; }
|
||||
if (password.length < 8) { errEl.textContent = 'Password must be at least 8 characters'; errEl.style.display = ''; return; }
|
||||
@@ -322,7 +376,6 @@
|
||||
errEl.style.display = '';
|
||||
setTimeout(() => { errEl.style.color = ''; errEl.style.display = 'none'; _switchTab('login'); }, 3000);
|
||||
} else {
|
||||
// Auto-login
|
||||
const storageKey = base ? 'sb_auth_' + base.replace(/\//g, '') : 'sb_auth';
|
||||
localStorage.setItem(storageKey, JSON.stringify({
|
||||
accessToken: data.access_token, refreshToken: data.refresh_token, user: data.user,
|
||||
@@ -338,12 +391,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Enter key handlers
|
||||
document.getElementById('loginPassword').addEventListener('keydown', e => {
|
||||
document.getElementById('loginPassword')?.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') Pages.doLogin();
|
||||
});
|
||||
document.getElementById('loginUsername').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') document.getElementById('loginPassword').focus();
|
||||
document.getElementById('loginUsername')?.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') document.getElementById('loginPassword')?.focus();
|
||||
});
|
||||
document.getElementById('regConfirm')?.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') _doRegister();
|
||||
|
||||
@@ -15,12 +15,15 @@ type GroupStore struct{}
|
||||
func NewGroupStore() *GroupStore { return &GroupStore{} }
|
||||
|
||||
func (s *GroupStore) Create(ctx context.Context, g *models.Group) error {
|
||||
if g.Source == "" {
|
||||
g.Source = "manual"
|
||||
}
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO groups (name, description, scope, team_id, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
INSERT INTO groups (name, description, scope, team_id, created_by, source)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, created_at, updated_at`,
|
||||
g.Name, g.Description, g.Scope,
|
||||
models.NullString(g.TeamID), g.CreatedBy,
|
||||
models.NullString(g.TeamID), g.CreatedBy, g.Source,
|
||||
).Scan(&g.ID, &g.CreatedAt, &g.UpdatedAt)
|
||||
}
|
||||
|
||||
@@ -30,10 +33,11 @@ func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, err
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
|
||||
COALESCE(g.source, 'manual')
|
||||
FROM groups g WHERE g.id = $1`, id).Scan(
|
||||
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &g.CreatedBy,
|
||||
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount,
|
||||
&g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -81,7 +85,8 @@ func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
|
||||
COALESCE(g.source, 'manual')
|
||||
FROM groups g
|
||||
ORDER BY g.scope, g.name`)
|
||||
}
|
||||
@@ -90,7 +95,8 @@ func (s *GroupStore) ListForTeam(ctx context.Context, teamID string) ([]models.G
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
|
||||
COALESCE(g.source, 'manual')
|
||||
FROM groups g
|
||||
WHERE g.scope = 'team' AND g.team_id = $1
|
||||
ORDER BY g.name`, teamID)
|
||||
@@ -100,7 +106,8 @@ func (s *GroupStore) ListForUser(ctx context.Context, userID string) ([]models.G
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
|
||||
COALESCE(g.source, 'manual')
|
||||
FROM groups g
|
||||
WHERE g.id IN (SELECT group_id FROM group_members WHERE user_id = $1)
|
||||
ORDER BY g.scope, g.name`, userID)
|
||||
@@ -196,7 +203,7 @@ func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.G
|
||||
var g models.Group
|
||||
var teamID sql.NullString
|
||||
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Scope, &teamID,
|
||||
&g.CreatedBy, &g.CreatedAt, &g.UpdatedAt, &g.MemberCount); err != nil {
|
||||
&g.CreatedBy, &g.CreatedAt, &g.UpdatedAt, &g.MemberCount, &g.Source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.TeamID = NullableStringPtr(teamID)
|
||||
|
||||
@@ -21,11 +21,14 @@ func (s *GroupStore) Create(ctx context.Context, g *models.Group) error {
|
||||
now := time.Now().UTC()
|
||||
g.CreatedAt = now
|
||||
g.UpdatedAt = now
|
||||
if g.Source == "" {
|
||||
g.Source = "manual"
|
||||
}
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO groups (id, name, description, scope, team_id, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
INSERT INTO groups (id, name, description, scope, team_id, created_by, source, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
g.ID, g.Name, g.Description, g.Scope,
|
||||
models.NullString(g.TeamID), g.CreatedBy,
|
||||
models.NullString(g.TeamID), g.CreatedBy, g.Source,
|
||||
now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
return err
|
||||
@@ -37,10 +40,11 @@ func (s *GroupStore) GetByID(ctx context.Context, id string) (*models.Group, err
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) AS member_count,
|
||||
COALESCE(g.source, 'manual')
|
||||
FROM groups g WHERE g.id = ?`, id).Scan(
|
||||
&g.ID, &g.Name, &g.Description, &g.Scope, &teamID, &g.CreatedBy,
|
||||
st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount,
|
||||
st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -88,7 +92,8 @@ func (s *GroupStore) ListAll(ctx context.Context) ([]models.Group, error) {
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
|
||||
COALESCE(g.source, 'manual')
|
||||
FROM groups g
|
||||
ORDER BY g.scope, g.name`)
|
||||
}
|
||||
@@ -97,7 +102,8 @@ func (s *GroupStore) ListForTeam(ctx context.Context, teamID string) ([]models.G
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
|
||||
COALESCE(g.source, 'manual')
|
||||
FROM groups g
|
||||
WHERE g.scope = 'team' AND g.team_id = ?
|
||||
ORDER BY g.name`, teamID)
|
||||
@@ -107,7 +113,8 @@ func (s *GroupStore) ListForUser(ctx context.Context, userID string) ([]models.G
|
||||
return queryGroups(ctx, `
|
||||
SELECT g.id, g.name, g.description, g.scope, g.team_id, g.created_by,
|
||||
g.created_at, g.updated_at,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id)
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id),
|
||||
COALESCE(g.source, 'manual')
|
||||
FROM groups g
|
||||
WHERE g.id IN (SELECT group_id FROM group_members WHERE user_id = ?)
|
||||
ORDER BY g.scope, g.name`, userID)
|
||||
@@ -203,7 +210,7 @@ func queryGroups(ctx context.Context, q string, args ...interface{}) ([]models.G
|
||||
var g models.Group
|
||||
var teamID sql.NullString
|
||||
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Scope, &teamID,
|
||||
&g.CreatedBy, st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount); err != nil {
|
||||
&g.CreatedBy, st(&g.CreatedAt), st(&g.UpdatedAt), &g.MemberCount, &g.Source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.TeamID = NullableStringPtr(teamID)
|
||||
|
||||
Reference in New Issue
Block a user