package httpx import ( "errors" "net/http" "net/url" "strings" "bountyboard/internal/domain" "bountyboard/internal/store" "bountyboard/internal/ulid" ) const ( oidcStateCookie = "bb_oidc_state" oidcVerifierCookie = "bb_oidc_verifier" oidcCookieTTL = 300 // seconds; the hop to the IdP and back ) func (s *Server) oidcFlowCookie(name, value string) *http.Cookie { maxAge := oidcCookieTTL if value == "" { maxAge = -1 } return &http.Cookie{ Name: name, Value: value, Path: "/api/v1/auth/oidc", MaxAge: maxAge, HttpOnly: true, Secure: s.cfg.CookieSecure(), SameSite: http.SameSiteLaxMode, } } func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) { if !s.oidc.Enabled() { writeError(w, http.StatusNotFound, "oidc_disabled", "SSO is not configured") return } authURL, state, verifier, err := s.oidc.AuthURL(r.Context()) if err != nil { s.internalError(w, r, "oidc auth url", err) return } http.SetCookie(w, s.oidcFlowCookie(oidcStateCookie, state)) http.SetCookie(w, s.oidcFlowCookie(oidcVerifierCookie, verifier)) http.Redirect(w, r, authURL, http.StatusFound) } // loginRedirect sends browser-flow errors back to the login page (the API // has no JSON consumer mid-redirect). func (s *Server) loginRedirect(w http.ResponseWriter, r *http.Request, errCode string) { target := "/login" if errCode != "" { target += "?error=" + url.QueryEscape(errCode) } http.Redirect(w, r, target, http.StatusFound) } func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) { if !s.oidc.Enabled() { writeError(w, http.StatusNotFound, "oidc_disabled", "SSO is not configured") return } // Always clear the one-shot flow cookies. defer func() { http.SetCookie(w, s.oidcFlowCookie(oidcStateCookie, "")) http.SetCookie(w, s.oidcFlowCookie(oidcVerifierCookie, "")) }() stateCookie, err1 := r.Cookie(oidcStateCookie) verifierCookie, err2 := r.Cookie(oidcVerifierCookie) if err1 != nil || err2 != nil || stateCookie.Value == "" || r.URL.Query().Get("state") != stateCookie.Value { s.loginRedirect(w, r, "oidc_state_mismatch") return } code := r.URL.Query().Get("code") if code == "" { s.loginRedirect(w, r, "oidc_denied") return } claims, err := s.oidc.Exchange(r.Context(), code, verifierCookie.Value) if err != nil { s.log.Error("oidc exchange", "err", err, "requestId", RequestID(r.Context())) s.loginRedirect(w, r, "oidc_failed") return } u, err := s.resolveOIDCUser(r, claims.Issuer, claims.Subject, claims.Email, claims.EmailVerified, claims.Name) if err != nil { var code *oidcLoginError if errors.As(err, &code) { s.loginRedirect(w, r, code.code) return } s.internalError(w, r, "oidc user resolution", err) return } sess, csrf, err := s.newSessionFor(r, u) if err != nil { s.internalError(w, r, "create session", err) return } s.setAuthCookies(w, sess.Token, csrf) http.Redirect(w, r, "/", http.StatusFound) } type oidcLoginError struct{ code string } func (e *oidcLoginError) Error() string { return "oidc login rejected: " + e.code } // resolveOIDCUser implements §7 linking: by (issuer,subject) first, then by // verified email, otherwise a fresh developer account. func (s *Server) resolveOIDCUser(r *http.Request, issuer, subject, email string, emailVerified bool, name string) (*domain.User, error) { ctx := r.Context() u, err := s.store.UserByOIDC(ctx, issuer, subject) switch { case err == nil: if u.Disabled { return nil, &oidcLoginError{code: "account_disabled"} } return u, nil case !errors.Is(err, store.ErrNotFound): return nil, err } email = store.NormalizeEmail(email) if email == "" { return nil, &oidcLoginError{code: "oidc_no_email"} } existing, err := s.store.UserByEmail(ctx, email) switch { case err == nil: // Linking an SSO identity to a pre-existing local account requires // the IdP to vouch for the address, otherwise anyone controlling the // IdP account could take over the local one. if !emailVerified { return nil, &oidcLoginError{code: "oidc_email_unverified"} } if existing.Disabled { return nil, &oidcLoginError{code: "account_disabled"} } if err := s.store.LinkOIDC(ctx, existing.ID, issuer, subject); err != nil { return nil, err } existing.Auth.OIDC = &domain.OIDCAuth{Issuer: issuer, Subject: subject} return existing, nil case !errors.Is(err, store.ErrNotFound): return nil, err } if strings.TrimSpace(name) == "" { name = email } u = &domain.User{ ID: ulid.New(), Email: email, Name: name, Roles: domain.Roles{Developer: true}, // first OIDC login = developer (§7) Auth: domain.Auth{OIDC: &domain.OIDCAuth{Issuer: issuer, Subject: subject}}, Settings: domain.UserSettings{ Theme: "light", Notifications: domain.NotificationPrefs{Email: true, InApp: true}, }, } if err := s.store.CreateUser(ctx, u); err != nil { return nil, err } s.metrics.Inc("auth_oidc_signups_total", 1) return u, nil }