Files
silo-server/internal/auth/oauth_handler.go
203a18ae83 feat(observability): OpenTelemetry logs+traces with secret redaction and slog standardization (#290)
* 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>
2026-07-09 08:53:52 -04:00

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
}