Files
cairnobs/enterprise/internal/oidc/oidc.go
T
jcoffey-dev 3eb0f4c589 Phase 4: SSO scaffolding, RBAC enforcement, tenant-scoped dashboards, audit logging, K8s deployment
RBAC (api/internal/authz) is live on /query and /dashboards, backed by a
new enterprise/ module (session issuance, audit logging, RBAC storage,
OIDC/SAML protocol wiring) that core never imports -- only calls over
HTTP. Found and fixed a real cross-tenant vulnerability in dashboards
(no tenant_id filtering at all) while writing the threat model doc.

Two things are explicitly NOT done, documented rather than hidden:
tenant isolation for log data itself (/query still shares one ClickHouse
connection and Tantivy index across every tenant -- RBAC controls who
can query, not what a query can see), and human SSO login (protocol
wiring exists, no HTTP handler calls it yet). See
docs/security/threat-model.md and docs/phase-4-runbook.md.

Also adds deploy/ (Go Operator + Helm chart, validated offline only --
no cluster was reachable in this environment).
2026-08-13 22:16:59 -07:00

117 lines
3.9 KiB
Go

// Package oidc wires coreos/go-oidc into a small relying-party client:
// discovery, the login redirect, and code exchange + ID token
// verification. Deliberately thin -- this package answers "is this
// person who they say they are, and what's their email/subject" and
// nothing about tenants/roles; internal/session maps a verified identity
// to a tenant.ID via tenant.TrustFromValidatedSession, kept as a
// separate concern per /docs/phase-4-isolation-design.md.
package oidc
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
goidc "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
type Config struct {
IssuerURL string
ClientID string
ClientSecret string
RedirectURL string
// Scopes beyond the mandatory "openid" -- "email" and "profile" are
// the common additions IdPs support without extra configuration.
Scopes []string
}
// Provider wraps a discovered OIDC issuer and the oauth2 config derived
// from it. Construction does real network discovery (GET
// {issuer}/.well-known/openid-configuration) -- see New's doc comment.
type Provider struct {
verifier *goidc.IDTokenVerifier
oauth2 oauth2.Config
}
// Claims is the subset of ID token claims Sentry actually uses. Extend
// deliberately, not by passing the raw claim map further up the stack --
// every field added here is a field internal/session has to decide how
// to trust.
type Claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
}
// New performs OIDC discovery against cfg.IssuerURL. Real network I/O --
// call once at startup (or lazily, cached), not per request.
func New(ctx context.Context, cfg Config) (*Provider, error) {
if cfg.IssuerURL == "" || cfg.ClientID == "" || cfg.RedirectURL == "" {
return nil, fmt.Errorf("oidc: IssuerURL, ClientID, and RedirectURL are required")
}
issuer, err := goidc.NewProvider(ctx, cfg.IssuerURL)
if err != nil {
return nil, fmt.Errorf("oidc: discovering issuer %q: %w", cfg.IssuerURL, err)
}
scopes := append([]string{goidc.ScopeOpenID}, cfg.Scopes...)
return &Provider{
verifier: issuer.Verifier(&goidc.Config{ClientID: cfg.ClientID}),
oauth2: oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Endpoint: issuer.Endpoint(),
Scopes: scopes,
},
}, nil
}
// NewState generates a CSRF-protection state value for the login
// redirect. The caller is responsible for storing it (session/cookie)
// and comparing it against what comes back to the callback endpoint --
// this package doesn't hold any server-side state itself.
func NewState() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("oidc: generating state: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// AuthCodeURL is where the browser gets redirected to start login.
func (p *Provider) AuthCodeURL(state string) string {
return p.oauth2.AuthCodeURL(state)
}
// Exchange trades an authorization code for tokens and returns the
// verified ID token's claims. Verification (signature, issuer,
// audience, expiry) happens inside p.verifier.Verify -- this is the
// step that actually establishes trust, not just "we got a token back."
func (p *Provider) Exchange(ctx context.Context, code string) (*Claims, error) {
token, err := p.oauth2.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("oidc: exchanging code: %w", err)
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok || rawIDToken == "" {
return nil, fmt.Errorf("oidc: token response had no id_token")
}
idToken, err := p.verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("oidc: verifying id_token: %w", err)
}
var claims Claims
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("oidc: decoding claims: %w", err)
}
return &claims, nil
}