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>
76 lines
2.6 KiB
Go
76 lines
2.6 KiB
Go
// 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
|