c1cc781279
- 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>
304 lines
9.0 KiB
Go
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, ®)
|
|
|
|
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)
|
|
}
|
|
}
|