Files
BountyBoard/internal/http/oidc_integration_test.go
etalon c1cc781279 feat: authentication with local accounts, sessions, CSRF, RBAC, and OIDC PKCE (phase 3)
- argon2id (t=3, m=64MiB, p=2) PHC hashing honoring embedded params
- token-bucket rate limiting (10/15min per IP+email) on login/register
- opaque 32B session tokens in Mongo, 30-day sliding expiry, logout-all
- CSRF double-submit cookie/header on authenticated mutations
- bootstrap admin from env with forced first-login password change
- requireAuth/requireRole middleware with disabled-account enforcement
- OIDC code flow with PKCE: lazy discovery, account linking only on
  verified email, auto-created developer accounts
- unit tests (RBAC matrix, CSRF, password, rate limiter) + integration
  suite covering the full auth matrix incl. an in-test fake IdP

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:31:25 +02:00

304 lines
9.0 KiB
Go

//go:build integration
package httpx
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"bountyboard/internal/domain"
)
// fakeIdP is a minimal OIDC provider: discovery, JWKS, and a token endpoint
// that mints RS256 id_tokens for whatever identity the test configures.
type fakeIdP struct {
srv *httptest.Server
key *rsa.PrivateKey
clientID string
// identity returned by the next token exchange
sub string
email string
emailVerified bool
name string
}
func newFakeIdP(t *testing.T, clientID string) *fakeIdP {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
idp := &fakeIdP{key: key, clientID: clientID}
mux := http.NewServeMux()
mux.HandleFunc("GET /.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"issuer": idp.srv.URL,
"authorization_endpoint": idp.srv.URL + "/authorize",
"token_endpoint": idp.srv.URL + "/token",
"jwks_uri": idp.srv.URL + "/jwks",
"id_token_signing_alg_values_supported": []string{"RS256"},
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
})
})
mux.HandleFunc("GET /jwks", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
pub := key.Public().(*rsa.PublicKey)
json.NewEncoder(w).Encode(map[string]any{
"keys": []map[string]any{{
"kty": "RSA", "alg": "RS256", "use": "sig", "kid": "test",
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
}},
})
})
mux.HandleFunc("POST /token", func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// PKCE: a verifier must be present (the app always sends one).
if r.PostForm.Get("code_verifier") == "" {
http.Error(w, "missing code_verifier", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"access_token": "fake-access-token",
"token_type": "Bearer",
"id_token": idp.mintIDToken(t),
})
})
idp.srv = httptest.NewServer(mux)
t.Cleanup(idp.srv.Close)
return idp
}
func (idp *fakeIdP) mintIDToken(t *testing.T) string {
t.Helper()
header, _ := json.Marshal(map[string]string{"alg": "RS256", "kid": "test", "typ": "JWT"})
payload, _ := json.Marshal(map[string]any{
"iss": idp.srv.URL,
"sub": idp.sub,
"aud": idp.clientID,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"email": idp.email,
"email_verified": idp.emailVerified,
"name": idp.name,
})
signingInput := base64.RawURLEncoding.EncodeToString(header) + "." +
base64.RawURLEncoding.EncodeToString(payload)
digest := sha256.Sum256([]byte(signingInput))
sig, err := rsa.SignPKCS1v15(rand.Reader, idp.key, crypto.SHA256, digest[:])
if err != nil {
t.Fatal(err)
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
}
// oidcLogin walks the full browser flow and returns the final redirect
// location after the callback.
func oidcLogin(t *testing.T, c *http.Client, appURL string) string {
t.Helper()
resp, err := c.Get(appURL + "/api/v1/auth/oidc/login")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("oidc login: %d", resp.StatusCode)
}
loc, err := url.Parse(resp.Header.Get("Location"))
if err != nil {
t.Fatal(err)
}
q := loc.Query()
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
t.Fatalf("authorize URL missing PKCE: %s", loc)
}
state := q.Get("state")
if state == "" {
t.Fatal("authorize URL missing state")
}
cb := fmt.Sprintf("%s/api/v1/auth/oidc/callback?code=fake-code&state=%s",
appURL, url.QueryEscape(state))
resp, err = c.Get(cb)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("callback: %d", resp.StatusCode)
}
return resp.Header.Get("Location")
}
func meUser(t *testing.T, c *http.Client, appURL string) (*domain.User, int) {
t.Helper()
resp, err := c.Get(appURL + "/api/v1/auth/me")
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, resp.StatusCode
}
var out struct {
User domain.User `json:"user"`
}
bodyJSON(t, resp, &out)
return &out.User, http.StatusOK
}
func TestOIDCFullFlow(t *testing.T) {
const clientID = "bountyboard-test"
idp := newFakeIdP(t, clientID)
ts, _, _, _ := newAuthStack(t, map[string]string{
"OIDC_ISSUER_URL": idp.srv.URL,
"OIDC_CLIENT_ID": clientID,
"OIDC_CLIENT_SECRET": "shhh",
"APP_BASE_URL": "http://localhost:8787", // redirect URI base; IdP doesn't validate it here
})
idp.sub, idp.email, idp.emailVerified, idp.name = "subject-1", "sso-dev@example.com", true, "SSO Dev"
c := newClient(t)
if loc := oidcLogin(t, c, ts.URL); loc != "/" {
t.Fatalf("post-login redirect = %q, want /", loc)
}
u, code := meUser(t, c, ts.URL)
if code != http.StatusOK {
t.Fatalf("me after oidc login: %d", code)
}
if u.Email != "sso-dev@example.com" || !u.Roles.Developer || u.Roles.Admin {
t.Fatalf("oidc-created user wrong: %+v", u)
}
firstID := u.ID
// second login with the same subject reuses the account
c2 := newClient(t)
oidcLogin(t, c2, ts.URL)
u2, _ := meUser(t, c2, ts.URL)
if u2.ID != firstID {
t.Fatalf("second oidc login created a new user: %s vs %s", u2.ID, firstID)
}
}
func TestOIDCLinksVerifiedEmailToLocalAccount(t *testing.T) {
const clientID = "bountyboard-test"
idp := newFakeIdP(t, clientID)
ts, _, _, _ := newAuthStack(t, map[string]string{
"OIDC_ISSUER_URL": idp.srv.URL,
"OIDC_CLIENT_ID": clientID,
"OIDC_CLIENT_SECRET": "shhh",
})
// existing local account
c := newClient(t)
resp := postJSON(t, c, ts.URL+"/api/v1/auth/register",
map[string]string{"email": "linked@example.com", "name": "Linked", "password": "password123"}, "")
var reg struct {
User domain.User `json:"user"`
}
bodyJSON(t, resp, &reg)
idp.sub, idp.email, idp.emailVerified, idp.name = "subject-link", "Linked@Example.com", true, "Linked"
c2 := newClient(t)
if loc := oidcLogin(t, c2, ts.URL); loc != "/" {
t.Fatalf("link flow redirect = %q", loc)
}
u, _ := meUser(t, c2, ts.URL)
if u.ID != reg.User.ID {
t.Fatalf("oidc login did not link: got user %s, want %s", u.ID, reg.User.ID)
}
// local password still works after linking
resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
map[string]string{"email": "linked@example.com", "password": "password123"}, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("local login after link: %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestOIDCUnverifiedEmailDoesNotLink(t *testing.T) {
const clientID = "bountyboard-test"
idp := newFakeIdP(t, clientID)
ts, _, _, _ := newAuthStack(t, map[string]string{
"OIDC_ISSUER_URL": idp.srv.URL,
"OIDC_CLIENT_ID": clientID,
"OIDC_CLIENT_SECRET": "shhh",
})
c := newClient(t)
resp := postJSON(t, c, ts.URL+"/api/v1/auth/register",
map[string]string{"email": "victim@example.com", "name": "V", "password": "password123"}, "")
resp.Body.Close()
idp.sub, idp.email, idp.emailVerified = "attacker", "victim@example.com", false
c2 := newClient(t)
loc := oidcLogin(t, c2, ts.URL)
if !strings.Contains(loc, "error=oidc_email_unverified") {
t.Fatalf("unverified email link must be refused, redirect = %q", loc)
}
if _, code := meUser(t, c2, ts.URL); code != http.StatusUnauthorized {
t.Fatalf("attacker must not get a session, me = %d", code)
}
}
func TestOIDCStateMismatchRejected(t *testing.T) {
const clientID = "bountyboard-test"
idp := newFakeIdP(t, clientID)
ts, _, _, _ := newAuthStack(t, map[string]string{
"OIDC_ISSUER_URL": idp.srv.URL,
"OIDC_CLIENT_ID": clientID,
"OIDC_CLIENT_SECRET": "shhh",
})
idp.sub, idp.email, idp.emailVerified = "s", "s@example.com", true
c := newClient(t)
resp, err := c.Get(ts.URL + "/api/v1/auth/oidc/login")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
resp, err = c.Get(ts.URL + "/api/v1/auth/oidc/callback?code=fake-code&state=forged-state")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if loc := resp.Header.Get("Location"); !strings.Contains(loc, "error=oidc_state_mismatch") {
t.Fatalf("forged state redirect = %q", loc)
}
}
func TestOIDCDisabledByDefault(t *testing.T) {
ts, _, _, _ := newAuthStack(t, nil)
resp, err := http.Get(ts.URL + "/api/v1/auth/oidc/login")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("oidc login without config: %d, want 404", resp.StatusCode)
}
}