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