//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) } }