ProofedMesh/services/signing-service/internal/token/pocket_id.go

77 lines
2.6 KiB
Go
Raw Normal View History

signing-service: Pocket-ID JWKS + K8s TokenReview + OPA (DEV-609) Wires the signing-service's production path for ProofedMesh Phase 1: the service now performs a double identity check (Pocket-ID OIDC JWT + K8s ServiceAccount TokenReview), reconciles both against the trust-api `agents` registry, and asks an OPA/Rego policy whether the caller may sign with the requested key_ref. Code - internal/token/pocket_id.go: OIDC-Discovery-backed JWKS validator with automatic kid rotation (coreos/go-oidc). - internal/token/k8s_tokenreview.go: hand-rolled TokenReview client; reads X-Workload-Token, posts to authentication.k8s.io/v1 via the pod's in-cluster SA token + CA. - internal/registry: Postgres resolver for the agents table + in-memory Static resolver for tests. - internal/policy/opa.go: OPA/rego StructuredDecider; input carries both lookups so the policy can enforce identity consistency AND the key_ref ↔ role mapping. - internal/httpapi/sign.go: new structured flow when Validator+TokenReviewer+Registry+StructuredPolicy are all wired; dev path (static bearers + AllowAll) remains intact. - cmd/signing-service/main.go: -pocket-id-issuer, -pocket-id-audience, -k8s-tokenreview, -registry-dsn, -policy-bundle flags. Policy - policies/sinks/signing_service.rego: identities_agree + key_ref_matches_role rules; agent → key_ref prefix map covers approval-service, testagent-transform, dummy-sink. Deploy - deploy/signing-service/01-rbac.yaml: ClusterRole with tokenreviews.create + binding to the signing-service SA. - deploy/signing-service/20-deployment.yaml: adds POCKET_ID_ISSUER, POCKET_ID_AUDIENCE, TRUST_PG_DSN env, projected SA token volume, OPA policy ConfigMap mount, structured flags. - deploy/signing-service/00-serviceaccount.yaml: disables default auto-mount; a projected volume is used. - deploy/signing-service/README.md: documents prod flags + ConfigMap generation. Tests - httptest-mock Pocket-ID (OIDC discovery + JWKS, kid rotation), StaticTokenReviewer fake, Static registry, real Rego policy. - Happy paths: Approval-Service → trust-api/approval-signer-2026-q4; testagent-transform → trust-api/agent-testagent-transform-2026-q4. - Denies: registry mismatch, wrong key_ref for role, missing / bad workload token, bad Pocket-ID token, kid rotation. Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 19:42:40 +00:00
// Pocket-ID OIDC-backed Bearer token validator.
//
// Phase 1 wires this up for the production path: the signing-service
// discovers the issuer, caches the JWKS, and auto-refreshes on `kid`
// rotation via coreos/go-oidc's RemoteKeySet.
package token
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/coreos/go-oidc/v3/oidc"
)
// PocketIDValidator verifies a Bearer JWT against a Pocket-ID issuer and
// returns the `sub` claim (which is the Pocket-ID client id).
type PocketIDValidator struct {
verifier *oidc.IDTokenVerifier
issuer string
audience string
}
// NewPocketIDValidator performs OIDC discovery and returns a validator.
// If audience is non-empty, the verifier enforces the `aud` claim.
// SkipClientIDCheck is used when audience is empty because Pocket ID
// subject tokens may be addressed to a service name rather than a client.
func NewPocketIDValidator(ctx context.Context, issuer, audience string) (*PocketIDValidator, error) {
if issuer == "" {
return nil, errors.New("token.PocketID: empty issuer")
}
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, fmt.Errorf("token.PocketID: oidc discovery for %q: %w", issuer, err)
}
cfg := &oidc.Config{
ClientID: audience,
SkipClientIDCheck: audience == "",
}
v := provider.Verifier(cfg)
return &PocketIDValidator{verifier: v, issuer: issuer, audience: audience}, nil
}
// Validate implements Validator. It parses, verifies and returns `sub`.
func (p *PocketIDValidator) Validate(ctx context.Context, bearer string) (string, error) {
if bearer == "" {
return "", ErrInvalid
}
idTok, err := p.verifier.Verify(ctx, bearer)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalid, err)
}
if idTok.Subject == "" {
return "", fmt.Errorf("%w: missing sub claim", ErrInvalid)
}
return idTok.Subject, nil
}
// Issuer returns the configured issuer URL (for logs / healthz).
func (p *PocketIDValidator) Issuer() string { return p.issuer }
// NewPocketIDValidatorWithHTTPClient is the same as NewPocketIDValidator
// but uses the supplied HTTP client (test seams, custom timeouts).
func NewPocketIDValidatorWithHTTPClient(ctx context.Context, issuer, audience string, client *http.Client) (*PocketIDValidator, error) {
if client != nil {
ctx = oidc.ClientContext(ctx, client)
}
return NewPocketIDValidator(ctx, issuer, audience)
}
// SignerRefreshInterval is advisory — go-oidc refreshes on `kid` miss but
// callers who want proactive refresh can wrap this in a timer. We expose
// it so cmd/ can decide a cadence.
const SignerRefreshInterval = 15 * time.Minute