Skip to content

Commit 8923bcc

Browse files
committed
Merge branch 'main' into feature/webhooks
Signed-off-by: Mark Rai <markraidc@gmail.com>
2 parents e8d8799 + 959ef69 commit 8923bcc

7 files changed

Lines changed: 295 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,35 @@
11
# Changelog
22

3-
> **Upgrades:** No breaking changes in **3.7.x** / **3.8.x** / **3.9.x** unless noted below.
3+
> **Upgrades:** No breaking changes in **3.7.x** / **3.8.x** / **3.9.x** / **3.10.x** unless noted below.
44
55

6+
## [3.10.0] - 2026-04-04
7+
8+
### Features
9+
10+
- **Event bus + SSE****`internal/eventbus`** fanout; **`PublishEvent`** on the server. Board refresh / members events go through the bus; **`sseBridge`** keeps the same SSE JSON as before.
11+
- **`todo.assigned`** — Published after commit from **`CreateTodo`** / **`UpdateTodo`** when assignee changes (non-anonymous temp boards). SSE uses reason **`todo_assigned`**; handlers skip duplicate **`todo_created`** / **`todo_updated`** refresh when **`AssignmentChanged`**.
12+
- **Webhooks (full mode)****`POST` / `GET` / `DELETE`** **`/api/webhooks`** (maintainer, session; **404** in anonymous mode). Migration **050**; optional HMAC **`X-Scrumboy-Signature`**; async queue + worker, retries, JSON envelope with event **`id`** (for idempotency). Dispatcher enqueues in a goroutine with a detached context so SSE is not blocked.
13+
14+
### Fixes
15+
16+
- **Shutdown** — HTTP **`Shutdown`** before cancelling the webhook worker.
17+
- **CreateTodo** — Same **`!isAnonymousBoard`** gate as **`UpdateTodo`** for assignment events.
18+
19+
### Other
20+
21+
- Tests: **`eventbus_regression_test.go`**. Docs: README webhooks section + TOC. Dep: **`github.com/google/uuid`**.
22+
23+
---
24+
25+
## [3.9.4] - 2026-04-04
26+
27+
### Fixes
28+
29+
- **OIDC / SSO - account linking for existing users** — When a user signs in with **Continue with SSO** and the IdP returns a **verified** email that already matches a **`users`** row (e.g. bootstrap owner or admin-created account from before OIDC), Scrumboy now **links** the **`(issuer, subject)`** identity in **`user_oidc_identities`** to that user instead of failing with a duplicate-email conflict. Local password hashes are unchanged; SSO and password login can both work for the same account when local auth remains enabled. Integration test **`TestOIDCAutoLinkExistingUser`** covers the full callback flow; the test **fake IdP** now relays **`nonce`** from authorize → token so end-to-end OIDC tests match real providers.
30+
31+
---
32+
633
## [3.9.3] - 2026-04-05
734

835
### Improvements
@@ -15,6 +42,14 @@
1542

1643
---
1744

45+
## [3.9.2] - (no release)
46+
47+
### Note
48+
49+
- **Version number skipped in git** — There is no commit in this repository that sets **`internal/version/version.go`** to **3.9.2**, and no **`README`** / **`CHANGELOG`** reference to **3.9.2** before this note. After **3.9.1**, the next bump was **3.9.3** (commit **`2c5b576`**, *multiple UX enhancements…*). No separate user-facing changes are recorded under **3.9.2**; see **3.9.1** (OIDC **`dist/`** rebuild) and **3.9.3** (UX items above) for work in that window.
50+
51+
---
52+
1853
## [3.9.1] - 2026-04-04
1954

2055
### Fixes

README.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<p align="center">
22
<img width="372" src="internal/httpapi/web/githublogo.png" alt="scrumboy logo" />
33
<br />
4-
<img src="https://img.shields.io/badge/version-v3.9.3-blue" alt="version" />
4+
<img src="https://img.shields.io/badge/version-v3.10.0-blue" alt="version" />
55
<a href="LICENSE">
66
<img src="https://img.shields.io/badge/license-AGPL--v3-orange" alt="license" />
77
</a>
@@ -12,6 +12,34 @@
1212

1313
<img width="2975" height="1078" alt="image" src="internal/httpapi/web/github_preview.jpg" />
1414

15+
## Table of contents
16+
17+
- [Quick Start](#quick-start)
18+
- [Run with Docker](#run-with-docker)
19+
- [Run from source](#run-from-source)
20+
- [Optional Configuration](#optional-configuration)
21+
- [Environment variables](#environment-variables)
22+
- [Encryption key (optional)](#encryption-key-optional)
23+
- [OIDC / SSO login (optional)](#oidc--sso-login-optional)
24+
- [TLS / HTTPS (optional)](#tls--https-optional)
25+
- [Frontend build note](#frontend-build-note)
26+
- [Why Scrumboy?](#why-scrumboy)
27+
- [Who is this for?](#who-is-this-for)
28+
- [Modes](#modes)
29+
- [Features](#features)
30+
- [Integrations & API Access](#integrations--api-access)
31+
- [MCP (JSON-RPC) for AI agents](#mcp-json-rpc-for-ai-agents)
32+
- [Webhooks (outbound HTTP)](#webhooks-outbound-http)
33+
- [Config](#config)
34+
- [Roles](#roles)
35+
- [System roles (instance-wide)](#system-roles-instance-wide)
36+
- [Project roles (per project)](#project-roles-per-project)
37+
- [Export scope](#export-scope)
38+
- [Import modes](#import-modes)
39+
- [Code layout (reference)](#code-layout-reference)
40+
- [Documentation](#documentation)
41+
- [License and Contributions](#license-and-contributions)
42+
1543
## Quick Start
1644

1745
Runs in seconds. No setup required.
@@ -117,6 +145,8 @@ Simplicity of a light Kanban, with the power of structured systems: Roles, sprin
117145

118146
- Realtime SSE enabled boards for instant multi-user actions.
119147

148+
- **Webhooks (API-only, full mode):** Register URLs per project so Scrumboy can POST JSON when subscribed domain events fire (e.g. `todo.assigned`). For your own automations—not in-app or browser notifications. See [Integrations](#integrations--api-access).
149+
120150
- Customizable Tags: Users can inherit and customize tag colors.
121151

122152
- Advanced filtering: Search todos based on text or tags.
@@ -218,6 +248,26 @@ This enables:
218248
- AI agents and MCP clients (use **`POST /mcp/rpc`** for JSON-RPC; **`POST /mcp`** remains available for the legacy `{ "tool", "input" }` envelope)
219249
- Scripting/integrations without login flows
220250

251+
### Webhooks (outbound HTTP)
252+
253+
Scrumboy can **POST JSON to URLs you register** when certain events occur. This is for **server-side integrations** (your script, gateway, queue worker, etc.). It does **not** add notifications inside the Scrumboy UI; live boards still update via **SSE** as before.
254+
255+
- **Availability:** **Full mode only** (endpoints are disabled in anonymous mode).
256+
- **Who can configure:** Project **maintainers**, via the HTTP API only—there is **no settings screen** for webhooks yet.
257+
- **API:** `POST /api/webhooks` (create), `GET /api/webhooks` (list yours), `DELETE /api/webhooks/{id}` — same session cookie / CSRF header rules as other mutating `/api/*` calls.
258+
- **Events:** Subscribe to specific types (e.g. `todo.assigned`) or `*` for all delivered types. The set may grow over time; unused types in your list are harmless.
259+
- **Security:** Optional per-webhook **secret**; when set, requests include an `X-Scrumboy-Signature` header (`sha256=` HMAC of the raw JSON body).
260+
- **Semantics:** Best-effort delivery with retries on failure; not a durable external queue—design for idempotent receivers using the event `id` in the JSON body.
261+
262+
Example create (replace cookie / project id / URL):
263+
264+
```bash
265+
curl -b cookies.txt -X POST http://localhost:8080/api/webhooks \
266+
-H "Content-Type: application/json" \
267+
-H "X-Scrumboy: 1" \
268+
-d '{"projectId":1,"url":"https://example.com/scrumboy-hook","events":["todo.assigned"],"secret":"optional-shared-secret"}'
269+
```
270+
221271

222272
# Config
223273

internal/httpapi/oidc.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,28 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
6666
u, err = s.store.CreateUserOIDC(ctx, configuredIssuer, result.Issuer, result.Subject, result.Email, result.Name)
6767
if err != nil {
6868
if errors.Is(err, store.ErrConflict) {
69-
s.logger.Printf("oidc: login aborted: email already in use by existing user (OIDC identity not linked), sub=%s", result.Subject)
69+
// Email already belongs to a local-password user. Auto-link
70+
// the OIDC identity so pre-existing users can log in via SSO
71+
// without creating a duplicate account. The IdP email is
72+
// already verified (HandleCallback enforces email_verified).
73+
existing, lookupErr := s.store.GetUserByEmail(ctx, result.Email)
74+
if lookupErr != nil {
75+
s.logger.Printf("oidc: auto-link lookup failed for %s: %v", result.Email, lookupErr)
76+
http.Redirect(w, r, "/?oidc_error=token", http.StatusFound)
77+
return
78+
}
79+
if linkErr := s.store.LinkOIDCIdentity(ctx, existing.ID, result.Issuer, result.Subject, result.Email); linkErr != nil {
80+
s.logger.Printf("oidc: auto-link failed for user %d: %v", existing.ID, linkErr)
81+
http.Redirect(w, r, "/?oidc_error=token", http.StatusFound)
82+
return
83+
}
84+
s.logger.Printf("oidc: auto-linked existing user %d (%s) to OIDC identity sub=%s", existing.ID, result.Email, result.Subject)
85+
u = existing
7086
} else {
7187
s.logger.Printf("oidc: create user: %v", err)
88+
http.Redirect(w, r, "/?oidc_error=token", http.StatusFound)
89+
return
7290
}
73-
http.Redirect(w, r, "/?oidc_error=token", http.StatusFound)
74-
return
7591
}
7692
}
7793

internal/httpapi/oidc_test.go

Lines changed: 130 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"net/url"
1212
"path/filepath"
1313
"strings"
14+
"sync"
1415
"testing"
1516
"time"
1617

@@ -34,6 +35,9 @@ type fakeIdP struct {
3435
email string
3536
emailVer bool
3637
name string
38+
39+
mu sync.Mutex
40+
nonces map[string]string // auth code → nonce
3741
}
3842

3943
func newFakeIdP(t *testing.T) *fakeIdP {
@@ -91,10 +95,21 @@ func (f *fakeIdP) handleJWKS(w http.ResponseWriter, r *http.Request) {
9195

9296
func (f *fakeIdP) handleAuthorize(w http.ResponseWriter, r *http.Request) {
9397
state := r.URL.Query().Get("state")
98+
nonce := r.URL.Query().Get("nonce")
9499
redir := r.URL.Query().Get("redirect_uri")
100+
101+
code := "code-" + state[:8]
102+
103+
f.mu.Lock()
104+
if f.nonces == nil {
105+
f.nonces = make(map[string]string)
106+
}
107+
f.nonces[code] = nonce
108+
f.mu.Unlock()
109+
95110
u, _ := url.Parse(redir)
96111
q := u.Query()
97-
q.Set("code", "test-auth-code")
112+
q.Set("code", code)
98113
q.Set("state", state)
99114
u.RawQuery = q.Encode()
100115
http.Redirect(w, r, u.String(), http.StatusFound)
@@ -110,10 +125,11 @@ func (f *fakeIdP) handleToken(w http.ResponseWriter, r *http.Request) {
110125
return
111126
}
112127

113-
nonce := r.FormValue("nonce")
114-
if nonce == "" {
115-
nonce = "fallback"
116-
}
128+
code := r.FormValue("code")
129+
f.mu.Lock()
130+
nonce := f.nonces[code]
131+
delete(f.nonces, code)
132+
f.mu.Unlock()
117133

118134
now := time.Now()
119135
claims := map[string]any{
@@ -330,3 +346,112 @@ func TestOIDCCallbackInvalidState(t *testing.T) {
330346
}
331347
}
332348

349+
// newTestOIDCServerWithStore is like newTestOIDCServer but also returns the
350+
// store so tests can pre-create local users before exercising the OIDC flow.
351+
func newTestOIDCServerWithStore(t *testing.T, idp *fakeIdP) (*httptest.Server, *store.Store, func()) {
352+
t.Helper()
353+
354+
dir := t.TempDir()
355+
sqlDB, err := db.Open(filepath.Join(dir, "app.db"), db.Options{
356+
BusyTimeout: 5000,
357+
JournalMode: "WAL",
358+
Synchronous: "FULL",
359+
})
360+
if err != nil {
361+
t.Fatalf("open db: %v", err)
362+
}
363+
if err := migrate.Apply(context.Background(), sqlDB); err != nil {
364+
_ = sqlDB.Close()
365+
t.Fatalf("migrate: %v", err)
366+
}
367+
368+
st := store.New(sqlDB, nil)
369+
370+
oidcSvc := oidc.New(oidc.Config{
371+
IssuerCanonical: idp.issuer,
372+
ClientID: idp.clientID,
373+
ClientSecret: "test-secret",
374+
RedirectURL: "http://placeholder/api/auth/oidc/callback",
375+
})
376+
377+
srv := NewServer(st, Options{
378+
MaxRequestBody: 1 << 20,
379+
ScrumboyMode: "full",
380+
OIDCService: oidcSvc,
381+
})
382+
ts := httptest.NewServer(srv)
383+
384+
oidcSvc2 := oidc.New(oidc.Config{
385+
IssuerCanonical: idp.issuer,
386+
ClientID: idp.clientID,
387+
ClientSecret: "test-secret",
388+
RedirectURL: ts.URL + "/api/auth/oidc/callback",
389+
})
390+
srv.oidcService = oidcSvc2
391+
392+
return ts, st, func() {
393+
ts.Close()
394+
_ = sqlDB.Close()
395+
}
396+
}
397+
398+
// TestOIDCAutoLinkExistingUser verifies that when a pre-existing local-password
399+
// user tries OIDC login with a matching verified email, the identity is
400+
// auto-linked and login succeeds (instead of failing with a conflict error).
401+
func TestOIDCAutoLinkExistingUser(t *testing.T) {
402+
idp := newFakeIdP(t)
403+
defer idp.close()
404+
405+
ts, st, cleanup := newTestOIDCServerWithStore(t, idp)
406+
defer cleanup()
407+
408+
ctx := context.Background()
409+
410+
// Bootstrap the owner with the same email the IdP will provide.
411+
_, err := st.BootstrapUser(ctx, idp.email, "Password123!", "Alice Local")
412+
if err != nil {
413+
t.Fatalf("bootstrap: %v", err)
414+
}
415+
416+
// Follow the full OIDC flow: login redirect → IdP authorize → callback.
417+
jar, _ := cookiejar.New(nil)
418+
client := &http.Client{Jar: jar}
419+
420+
resp, err := client.Get(ts.URL + "/api/auth/oidc/login?return_to=/")
421+
if err != nil {
422+
t.Fatalf("oidc login: %v", err)
423+
}
424+
resp.Body.Close()
425+
426+
// After the full redirect chain the user should land on "/" with a session
427+
// cookie, NOT on "/?oidc_error=token".
428+
finalURL := resp.Request.URL.String()
429+
if strings.Contains(finalURL, "oidc_error") {
430+
t.Fatalf("expected successful login, got redirect to %s", finalURL)
431+
}
432+
433+
// Verify the session is live: GET /api/auth/status should return the user.
434+
var status map[string]any
435+
doJSON(t, client, "GET", ts.URL+"/api/auth/status", nil, &status)
436+
user, ok := status["user"].(map[string]any)
437+
if !ok || user == nil {
438+
t.Fatalf("expected authenticated user in status, got %v", status)
439+
}
440+
if user["email"] != idp.email {
441+
t.Errorf("expected email=%s, got %v", idp.email, user["email"])
442+
}
443+
444+
// A second OIDC login with the same identity should succeed (identity
445+
// already linked, no conflict).
446+
jar2, _ := cookiejar.New(nil)
447+
client2 := &http.Client{Jar: jar2}
448+
resp2, err := client2.Get(ts.URL + "/api/auth/oidc/login?return_to=/")
449+
if err != nil {
450+
t.Fatalf("second oidc login: %v", err)
451+
}
452+
resp2.Body.Close()
453+
if strings.Contains(resp2.Request.URL.String(), "oidc_error") {
454+
t.Fatalf("second login failed: %s", resp2.Request.URL.String())
455+
}
456+
}
457+

internal/httpapi/server.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ type storeAPI interface {
8383
RevokeUserAPIToken(ctx context.Context, userID, tokenID int64) error
8484

8585
GetUserByOIDCIdentity(ctx context.Context, issuer, subject string) (store.User, error)
86+
GetUserByEmail(ctx context.Context, email string) (store.User, error)
87+
LinkOIDCIdentity(ctx context.Context, userID int64, issuer, subject, email string) error
8688
CreateUserOIDC(ctx context.Context, configuredIssuer, issuer, subject, email, name string) (store.User, error)
8789

8890
ListProjects(ctx context.Context) ([]store.ProjectListEntry, error)

0 commit comments

Comments
 (0)