Changeset 0.24.1 (#157)

This commit is contained in:
2026-03-07 17:11:32 +00:00
parent a63728a481
commit b6cc4df6e7
27 changed files with 2094 additions and 453 deletions

View File

@@ -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

View File

@@ -1 +1 @@
0.24.0
0.24.1

117
ci/keycloak-realm.json Normal file
View 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"
]
}
]
}

View 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

View File

@@ -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
---

View File

@@ -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 34: 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.

View File

@@ -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;

View 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
View 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
View 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
View 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
View 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
}

View File

@@ -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"),
}
}

View File

@@ -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,

View 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
}

View File

@@ -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

View 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';

View 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';

View File

@@ -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{

View File

@@ -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

View File

@@ -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.
}

View File

@@ -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 ────────────────

View File

@@ -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
}

View File

@@ -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,
})
}
}

View File

@@ -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 &middot; 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();

View File

@@ -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)

View File

@@ -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)