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>
This commit is contained in:
1 parent
3daa368233
commit
f521fa8668
35 files changed
+10392
-1
No files matched your search
@@ -0,0 +1,346 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user