Files
VeillaxandClaude Sonnet 5.5 f521fa8668 Add the Hammerhead control plane
Dashboard and API, accounts (organizations, roles, invitations, 2FA, OIDC
single sign-on, API keys, revocable sessions), an encrypted SQLite database,
resource logins, alerts and /metrics, mail egress management with DKIM keys,
and the .env loader. Split out of the original single repository; depends on
Reef for the shared types. The end-to-end tests that run Hammerhead, Blacktip
and Bamboo together live in e2e/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 02:50:03 -06:00

347 lines
9.0 KiB
Go

package hammerhead
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Hammerhead acts as an OpenID Connect relying party using the authorization
// code flow with PKCE. Identity comes from the provider's userinfo endpoint,
// fetched over TLS with the access token we just received from the token endpoint.
type oidcDoc struct {
auth, token, userinfo string
fetched time.Time
}
type oauthState struct {
idp, verifier, ret string
expires time.Time
}
var oidcClient = &http.Client{Timeout: 10 * time.Second}
func (s *Server) discover(idp *IdP) (*oidcDoc, error) {
s.mu.Lock()
d := s.oidc[idp.ID]
s.mu.Unlock()
if d != nil && time.Since(d.fetched) < time.Hour {
return d, nil
}
resp, err := oidcClient.Get(strings.TrimRight(idp.Issuer, "/") + "/.well-known/openid-configuration")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("discovery returned %d", resp.StatusCode)
}
var doc struct {
Auth string `json:"authorization_endpoint"`
Token string `json:"token_endpoint"`
Userinfo string `json:"userinfo_endpoint"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&doc); err != nil {
return nil, err
}
if doc.Auth == "" || doc.Token == "" || doc.Userinfo == "" {
return nil, errors.New("provider discovery document is missing endpoints")
}
d = &oidcDoc{auth: doc.Auth, token: doc.Token, userinfo: doc.Userinfo, fetched: time.Now()}
s.mu.Lock()
s.oidc[idp.ID] = d
s.mu.Unlock()
return d, nil
}
// baseURL is where browsers reach hammerhead.
func (s *Server) baseURL(r *http.Request) string {
s.mu.Lock()
u := s.st.Settings.DashboardURL
s.mu.Unlock()
if u != "" {
return u
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
return scheme + "://" + r.Host
}
func safeReturn(p string) string {
if p == "" || p[0] != '/' || strings.HasPrefix(p, "//") || strings.HasPrefix(p, "/\\") {
return "/"
}
return p
}
func (s *Server) oidcStart(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
var idp *IdP
for _, p := range s.st.IdPs {
if p.ID == r.PathValue("id") {
c := *p
idp = &c
}
}
s.mu.Unlock()
if idp == nil {
http.Error(w, "unknown provider", 404)
return
}
doc, err := s.discover(idp)
if err != nil {
http.Error(w, "identity provider unreachable: "+err.Error(), 502)
return
}
state, verifier := randHex(16), randHex(32)
sum := sha256.Sum256([]byte(verifier))
s.mu.Lock()
now := time.Now()
for k, v := range s.oauth {
if now.After(v.expires) {
delete(s.oauth, k)
}
}
s.oauth[state] = oauthState{idp: idp.ID, verifier: verifier, ret: safeReturn(r.URL.Query().Get("return")), expires: now.Add(10 * time.Minute)}
s.mu.Unlock()
scopes := idp.Scopes
if scopes == "" {
scopes = "openid email profile"
}
q := url.Values{
"response_type": {"code"},
"client_id": {idp.ClientID},
"redirect_uri": {s.baseURL(r) + "/auth/oidc/callback"},
"scope": {scopes},
"state": {state},
"code_challenge": {base64.RawURLEncoding.EncodeToString(sum[:])},
"code_challenge_method": {"S256"},
}
sep := "?"
if strings.Contains(doc.auth, "?") {
sep = "&"
}
http.Redirect(w, r, doc.auth+sep+q.Encode(), http.StatusFound)
}
func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
s.mu.Lock()
st, found := s.oauth[q.Get("state")]
delete(s.oauth, q.Get("state"))
var idp *IdP
if found && time.Now().Before(st.expires) {
for _, p := range s.st.IdPs {
if p.ID == st.idp {
c := *p
idp = &c
}
}
}
s.mu.Unlock()
if idp == nil {
http.Error(w, "sign-in expired, please try again", 400)
return
}
if e := q.Get("error"); e != "" {
http.Error(w, "identity provider said: "+e, 400)
return
}
email, name, sub, err := s.oidcExchange(r, idp, q.Get("code"), st.verifier)
if err != nil {
http.Error(w, "sign-in failed: "+err.Error(), 400)
return
}
uid, err := s.oidcUser(idp, email, name, sub)
if err != nil {
http.Error(w, err.Error(), 403)
return
}
s.startSession(w, r, uid)
s.audit("", email, "login", "oidc:"+idp.Name)
http.Redirect(w, r, st.ret, http.StatusFound)
}
func (s *Server) oidcExchange(r *http.Request, idp *IdP, code, verifier string) (email, name, sub string, err error) {
doc, err := s.discover(idp)
if err != nil {
return
}
resp, err := oidcClient.PostForm(doc.token, url.Values{
"grant_type": {"authorization_code"}, "code": {code},
"redirect_uri": {s.baseURL(r) + "/auth/oidc/callback"},
"client_id": {idp.ClientID}, "client_secret": {idp.ClientSecret}, "code_verifier": {verifier},
})
if err != nil {
return
}
defer resp.Body.Close()
var tok struct {
AccessToken string `json:"access_token"`
}
if resp.StatusCode != 200 || json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&tok) != nil || tok.AccessToken == "" {
err = fmt.Errorf("token exchange failed (%d)", resp.StatusCode)
return
}
req, _ := http.NewRequest("GET", doc.userinfo, nil)
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
ui, err := oidcClient.Do(req)
if err != nil {
return
}
defer ui.Body.Close()
var claims struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified any `json:"email_verified"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
}
if ui.StatusCode != 200 || json.NewDecoder(io.LimitReader(ui.Body, 1<<20)).Decode(&claims) != nil {
err = fmt.Errorf("userinfo failed (%d)", ui.StatusCode)
return
}
// some providers send "true" as a string, some omit it; only an explicit false is refused
if v, ok := claims.EmailVerified.(bool); ok && !v {
err = errors.New("your email address is not verified at the identity provider")
return
}
if v, ok := claims.EmailVerified.(string); ok && strings.EqualFold(v, "false") {
err = errors.New("your email address is not verified at the identity provider")
return
}
if claims.Sub == "" || claims.Email == "" {
err = errors.New("the identity provider did not return an email address")
return
}
name = claims.Name
if name == "" {
name = claims.PreferredUsername
}
return strings.ToLower(claims.Email), name, claims.Sub, nil
}
func (s *Server) oidcUser(idp *IdP, email, name, sub string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, u := range s.st.Users {
if u.IdP == idp.ID && u.IdPSub == sub {
return u.ID, nil
}
}
if len(idp.AllowedDomains) > 0 {
okDomain := false
for _, d := range idp.AllowedDomains {
if strings.HasSuffix(email, "@"+strings.ToLower(strings.TrimSpace(d))) {
okDomain = true
}
}
if !okDomain {
return "", errors.New("your email domain is not allowed to sign in here")
}
}
if u := s.userByEmail(email); u != nil { // link an existing account by verified email
if u.IdP == "" {
u.IdP, u.IdPSub = idp.ID, sub
s.save()
}
return u.ID, nil
}
if !idp.AutoProvision {
return "", errors.New("no account exists for " + email + "; ask an administrator to invite you")
}
if name == "" {
name = email
}
u := &User{ID: newID(), Email: email, Name: name, IdP: idp.ID, IdPSub: sub, Created: time.Now()}
s.st.Users = append(s.st.Users, u)
if s.findOrg(idp.OrgID) != nil {
role := s.findRole(idp.RoleID)
if role == nil || role.OrgID != idp.OrgID {
for _, r := range s.st.Roles {
if r.OrgID == idp.OrgID && r.Name == "Member" && r.Builtin {
role = r
}
}
}
if role != nil {
s.st.Members = append(s.st.Members, &Member{UserID: u.ID, OrgID: idp.OrgID, RoleID: role.ID})
}
}
s.save()
return u.ID, nil
}
// ---------- admin CRUD ----------
func (s *Server) listIdPs(w http.ResponseWriter, r *http.Request, p *principal) {
s.mu.Lock()
defer s.mu.Unlock()
out := []IdP{}
for _, i := range s.st.IdPs {
c := *i
c.ClientSecret = ""
out = append(out, c)
}
writeJSON(w, 200, out)
}
func (s *Server) saveIdP(w http.ResponseWriter, r *http.Request, p *principal) {
var in IdP
if !decode(w, r, &in) {
return
}
in.Issuer = strings.TrimRight(strings.TrimSpace(in.Issuer), "/")
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" || in.Issuer == "" || in.ClientID == "" {
fail(w, 400, errors.New("name, issuer URL and client ID are required"))
return
}
if !strings.HasPrefix(in.Issuer, "https://") && !strings.HasPrefix(in.Issuer, "http://") {
fail(w, 400, errors.New("issuer must be a URL"))
return
}
s.mu.Lock()
defer s.mu.Unlock()
if id := r.PathValue("id"); id != "" {
for _, e := range s.st.IdPs {
if e.ID == id {
if in.ClientSecret == "" {
in.ClientSecret = e.ClientSecret
}
in.ID = id
*e = in
delete(s.oidc, id)
s.save()
ok(w)
return
}
}
fail(w, 404, errors.New("no such provider"))
return
}
in.ID = newID()
s.st.IdPs = append(s.st.IdPs, &in)
s.save()
ok(w)
}
func (s *Server) deleteIdP(w http.ResponseWriter, r *http.Request, p *principal) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
s.st.IdPs = filter(s.st.IdPs, func(i *IdP) bool { return i.ID != id })
s.save()
ok(w)
}