* feat(observability): OpenTelemetry logs+traces with secret redaction Part of #265. Adds opt-in OpenTelemetry (logs + traces) alongside the existing stderr + opslog pipeline, plus secret redaction on all sinks. Default-off: with no OTEL_* / SILO_OTEL_ENABLED config, behavior is unchanged. Bootstrap (internal/telemetry): - Setup() builds one shared resource, a TracerProvider (parent-based trace-id ratio sampler), a LoggerProvider, and the W3C TraceContext+Baggage propagator from env. It installs NO MeterProvider — metrics stay on Prometheus, and the built-in no-op global MeterProvider keeps the trace instrumentation libs from double-emitting. Shutdown is deferred with a flush timeout. - Logs are bridged via otelslog fan-out (slog.MultiHandler), level-gated by the shared LevelVar and best-effort so a failing collector can't break the console or DB branches. stderr + opslog stay untouched. Secret redaction (internal/logredact): - A slog.Handler masks secret-keyed attributes (password, token, api_key, authorization, cookie, ...) — including .With-bound attrs, nested groups, secret-keyed group subtrees, and values behind a LogValuer — on the console and OTLP sinks, with a no-op fast path when a record has no secret keys. opslog.shouldRedact delegates to logredact.SecretKey so all sinks share one marker list. Rotation is infra-managed (no custom file sink): container runtime for stderr, collector/backend for OTLP, opslog partition-pruning for the DB. Documented in docs/architecture/observability.md. Verification: go build ./..., go vet, gofmt -l — clean; go test ./internal/telemetry/ ./internal/logredact/ -race pass. AI-use disclosure: implemented with AI assistance (Claude Code), including adversarial reviews that hardened the bootstrap and fixed two redaction leak paths; reviewed by the author. * refactor(observability): slog context+component sweep, sloglint gate (phase 3) Part of #265. Builds on the OTel bootstrap + redaction commit. Standardizes every log call site onto the context-carrying slog variants so records correlate with the active OpenTelemetry trace, and locks the standard in with a machine gate so future code (human- or AI-authored) can't drift back. - Call-site sweep: converted the remaining slog.<Level>(...) calls to the slog.<Level>Context(ctx, ...) form wherever a context.Context is in scope (background/init calls with no ctx are left as-is), across 183 files. Applied via a type-aware AST codemod. Log levels and message strings are preserved verbatim; a component attr (canonical per-package name) is added to direct package-level slog calls. Bound-logger calls keep their existing .With bindings. The main.go and telemetry package conversions rode with their file in the previous commit to keep each file within a single commit. - Enforcement (.golangci.yml): enable sloglint with context=scope, static-msg, key-naming-case=snake, no-mixed-args. After the sweep all four report zero violations repo-wide (tests included), so make lint / CI now blocks any regression to the non-context form. The gate ships with the sweep because it cannot be green until the legacy sites are converted. Metrics remain on Prometheus; no behavior change to /metrics or Grafana. Verification: go build ./..., go vet ./..., gofmt -l — clean; sloglint (all 4 rules) 0 violations repo-wide; log levels verified unchanged. AI-use disclosure: implemented with AI assistance (Claude Code), including the codemod; reviewed by the author. * fix(observability): honor per-signal OTLP protocol and secret WithGroup names Two Codex review findings on PR #290: - telemetry: OTEL_EXPORTER_OTLP_{TRACES,LOGS}_PROTOCOL now override the generic OTEL_EXPORTER_OTLP_PROTOCOL per signal, so mixed collector setups (e.g. HTTP logs + gRPC traces) build the right exporter. - logredact: entering a group whose name is secret-bearing (e.g. WithGroup("authorization")) now masks every leaf in that subtree, matching how slog.Group("authorization", ...) is masked as a whole. * fix(observability): address review feedback on telemetry bootstrap - Telemetry setup failure no longer kills boot: Setup returns usable no-op providers alongside the error and main logs and continues with telemetry disabled, honoring the best-effort contract. - Honor OTEL_TRACES_SAMPLER (always_on/off, traceidratio, parentbased_* variants); unsupported values fall back to parentbased_traceidratio. - Attach node identity as semconv service.instance.id instead of the non-semconv node.name. - Rename opslog retention-scope log attrs to target_component/target_level so they no longer collide with the canonical component routing key, and tag those lines with component=opslog. - Fix stale levelGated comment casing; use WarnContext in the telemetry shutdown defer; document the LogValuer double-resolve on the redaction slow path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
328 lines
11 KiB
Go
328 lines
11 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
|
"github.com/go-chi/chi/v5"
|
|
"google.golang.org/protobuf/types/known/structpb"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/clientip"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
// OAuthClient is the host-side gRPC client surface the OAuth handler needs.
|
|
// Defined as an interface so handler tests can substitute a fake.
|
|
type OAuthClient interface {
|
|
InitAuthorize(ctx context.Context, req *pluginv1.InitAuthorizeRequest) (*pluginv1.InitAuthorizeResponse, error)
|
|
ExchangeCode(ctx context.Context, req *pluginv1.ExchangeCodeRequest) (*pluginv1.AuthenticateResponse, error)
|
|
}
|
|
|
|
// OAuthLoginCompleter wraps the post-ExchangeCode work: lookup or provision
|
|
// the user identified by the AuthenticateResponse, create a session, mint a
|
|
// token pair. Defined as an interface so tests can avoid spinning up the
|
|
// full auth.Service.
|
|
type OAuthLoginCompleter interface {
|
|
CompleteOAuthLogin(ctx context.Context, in OAuthLoginInput) (*TokenPair, *models.User, error)
|
|
}
|
|
|
|
// OAuthLoginInput carries everything the completer needs to issue a session.
|
|
type OAuthLoginInput struct {
|
|
InstallationID int
|
|
CapabilityID string
|
|
Response *pluginv1.AuthenticateResponse
|
|
LinkingUserID int // 0 = not linking
|
|
DeviceName string
|
|
IP string
|
|
}
|
|
|
|
// OAuthHandlerDeps wires the OAuthHandler. ResolveClient turns the URL's
|
|
// installation_id into a plugin gRPC client; Provisioner consumes the
|
|
// AuthenticateResponse and produces a session.
|
|
type OAuthHandlerDeps struct {
|
|
Store OAuthStore
|
|
CompletionStore OAuthCompletionStore
|
|
StateSecret []byte
|
|
ResolveClient func(ctx context.Context, installationID int) (OAuthClient, string, error) // returns (client, capabilityID, err)
|
|
LoginCompleter OAuthLoginCompleter
|
|
HostBaseURL string
|
|
StateTTL time.Duration
|
|
// FrontendCompletePath is the SPA path the callback redirects to after
|
|
// minting a one-time completion code. The SPA exchanges that code for tokens.
|
|
FrontendCompletePath string
|
|
}
|
|
|
|
// OAuthHandler serves /init and /callback for OAuth-capable auth plugins.
|
|
type OAuthHandler struct {
|
|
deps OAuthHandlerDeps
|
|
}
|
|
|
|
func NewOAuthHandler(d OAuthHandlerDeps) *OAuthHandler {
|
|
if d.StateTTL == 0 {
|
|
d.StateTTL = 10 * time.Minute
|
|
}
|
|
if d.FrontendCompletePath == "" {
|
|
d.FrontendCompletePath = "/login/oauth-complete"
|
|
}
|
|
if d.CompletionStore == nil {
|
|
if store, ok := d.Store.(OAuthCompletionStore); ok {
|
|
d.CompletionStore = store
|
|
}
|
|
}
|
|
return &OAuthHandler{deps: d}
|
|
}
|
|
|
|
// ErrMissingInstallID is returned when the URL path has no install_id.
|
|
var ErrMissingInstallID = errors.New("install_id required")
|
|
|
|
// HandleInit serves POST /api/v1/auth/oauth/{install_id}/init.
|
|
func (h *OAuthHandler) HandleInit(w http.ResponseWriter, r *http.Request) {
|
|
installID, err := strconv.Atoi(chi.URLParam(r, "install_id"))
|
|
if err != nil || installID <= 0 {
|
|
http.Error(w, "invalid install_id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
next := normalizeOAuthNext(r.URL.Query().Get("next"))
|
|
|
|
client, _, err := h.deps.ResolveClient(r.Context(), installID)
|
|
if err != nil {
|
|
http.Error(w, "auth plugin unavailable", http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
nonce, err := randomHex(16)
|
|
if err != nil {
|
|
http.Error(w, "rand failure", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
state := SignState(h.deps.StateSecret, StatePayload{
|
|
Nonce: nonce,
|
|
InstallID: strconv.Itoa(installID),
|
|
ExpiresAt: now.Add(h.deps.StateTTL),
|
|
})
|
|
redirectURI := strings.TrimRight(h.deps.HostBaseURL, "/") + "/api/v1/auth/oauth/" + strconv.Itoa(installID) + "/callback"
|
|
|
|
resp, err := client.InitAuthorize(r.Context(), &pluginv1.InitAuthorizeRequest{
|
|
RedirectUri: redirectURI,
|
|
State: state,
|
|
// Linking is wired in a follow-up — see TODO below.
|
|
})
|
|
if err != nil {
|
|
slog.WarnContext(r.Context(), "oauth init_authorize failed", "component", "auth", "installation_id", installID, "error", err)
|
|
http.Error(w, "plugin init_authorize failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
if resp.GetAuthorizeUrl() == "" {
|
|
http.Error(w, "plugin returned empty authorize_url", http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
psBytes, _ := json.Marshal(resp.GetProviderState().AsMap())
|
|
sess := OAuthSession{
|
|
State: state,
|
|
InstallID: strconv.Itoa(installID),
|
|
RedirectURI: redirectURI,
|
|
ProviderState: psBytes,
|
|
NextURL: next,
|
|
ExpiresAt: now.Add(h.deps.StateTTL),
|
|
// TODO: when linking flow lands, read user_id from existing session
|
|
// and set LinkingUserID here.
|
|
}
|
|
if err := h.deps.Store.Insert(r.Context(), sess); err != nil {
|
|
slog.WarnContext(r.Context(), "oauth session insert failed", "component", "auth", "installation_id", installID, "error", err)
|
|
http.Error(w, "store insert failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, resp.GetAuthorizeUrl(), http.StatusFound)
|
|
}
|
|
|
|
// HandleCallback serves GET /api/v1/auth/oauth/{install_id}/callback.
|
|
func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
|
installID, err := strconv.Atoi(chi.URLParam(r, "install_id"))
|
|
if err != nil || installID <= 0 {
|
|
http.Error(w, "invalid install_id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
state := r.URL.Query().Get("state")
|
|
code := r.URL.Query().Get("code")
|
|
if state == "" || code == "" {
|
|
http.Error(w, "missing code or state", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
payload, err := VerifyState(h.deps.StateSecret, state)
|
|
if err != nil {
|
|
http.Redirect(w, r, "/login?error=oauth_failed&reason=state_invalid", http.StatusFound)
|
|
return
|
|
}
|
|
if payload.InstallID != strconv.Itoa(installID) {
|
|
http.Redirect(w, r, "/login?error=oauth_failed&reason=install_mismatch", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
sess, err := h.deps.Store.GetAndDelete(r.Context(), state)
|
|
if err != nil {
|
|
http.Redirect(w, r, "/login?error=oauth_failed&reason=session_expired", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
client, capabilityID, err := h.deps.ResolveClient(r.Context(), installID)
|
|
if err != nil {
|
|
http.Redirect(w, r, "/login?error=oauth_failed&reason=plugin_unavailable", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
var ps map[string]any
|
|
_ = json.Unmarshal(sess.ProviderState, &ps)
|
|
psStruct, _ := structpb.NewStruct(ps)
|
|
|
|
resp, err := client.ExchangeCode(r.Context(), &pluginv1.ExchangeCodeRequest{
|
|
Code: code,
|
|
State: state,
|
|
RedirectUri: sess.RedirectURI,
|
|
ProviderState: psStruct,
|
|
})
|
|
if err != nil {
|
|
slog.WarnContext(r.Context(), "oauth exchange_code failed", "component", "auth", "installation_id", installID, "error", err)
|
|
http.Redirect(w, r, "/login?error=oauth_failed&reason=exchange_failed", http.StatusFound)
|
|
return
|
|
}
|
|
if resp.GetExternalSubject() == "" {
|
|
http.Redirect(w, r, "/login?error=oauth_failed&reason=empty_subject", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
linkingUserID := 0
|
|
if sess.LinkingUserID != "" {
|
|
if uid, err := strconv.Atoi(sess.LinkingUserID); err == nil {
|
|
linkingUserID = uid
|
|
}
|
|
}
|
|
|
|
pair, _, err := h.deps.LoginCompleter.CompleteOAuthLogin(r.Context(), OAuthLoginInput{
|
|
InstallationID: installID,
|
|
CapabilityID: capabilityID,
|
|
Response: resp,
|
|
LinkingUserID: linkingUserID,
|
|
DeviceName: r.UserAgent(),
|
|
IP: clientIP(r),
|
|
})
|
|
if err != nil {
|
|
slog.WarnContext(r.Context(), "oauth login completion failed", "component", "auth", "installation_id", installID, "error", err)
|
|
http.Redirect(w, r, "/login?error=oauth_failed&reason=login_failed", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
if h.deps.CompletionStore == nil {
|
|
slog.WarnContext(r.Context(), "oauth completion store is unavailable", "component", "auth", "installation_id", installID)
|
|
http.Redirect(w, r, "/login?error=oauth_failed&reason=completion_unavailable", http.StatusFound)
|
|
return
|
|
}
|
|
completionCode, err := randomHex(32)
|
|
if err != nil {
|
|
slog.WarnContext(r.Context(), "oauth completion code generation failed", "component", "auth", "installation_id", installID, "error", err)
|
|
http.Redirect(w, r, "/login?error=oauth_failed&reason=completion_failed", http.StatusFound)
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
if err := h.deps.CompletionStore.InsertCompletion(r.Context(), OAuthCompletion{
|
|
Code: completionCode,
|
|
AccessToken: pair.AccessToken,
|
|
RefreshToken: pair.RefreshToken,
|
|
ExpiresIn: pair.ExpiresIn,
|
|
NextURL: sess.NextURL,
|
|
ExpiresAt: now.Add(time.Minute),
|
|
}); err != nil {
|
|
slog.WarnContext(r.Context(), "oauth completion insert failed", "component", "auth", "installation_id", installID, "error", err)
|
|
http.Redirect(w, r, "/login?error=oauth_failed&reason=completion_failed", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
values := url.Values{}
|
|
values.Set("code", completionCode)
|
|
completeURL := strings.TrimRight(h.deps.HostBaseURL, "/") + h.deps.FrontendCompletePath + "?" + values.Encode()
|
|
http.Redirect(w, r, completeURL, http.StatusFound)
|
|
}
|
|
|
|
type OAuthCompleteRequest struct {
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
type OAuthCompleteResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
NextURL string `json:"next"`
|
|
}
|
|
|
|
func (h *OAuthHandler) HandleComplete(w http.ResponseWriter, r *http.Request) {
|
|
if h.deps.CompletionStore == nil {
|
|
http.Error(w, "oauth completion unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
var req OAuthCompleteRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
code := strings.TrimSpace(req.Code)
|
|
if code == "" {
|
|
http.Error(w, "code required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
completion, err := h.deps.CompletionStore.GetAndDeleteCompletion(r.Context(), code)
|
|
if err != nil {
|
|
http.Error(w, "invalid or expired completion code", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(OAuthCompleteResponse{
|
|
AccessToken: completion.AccessToken,
|
|
RefreshToken: completion.RefreshToken,
|
|
ExpiresIn: completion.ExpiresIn,
|
|
NextURL: completion.NextURL,
|
|
})
|
|
}
|
|
|
|
func clientIP(r *http.Request) string {
|
|
if ip := strings.TrimSpace(clientip.FromContext(r.Context())); ip != "" {
|
|
return ip
|
|
}
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err == nil {
|
|
return strings.TrimSpace(host)
|
|
}
|
|
return strings.Trim(strings.TrimSpace(r.RemoteAddr), "[]")
|
|
}
|
|
|
|
func normalizeOAuthNext(next string) string {
|
|
next = strings.TrimSpace(next)
|
|
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
|
|
return "/"
|
|
}
|
|
return next
|
|
}
|
|
|
|
func randomHex(n int) (string, error) {
|
|
buf := make([]byte, n)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(buf), nil
|
|
}
|