From 2ffa9d035be386841268fdf92643d115eb411b46 Mon Sep 17 00:00:00 2001 From: Matt Braun Date: Fri, 13 Mar 2026 13:21:40 -0500 Subject: [PATCH 1/4] Add .DS_Store, binary, and scratch files to .gitignore Also deduplicate the .vscode entry. --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8807613..39e4912 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ *.pem /bin/* /.envrc -.vscode +.DS_Store +/tokenizer +/foo.toml From f8b01f17433b58208d92b26396b2b6ccd8a82972 Mon Sep 17 00:00:00 2001 From: Matt Braun Date: Fri, 20 Mar 2026 13:50:20 -0500 Subject: [PATCH 2/4] Add JWTProcessorConfig for Google service account auth Implements a stateless JWT-bearer token exchange flow: 1. Client sends sealed SA private key to tokenizer, proxied to Google's token endpoint 2. Tokenizer signs a JWT (RS256), builds the exchange request body, intercepts Google's response, seals the access token into a new InjectProcessorConfig, and returns the sealed blob to the caller 3. Caller uses the sealed access token for subsequent API requests via the existing InjectProcessor path Key design decisions: - Tokenizer is completely stateless (no cache). Multiple instances and blue/green deploys work without shared state. - The caller never sees any plaintext credential. The private key, JWT, and access token only exist in tokenizer's process memory. - Response body rewriting is a new capability, contained to this processor via the ResponseProcessorConfig interface. - SealingContext passes the parent Secret's auth config and validators into the processor so the derived sealed token carries the same authorization requirements. - Sub and scopes are overridable at request time via params. Adds golang-jwt/jwt/v5 dependency. --- go.mod | 1 + go.sum | 2 + processor.go | 264 ++++++++++++++++++++++++++- processor_test.go | 455 +++++++++++++++++++++++++++++++++++++++++++++- tokenizer.go | 83 ++++++--- tokenizer_test.go | 92 ++++++++++ 6 files changed, 861 insertions(+), 36 deletions(-) diff --git a/go.mod b/go.mod index b07fdf8..80c2a95 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( require ( github.com/alecthomas/repr v0.4.0 // indirect github.com/aws/smithy-go v1.20.3 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.3.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect diff --git a/go.sum b/go.sum index ffffba4..264ee38 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027 h1:1L0aalTpPz7YlMx github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 h1:dWB6v3RcOy03t/bUadywsbyrQwCqZeNIEX6M1OtSZOM= github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/processor.go b/processor.go index aa7f435..42650ea 100644 --- a/processor.go +++ b/processor.go @@ -4,18 +4,23 @@ import ( "bytes" "crypto" "crypto/hmac" + "crypto/rsa" _ "crypto/sha256" + "crypto/x509" "encoding/json" + "encoding/pem" "errors" "fmt" "io" "net/http" "net/textproto" + "net/url" "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/golang-jwt/jwt/v5" "github.com/icholy/replace" "golang.org/x/exp/slices" ) @@ -42,6 +47,16 @@ const ( // ParamPlaceholder specifies the placeholder pattern to replace in the // request body with the token value. Used by InjectBodyProcessor. ParamPlaceholder = "placeholder" + + // ParamSubject optionally specifies the subject (user to impersonate) + // for JWTProcessor. Allows different requests through the same sealed + // credential to impersonate different users. + ParamSubject = "sub" + + // ParamScopes optionally specifies the OAuth2 scopes for JWTProcessor. + // Allows different requests through the same sealed credential to + // request different scopes. + ParamScopes = "scopes" ) const ( @@ -51,11 +66,27 @@ const ( type RequestProcessor func(r *http.Request) error +// SealingContext provides the information a processor needs to create new +// sealed secrets. Only JWTProcessorConfig uses this today - it seals the +// access token into a new InjectProcessorConfig for the caller to reuse. +type SealingContext struct { + AuthConfig AuthConfig + RequestValidators []RequestValidator + SealKey *[32]byte +} + type ProcessorConfig interface { - Processor(map[string]string) (RequestProcessor, error) + Processor(params map[string]string, ctx *SealingContext) (RequestProcessor, error) StripHazmat() ProcessorConfig } +// ResponseProcessorConfig is an optional interface for processors that also +// need to transform the HTTP response. The JWT processor uses this to replace +// Google's token response with a sealed access token. +type ResponseProcessorConfig interface { + ResponseProcessor(params map[string]string, ctx *SealingContext) (func(*http.Response) error, error) +} + type wireProcessor struct { InjectProcessorConfig *InjectProcessorConfig `json:"inject_processor,omitempty"` InjectHMACProcessorConfig *InjectHMACProcessorConfig `json:"inject_hmac_processor,omitempty"` @@ -63,6 +94,7 @@ type wireProcessor struct { OAuthProcessorConfig *OAuthProcessorConfig `json:"oauth2_processor,omitempty"` OAuthBodyProcessorConfig *OAuthBodyProcessorConfig `json:"oauth2_body_processor,omitempty"` Sigv4ProcessorConfig *Sigv4ProcessorConfig `json:"sigv4_processor,omitempty"` + JWTProcessorConfig *JWTProcessorConfig `json:"jwt_processor,omitempty"` MultiProcessorConfig *MultiProcessorConfig `json:"multi_processor,omitempty"` } @@ -80,6 +112,8 @@ func newWireProcessor(p ProcessorConfig) (wireProcessor, error) { return wireProcessor{OAuthBodyProcessorConfig: p}, nil case *Sigv4ProcessorConfig: return wireProcessor{Sigv4ProcessorConfig: p}, nil + case *JWTProcessorConfig: + return wireProcessor{JWTProcessorConfig: p}, nil case *MultiProcessorConfig: return wireProcessor{MultiProcessorConfig: p}, nil default: @@ -115,6 +149,10 @@ func (wp *wireProcessor) getProcessorConfig() (ProcessorConfig, error) { np += 1 p = wp.Sigv4ProcessorConfig } + if wp.JWTProcessorConfig != nil { + np += 1 + p = wp.JWTProcessorConfig + } if wp.MultiProcessorConfig != nil { np += 1 p = wp.MultiProcessorConfig @@ -134,7 +172,7 @@ type InjectProcessorConfig struct { var _ ProcessorConfig = new(InjectProcessorConfig) -func (c *InjectProcessorConfig) Processor(params map[string]string) (RequestProcessor, error) { +func (c *InjectProcessorConfig) Processor(params map[string]string, _ *SealingContext) (RequestProcessor, error) { if c.Token == "" { return nil, errors.New("missing token") } @@ -167,7 +205,7 @@ type InjectHMACProcessorConfig struct { var _ ProcessorConfig = new(InjectHMACProcessorConfig) -func (c *InjectHMACProcessorConfig) Processor(params map[string]string) (RequestProcessor, error) { +func (c *InjectHMACProcessorConfig) Processor(params map[string]string, _ *SealingContext) (RequestProcessor, error) { var h crypto.Hash switch c.Hash { case "sha256": @@ -224,7 +262,7 @@ type InjectBodyProcessorConfig struct { var _ ProcessorConfig = new(InjectBodyProcessorConfig) -func (c *InjectBodyProcessorConfig) Processor(params map[string]string) (RequestProcessor, error) { +func (c *InjectBodyProcessorConfig) Processor(params map[string]string, _ *SealingContext) (RequestProcessor, error) { if c.Token == "" { return nil, errors.New("missing token") } @@ -273,7 +311,7 @@ type OAuthToken struct { var _ ProcessorConfig = (*OAuthProcessorConfig)(nil) -func (c *OAuthProcessorConfig) Processor(params map[string]string) (RequestProcessor, error) { +func (c *OAuthProcessorConfig) Processor(params map[string]string, _ *SealingContext) (RequestProcessor, error) { token := c.Token.AccessToken if params[ParamSubtoken] == SubtokenRefresh { token = c.Token.RefreshToken @@ -326,7 +364,7 @@ type OAuthBodyProcessorConfig struct { var _ ProcessorConfig = (*OAuthBodyProcessorConfig)(nil) -func (c *OAuthBodyProcessorConfig) Processor(params map[string]string) (RequestProcessor, error) { +func (c *OAuthBodyProcessorConfig) Processor(params map[string]string, _ *SealingContext) (RequestProcessor, error) { token := c.Token.AccessToken if params[ParamSubtoken] == SubtokenRefresh { token = c.Token.RefreshToken @@ -380,7 +418,7 @@ type Sigv4ProcessorConfig struct { var _ ProcessorConfig = (*Sigv4ProcessorConfig)(nil) -func (c *Sigv4ProcessorConfig) Processor(params map[string]string) (RequestProcessor, error) { +func (c *Sigv4ProcessorConfig) Processor(params map[string]string, _ *SealingContext) (RequestProcessor, error) { if len(c.AccessKey) == 0 { return nil, errors.New("missing access key") @@ -488,14 +526,222 @@ func (c *Sigv4ProcessorConfig) StripHazmat() ProcessorConfig { } } +const defaultTokenURL = "https://oauth2.googleapis.com/token" + +// JWTProcessorConfig signs a JWT using an RSA private key and constructs a +// token exchange request body. This processor also transforms the response: +// it intercepts the token endpoint's response, extracts the access token, +// seals it into a new InjectProcessorConfig secret, and replaces the response +// body with the sealed token. This keeps tokenizer completely stateless while +// ensuring the caller never sees any plaintext credential. +// +// The outbound token exchange happens as a normal proxied request - the caller +// sends the request to the token endpoint through tokenizer, and this processor +// builds the POST body (grant_type + signed JWT assertion). The response +// rewriting is the only side effect. +// +// This design follows the industry-standard pattern used by HashiCorp Vault +// and AWS IAM Roles Anywhere, where the proxy performs the full credential +// exchange internally. The alternative (returning the plaintext access token +// to the caller) was rejected because the token could be exfiltrated. +type JWTProcessorConfig struct { + PrivateKey []byte `json:"private_key"` // PEM-encoded RSA private key + Email string `json:"email"` // Service account email (JWT "iss") + Subject string `json:"sub"` // User to impersonate (domain-wide delegation) + Scopes string `json:"scopes"` // Space-separated OAuth2 scopes + TokenURL string `json:"token_url"` // Token endpoint (default: Google's) +} + +// SealedTokenResponse is the response body returned to the caller after a +// successful JWT token exchange. The sealed_token field is an opaque encrypted +// blob containing an InjectProcessorConfig that the caller can use for +// subsequent API requests through tokenizer. +type SealedTokenResponse struct { + SealedToken string `json:"sealed_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` +} + +var _ ProcessorConfig = (*JWTProcessorConfig)(nil) +var _ ResponseProcessorConfig = (*JWTProcessorConfig)(nil) + +func (c *JWTProcessorConfig) parseKey() (*rsa.PrivateKey, error) { + if len(c.PrivateKey) == 0 { + return nil, errors.New("missing private key") + } + + block, _ := pem.Decode(c.PrivateKey) + if block == nil { + return nil, errors.New("invalid PEM-encoded private key") + } + + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + // Try PKCS8 as a fallback (Google sometimes uses this format) + pkcs8Key, pkcs8Err := x509.ParsePKCS8PrivateKey(block.Bytes) + if pkcs8Err != nil { + return nil, fmt.Errorf("invalid private key: %w", err) + } + var ok bool + key, ok = pkcs8Key.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("private key is not RSA") + } + } + + return key, nil +} + +func (c *JWTProcessorConfig) tokenURL() string { + if c.TokenURL != "" { + return c.TokenURL + } + return defaultTokenURL +} + +func (c *JWTProcessorConfig) Processor(params map[string]string, _ *SealingContext) (RequestProcessor, error) { + rsaKey, err := c.parseKey() + if err != nil { + return nil, err + } + + if c.Email == "" { + return nil, errors.New("missing email") + } + + // Resolve subject and scopes, allowing param overrides + subject := c.Subject + if s, ok := params[ParamSubject]; ok { + subject = s + } + + scopes := c.Scopes + if s, ok := params[ParamScopes]; ok { + scopes = s + } + + tokenURL := c.tokenURL() + + return func(r *http.Request) error { + now := time.Now() + + claims := jwt.MapClaims{ + "iss": c.Email, + "scope": scopes, + "aud": tokenURL, + "iat": now.Unix(), + "exp": now.Add(time.Hour).Unix(), + } + if subject != "" { + claims["sub"] = subject + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + signedJWT, err := token.SignedString(rsaKey) + if err != nil { + return fmt.Errorf("failed to sign JWT: %w", err) + } + + // Build the token exchange POST body + body := url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"}, + "assertion": {signedJWT}, + }.Encode() + + r.Body = io.NopCloser(strings.NewReader(body)) + r.ContentLength = int64(len(body)) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + return nil + }, nil +} + +func (c *JWTProcessorConfig) ResponseProcessor(params map[string]string, ctx *SealingContext) (func(*http.Response) error, error) { + if ctx == nil { + return nil, errors.New("JWT response processor requires a sealing context") + } + + return func(resp *http.Response) error { + // Pass through non-OK responses without modification + if resp.StatusCode != http.StatusOK { + return nil + } + + // Parse Google's token response + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read token response: %w", err) + } + + var tokenResp struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` + } + if err := json.Unmarshal(body, &tokenResp); err != nil { + return fmt.Errorf("failed to parse token response: %w", err) + } + + if tokenResp.AccessToken == "" { + return errors.New("token response missing access_token") + } + + // Create a new Secret with InjectProcessorConfig, forwarding auth + // and validators from the original secret + newSecret := &Secret{ + AuthConfig: ctx.AuthConfig, + ProcessorConfig: &InjectProcessorConfig{Token: tokenResp.AccessToken}, + RequestValidators: ctx.RequestValidators, + } + + sealedToken, err := newSecret.sealRaw(ctx.SealKey) + if err != nil { + return fmt.Errorf("failed to seal access token: %w", err) + } + + // Subtract a small buffer from expires_in to account for clock skew + expiresIn := tokenResp.ExpiresIn + if expiresIn > 60 { + expiresIn -= 60 + } + + sealedResp := SealedTokenResponse{ + SealedToken: sealedToken, + ExpiresIn: expiresIn, + TokenType: "sealed", + } + + respJSON, err := json.Marshal(sealedResp) + if err != nil { + return fmt.Errorf("failed to marshal sealed response: %w", err) + } + + resp.Body = io.NopCloser(bytes.NewReader(respJSON)) + resp.ContentLength = int64(len(respJSON)) + resp.Header.Set("Content-Type", "application/json") + + return nil + }, nil +} + +func (c *JWTProcessorConfig) StripHazmat() ProcessorConfig { + return &JWTProcessorConfig{ + PrivateKey: redactedBase64, + Email: c.Email, + Subject: c.Subject, + Scopes: c.Scopes, + TokenURL: c.TokenURL, + } +} + type MultiProcessorConfig []ProcessorConfig var _ ProcessorConfig = new(MultiProcessorConfig) -func (c MultiProcessorConfig) Processor(params map[string]string) (RequestProcessor, error) { +func (c MultiProcessorConfig) Processor(params map[string]string, ctx *SealingContext) (RequestProcessor, error) { var processors []RequestProcessor for _, cfg := range c { - proc, err := cfg.Processor(params) + proc, err := cfg.Processor(params, ctx) if err != nil { return nil, err } diff --git a/processor_test.go b/processor_test.go index bfcac5d..cf2fb6f 100644 --- a/processor_test.go +++ b/processor_test.go @@ -3,12 +3,23 @@ package tokenizer import ( "bytes" "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" "io" "net/http" "strings" "testing" + "time" "github.com/alecthomas/assert/v2" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/nacl/box" ) func TestFmtProcessor(t *testing.T) { @@ -174,7 +185,7 @@ func TestInjectBodyProcessorConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - proc, err := tt.config.Processor(tt.params) + proc, err := tt.config.Processor(tt.params, nil) assert.NoError(t, err) req := &http.Request{ @@ -245,7 +256,7 @@ func TestOAuthProcessorConfigBodyInjection(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - proc, err := tt.config.Processor(tt.params) + proc, err := tt.config.Processor(tt.params, nil) assert.NoError(t, err) req := &http.Request{ @@ -358,7 +369,7 @@ func TestOAuthBodyProcessorConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - proc, err := tt.config.Processor(tt.params) + proc, err := tt.config.Processor(tt.params, nil) assert.NoError(t, err) req := &http.Request{ @@ -390,6 +401,440 @@ func stringToReadCloser(s string) io.ReadCloser { return io.NopCloser(strings.NewReader(s)) } +func generateTestRSAKey(t *testing.T) (*rsa.PrivateKey, []byte) { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NoError(t, err) + pemBytes := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + return key, pemBytes +} + +func generateTestSealKey(t *testing.T) (sealKey string, pub *[32]byte, priv *[32]byte) { + t.Helper() + pub, priv, err := box.GenerateKey(rand.Reader) + assert.NoError(t, err) + return hex.EncodeToString(pub[:]), pub, priv +} + +func TestJWTProcessorConfig_Processor_BuildsValidJWT(t *testing.T) { + rsaKey, pemBytes := generateTestRSAKey(t) + + cfg := &JWTProcessorConfig{ + PrivateKey: pemBytes, + Email: "test-sa@my-project.iam.gserviceaccount.com", + Scopes: "https://www.googleapis.com/auth/admin.directory.user", + TokenURL: "https://oauth2.googleapis.com/token", + } + + proc, err := cfg.Processor(nil, nil) + assert.NoError(t, err) + + req, err := http.NewRequest(http.MethodPost, "https://oauth2.googleapis.com/token", nil) + assert.NoError(t, err) + + err = proc(req) + assert.NoError(t, err) + + // Verify Content-Type is set + assert.Equal(t, "application/x-www-form-urlencoded", req.Header.Get("Content-Type")) + + // Read the body and parse the form + body, err := io.ReadAll(req.Body) + assert.NoError(t, err) + bodyStr := string(body) + + // Verify grant_type + assert.Contains(t, bodyStr, "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer") + + // Extract the assertion parameter + var assertion string + for _, param := range strings.Split(bodyStr, "&") { + kv := strings.SplitN(param, "=", 2) + if len(kv) == 2 && kv[0] == "assertion" { + assertion = kv[1] + } + } + assert.NotEqual(t, "", assertion) + + // Parse and verify the JWT + token, err := jwt.Parse(assertion, func(token *jwt.Token) (interface{}, error) { + assert.Equal(t, "RS256", token.Method.Alg()) + return &rsaKey.PublicKey, nil + }) + assert.NoError(t, err) + assert.True(t, token.Valid) + + claims, ok := token.Claims.(jwt.MapClaims) + assert.True(t, ok) + assert.Equal(t, "test-sa@my-project.iam.gserviceaccount.com", claims["iss"]) + assert.Equal(t, "https://www.googleapis.com/auth/admin.directory.user", claims["scope"]) + assert.Equal(t, "https://oauth2.googleapis.com/token", claims["aud"]) + + // Verify iat and exp are set and exp is ~1 hour after iat + iat, ok := claims["iat"].(float64) + assert.True(t, ok) + exp, ok := claims["exp"].(float64) + assert.True(t, ok) + assert.True(t, exp-iat >= 3599 && exp-iat <= 3601) + assert.True(t, time.Unix(int64(iat), 0).Before(time.Now().Add(time.Minute))) +} + +func TestJWTProcessorConfig_Processor_SubjectAndScopesFromParams(t *testing.T) { + rsaKey, pemBytes := generateTestRSAKey(t) + + cfg := &JWTProcessorConfig{ + PrivateKey: pemBytes, + Email: "test-sa@my-project.iam.gserviceaccount.com", + Scopes: "https://www.googleapis.com/auth/admin.directory.user", + TokenURL: "https://oauth2.googleapis.com/token", + } + + params := map[string]string{ + ParamSubject: "admin@example.com", + ParamScopes: "https://www.googleapis.com/auth/gmail.readonly", + } + + proc, err := cfg.Processor(params, nil) + assert.NoError(t, err) + + req, err := http.NewRequest(http.MethodPost, "https://oauth2.googleapis.com/token", nil) + assert.NoError(t, err) + + err = proc(req) + assert.NoError(t, err) + + // Extract and parse the JWT from the body + body, err := io.ReadAll(req.Body) + assert.NoError(t, err) + var assertion string + for _, param := range strings.Split(string(body), "&") { + kv := strings.SplitN(param, "=", 2) + if len(kv) == 2 && kv[0] == "assertion" { + assertion = kv[1] + } + } + + token, err := jwt.Parse(assertion, func(token *jwt.Token) (interface{}, error) { + return &rsaKey.PublicKey, nil + }) + assert.NoError(t, err) + claims := token.Claims.(jwt.MapClaims) + + // Params should override config + assert.Equal(t, "admin@example.com", claims["sub"]) + assert.Equal(t, "https://www.googleapis.com/auth/gmail.readonly", claims["scope"]) +} + +func TestJWTProcessorConfig_Processor_SubjectFromConfig(t *testing.T) { + rsaKey, pemBytes := generateTestRSAKey(t) + + cfg := &JWTProcessorConfig{ + PrivateKey: pemBytes, + Email: "test-sa@my-project.iam.gserviceaccount.com", + Subject: "default-admin@example.com", + Scopes: "https://www.googleapis.com/auth/admin.directory.user", + TokenURL: "https://oauth2.googleapis.com/token", + } + + proc, err := cfg.Processor(nil, nil) + assert.NoError(t, err) + + req, err := http.NewRequest(http.MethodPost, "https://oauth2.googleapis.com/token", nil) + assert.NoError(t, err) + + err = proc(req) + assert.NoError(t, err) + + body, err := io.ReadAll(req.Body) + assert.NoError(t, err) + var assertion string + for _, param := range strings.Split(string(body), "&") { + kv := strings.SplitN(param, "=", 2) + if len(kv) == 2 && kv[0] == "assertion" { + assertion = kv[1] + } + } + + token, err := jwt.Parse(assertion, func(token *jwt.Token) (interface{}, error) { + return &rsaKey.PublicKey, nil + }) + assert.NoError(t, err) + claims := token.Claims.(jwt.MapClaims) + assert.Equal(t, "default-admin@example.com", claims["sub"]) +} + +func TestJWTProcessorConfig_Processor_MissingPrivateKey(t *testing.T) { + cfg := &JWTProcessorConfig{ + Email: "test-sa@my-project.iam.gserviceaccount.com", + Scopes: "https://www.googleapis.com/auth/admin.directory.user", + TokenURL: "https://oauth2.googleapis.com/token", + } + + _, err := cfg.Processor(nil, nil) + assert.Error(t, err) +} + +func TestJWTProcessorConfig_Processor_InvalidPrivateKey(t *testing.T) { + cfg := &JWTProcessorConfig{ + PrivateKey: []byte("not-a-real-key"), + Email: "test-sa@my-project.iam.gserviceaccount.com", + Scopes: "https://www.googleapis.com/auth/admin.directory.user", + TokenURL: "https://oauth2.googleapis.com/token", + } + + _, err := cfg.Processor(nil, nil) + assert.Error(t, err) +} + +func TestJWTProcessorConfig_Processor_MissingEmail(t *testing.T) { + _, pemBytes := generateTestRSAKey(t) + + cfg := &JWTProcessorConfig{ + PrivateKey: pemBytes, + Scopes: "https://www.googleapis.com/auth/admin.directory.user", + TokenURL: "https://oauth2.googleapis.com/token", + } + + _, err := cfg.Processor(nil, nil) + assert.Error(t, err) +} + +func TestJWTProcessorConfig_Processor_DefaultTokenURL(t *testing.T) { + rsaKey, pemBytes := generateTestRSAKey(t) + + cfg := &JWTProcessorConfig{ + PrivateKey: pemBytes, + Email: "test-sa@my-project.iam.gserviceaccount.com", + Scopes: "https://www.googleapis.com/auth/admin.directory.user", + // TokenURL intentionally omitted - should default to Google's endpoint + } + + proc, err := cfg.Processor(nil, nil) + assert.NoError(t, err) + + req, err := http.NewRequest(http.MethodPost, "https://oauth2.googleapis.com/token", nil) + assert.NoError(t, err) + + err = proc(req) + assert.NoError(t, err) + + body, err := io.ReadAll(req.Body) + assert.NoError(t, err) + var assertion string + for _, param := range strings.Split(string(body), "&") { + kv := strings.SplitN(param, "=", 2) + if len(kv) == 2 && kv[0] == "assertion" { + assertion = kv[1] + } + } + + token, err := jwt.Parse(assertion, func(token *jwt.Token) (interface{}, error) { + return &rsaKey.PublicKey, nil + }) + assert.NoError(t, err) + claims := token.Claims.(jwt.MapClaims) + // Default token URL should be used as the aud claim + assert.Equal(t, defaultTokenURL, claims["aud"]) +} + +func TestJWTProcessorConfig_StripHazmat(t *testing.T) { + _, pemBytes := generateTestRSAKey(t) + + cfg := &JWTProcessorConfig{ + PrivateKey: pemBytes, + Email: "test-sa@my-project.iam.gserviceaccount.com", + Subject: "admin@example.com", + Scopes: "https://www.googleapis.com/auth/admin.directory.user", + TokenURL: "https://oauth2.googleapis.com/token", + } + + stripped := cfg.StripHazmat().(*JWTProcessorConfig) + + // Private key should be redacted + assert.NotEqual(t, pemBytes, stripped.PrivateKey) + assert.Equal(t, redactedBase64, stripped.PrivateKey) + + // Non-hazmat fields should be preserved + assert.Equal(t, "test-sa@my-project.iam.gserviceaccount.com", stripped.Email) + assert.Equal(t, "admin@example.com", stripped.Subject) + assert.Equal(t, "https://www.googleapis.com/auth/admin.directory.user", stripped.Scopes) + assert.Equal(t, "https://oauth2.googleapis.com/token", stripped.TokenURL) +} + +func TestJWTProcessorConfig_ResponseProcessor_SealsAccessToken(t *testing.T) { + _, pemBytes := generateTestRSAKey(t) + sealKey, pub, priv := generateTestSealKey(t) + + cfg := &JWTProcessorConfig{ + PrivateKey: pemBytes, + Email: "test-sa@my-project.iam.gserviceaccount.com", + Scopes: "https://www.googleapis.com/auth/admin.directory.user", + TokenURL: "https://oauth2.googleapis.com/token", + } + + authCfg := NewBearerAuthConfig("trustno1") + validators := []RequestValidator{AllowHosts("oauth2.googleapis.com", "admin.googleapis.com")} + + ctx := &SealingContext{ + AuthConfig: authCfg, + RequestValidators: validators, + SealKey: pub, + } + + respProc, err := cfg.ResponseProcessor(nil, ctx) + assert.NoError(t, err) + assert.NotEqual(t, nil, respProc) + + // Simulate Google's token response + googleResponse := `{"access_token":"ya29.test-access-token","expires_in":3600,"token_type":"Bearer"}` + resp := &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(googleResponse)), + Header: make(http.Header), + } + + err = respProc(resp) + assert.NoError(t, err) + + // Read the modified response body + respBody, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + + var sealedResp SealedTokenResponse + err = json.Unmarshal(respBody, &sealedResp) + assert.NoError(t, err) + + assert.Equal(t, "sealed", sealedResp.TokenType) + assert.True(t, sealedResp.ExpiresIn > 0 && sealedResp.ExpiresIn <= 3600) + assert.NotEqual(t, "", sealedResp.SealedToken) + + // Unseal the token and verify it's a valid InjectProcessorConfig + secret, err := unsealSecret(sealedResp.SealedToken, pub, priv) + assert.NoError(t, err) + + injector, ok := secret.ProcessorConfig.(*InjectProcessorConfig) + assert.True(t, ok) + assert.Equal(t, "ya29.test-access-token", injector.Token) + + // Verify auth config was forwarded + _ = sealKey // used for sealing + secretJSON, err := json.Marshal(secret) + assert.NoError(t, err) + assert.Contains(t, string(secretJSON), "bearer_auth") + + // Verify allowed hosts were forwarded + assert.Contains(t, string(secretJSON), "admin.googleapis.com") + assert.Contains(t, string(secretJSON), "oauth2.googleapis.com") +} + +func TestJWTProcessorConfig_ResponseProcessor_NonOKStatus(t *testing.T) { + _, pemBytes := generateTestRSAKey(t) + _, pub, _ := generateTestSealKey(t) + + cfg := &JWTProcessorConfig{ + PrivateKey: pemBytes, + Email: "test-sa@my-project.iam.gserviceaccount.com", + Scopes: "https://www.googleapis.com/auth/admin.directory.user", + TokenURL: "https://oauth2.googleapis.com/token", + } + + ctx := &SealingContext{ + AuthConfig: NewBearerAuthConfig("trustno1"), + RequestValidators: nil, + SealKey: pub, + } + + respProc, err := cfg.ResponseProcessor(nil, ctx) + assert.NoError(t, err) + + // Simulate an error response from Google + resp := &http.Response{ + StatusCode: 400, + Body: io.NopCloser(strings.NewReader(`{"error":"invalid_grant"}`)), + Header: make(http.Header), + } + + // Should pass through error responses without modification + err = respProc(resp) + assert.NoError(t, err) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.Contains(t, string(body), "invalid_grant") +} + +func TestJWTProcessorConfig_ResponseProcessor_NilContext(t *testing.T) { + _, pemBytes := generateTestRSAKey(t) + + cfg := &JWTProcessorConfig{ + PrivateKey: pemBytes, + Email: "test-sa@my-project.iam.gserviceaccount.com", + Scopes: "https://www.googleapis.com/auth/admin.directory.user", + TokenURL: "https://oauth2.googleapis.com/token", + } + + // Without a SealingContext, ResponseProcessor should return an error + _, err := cfg.ResponseProcessor(nil, nil) + assert.Error(t, err) +} + +func TestJWTProcessorConfig_JSONRoundTrip(t *testing.T) { + _, pemBytes := generateTestRSAKey(t) + + original := &Secret{ + AuthConfig: NewBearerAuthConfig("trustno1"), + ProcessorConfig: &JWTProcessorConfig{ + PrivateKey: pemBytes, + Email: "test-sa@my-project.iam.gserviceaccount.com", + Subject: "admin@example.com", + Scopes: "https://www.googleapis.com/auth/admin.directory.user", + TokenURL: "https://oauth2.googleapis.com/token", + }, + RequestValidators: []RequestValidator{AllowHosts("oauth2.googleapis.com", "admin.googleapis.com")}, + } + + data, err := json.Marshal(original) + assert.NoError(t, err) + + // Verify the JSON has the right key + assert.Contains(t, string(data), "jwt_processor") + + var restored Secret + err = json.Unmarshal(data, &restored) + assert.NoError(t, err) + + jwtCfg, ok := restored.ProcessorConfig.(*JWTProcessorConfig) + assert.True(t, ok) + assert.Equal(t, pemBytes, jwtCfg.PrivateKey) + assert.Equal(t, "test-sa@my-project.iam.gserviceaccount.com", jwtCfg.Email) + assert.Equal(t, "admin@example.com", jwtCfg.Subject) + assert.Equal(t, "https://www.googleapis.com/auth/admin.directory.user", jwtCfg.Scopes) + assert.Equal(t, "https://oauth2.googleapis.com/token", jwtCfg.TokenURL) +} + +// unsealSecret is a test helper that decrypts a sealed secret. +func unsealSecret(sealed string, pub *[32]byte, priv *[32]byte) (*Secret, error) { + ctSecret, err := base64.StdEncoding.DecodeString(sealed) + if err != nil { + return nil, err + } + + jsonSecret, ok := box.OpenAnonymous(nil, ctSecret, pub, priv) + if !ok { + return nil, fmt.Errorf("failed to unseal") + } + + secret := new(Secret) + if err := json.Unmarshal(jsonSecret, secret); err != nil { + return nil, err + } + + return secret, nil +} + func TestSigv4Processor(t *testing.T) { parseAuthHeader := func(s string) (string, string, string, string) { // AWS4-HMAC-SHA256 Credential=AccessKey/20260304/service/region/aws4_request, SignedHeaders=host;x-amz-date, Signature=674b77f7c09adf9becb2eb1c70183bb4e828330063b8b1577dfc64001074ea3d @@ -424,7 +869,7 @@ func TestSigv4Processor(t *testing.T) { // The processor will swap region/service when NoSwap is false for bug compat. cfgSwap := &Sigv4ProcessorConfig{"AccessKey", "SecretKey", false} - processSwap, err := cfgSwap.Processor(nil) + processSwap, err := cfgSwap.Processor(nil, nil) assert.NoError(t, err) req := r.Clone(context.Background()) @@ -440,7 +885,7 @@ func TestSigv4Processor(t *testing.T) { // The processor will not swap region/service when NoSwap is true. cfg := &Sigv4ProcessorConfig{"AccessKey", "SecretKey", true} - process, err := cfg.Processor(nil) + process, err := cfg.Processor(nil, nil) assert.NoError(t, err) req = r.Clone(context.Background()) diff --git a/tokenizer.go b/tokenizer.go index 5a863ee..86a680a 100644 --- a/tokenizer.go +++ b/tokenizer.go @@ -191,6 +191,11 @@ type proxyUserData struct { // tunneled connection. connectProcessors []RequestProcessor + // responseProcessors are invoked in HandleResponse to transform the + // upstream response before returning it to the caller. Currently only + // JWTProcessorConfig uses this to seal the access token. + responseProcessors []func(*http.Response) error + // start time of the CONNECT request if this is a tunneled connection. connectStart time.Time connLog logrus.FieldLogger @@ -223,9 +228,9 @@ func (t *tokenizer) HandleConnect(host string, ctx *goproxy.ProxyCtx) (*goproxy. return goproxy.RejectConnect, "" } - connectProcessors, safeSecret, err := t.processorsFromRequest(ctx.Req) - if safeSecret != "" { - pud.connLog = pud.connLog.WithField("secret", safeSecret) + connResult, err := t.processorsFromRequest(ctx.Req) + if connResult.safeSecret != "" { + pud.connLog = pud.connLog.WithField("secret", connResult.safeSecret) } if err != nil { pud.connLog.WithError(err).Warn("find processor (CONNECT)") @@ -233,7 +238,7 @@ func (t *tokenizer) HandleConnect(host string, ctx *goproxy.ProxyCtx) (*goproxy. return goproxy.RejectConnect, "" } - pud.connectProcessors = connectProcessors + pud.connectProcessors = connResult.request ctx.UserData = pud return goproxy.HTTPMitmConnect, host @@ -296,15 +301,16 @@ func (t *tokenizer) HandleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*ht } processors := append([]RequestProcessor(nil), pud.connectProcessors...) - reqProcessors, safeSecret, err := t.processorsFromRequest(req) - if safeSecret != "" { - pud.reqLog = pud.reqLog.WithField("secret", safeSecret) + reqResult, err := t.processorsFromRequest(req) + if reqResult.safeSecret != "" { + pud.reqLog = pud.reqLog.WithField("secret", reqResult.safeSecret) } if err != nil { pud.reqLog.WithError(err).Warn("find processor") return req, errorResponse(err) } else { - processors = append(processors, reqProcessors...) + processors = append(processors, reqResult.request...) + pud.responseProcessors = append(pud.responseProcessors, reqResult.response...) } if len(processors) == 0 && !t.OpenProxy { @@ -353,11 +359,20 @@ func (t *tokenizer) HandleResponse(resp *http.Response, ctx *goproxy.ProxyCtx) * if resp != nil { log = log.WithField("status", resp.StatusCode) resp.Header.Set("Connection", "close") + + // Run response processors (e.g. JWT token sealing) + for _, rp := range pud.responseProcessors { + if err := rp(resp); err != nil { + log.WithError(err).Warn("run response processor") + return errorResponse(err) + } + } } // reset pud for next request in tunnel pud.requestStart = time.Time{} pud.reqLog = nil + pud.responseProcessors = nil if ctx.Error != nil { log.WithError(ctx.Error).Warn() @@ -369,51 +384,75 @@ func (t *tokenizer) HandleResponse(resp *http.Response, ctx *goproxy.ProxyCtx) * return resp } -func (t *tokenizer) processorsFromRequest(req *http.Request) ([]RequestProcessor, string, error) { +type processorsResult struct { + request []RequestProcessor + response []func(*http.Response) error + safeSecret string +} + +func (t *tokenizer) processorsFromRequest(req *http.Request) (*processorsResult, error) { hdrs := req.Header[headerProxyTokenizer] - processors := make([]RequestProcessor, 0, len(hdrs)) + result := &processorsResult{ + request: make([]RequestProcessor, 0, len(hdrs)), + } - var safeSecret string for _, hdr := range hdrs { b64Secret, params, err := parseHeaderProxyTokenizer(hdr) if err != nil { - return nil, safeSecret, err + return result, err } ctSecret, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64Secret)) if err != nil { - return nil, safeSecret, fmt.Errorf("bad Proxy-Tokenizer encoding: %w", err) + return result, fmt.Errorf("bad Proxy-Tokenizer encoding: %w", err) } jsonSecret, ok := box.OpenAnonymous(nil, ctSecret, t.pub, t.priv) if !ok { - return nil, safeSecret, errors.New("failed Proxy-Tokenizer decryption") + return result, errors.New("failed Proxy-Tokenizer decryption") } secret := new(Secret) if err = json.Unmarshal(jsonSecret, secret); err != nil { - return nil, safeSecret, fmt.Errorf("bad secret json: %w", err) + return result, fmt.Errorf("bad secret json: %w", err) } - safeSecret = secret.StripHazmatString() + result.safeSecret = secret.StripHazmatString() if err = secret.AuthRequest(t, req); err != nil { - return nil, safeSecret, fmt.Errorf("unauthorized to use secret: %w", err) + return result, fmt.Errorf("unauthorized to use secret: %w", err) } for _, v := range secret.RequestValidators { if err := v.Validate(req); err != nil { - return nil, safeSecret, fmt.Errorf("request validator failed: %w", err) + return result, fmt.Errorf("request validator failed: %w", err) } } - processor, err := secret.Processor(params) + sealCtx := &SealingContext{ + AuthConfig: secret.AuthConfig, + RequestValidators: secret.RequestValidators, + SealKey: t.pub, + } + + processor, err := secret.Processor(params, sealCtx) if err != nil { - return nil, safeSecret, err + return result, err + } + result.request = append(result.request, processor) + + // Check if the processor also handles responses + if rpc, ok := secret.ProcessorConfig.(ResponseProcessorConfig); ok { + respProc, err := rpc.ResponseProcessor(params, sealCtx) + if err != nil { + return result, err + } + if respProc != nil { + result.response = append(result.response, respProc) + } } - processors = append(processors, processor) } - return processors, safeSecret, nil + return result, nil } func parseHeaderProxyTokenizer(hdr string) (string, map[string]string, error) { diff --git a/tokenizer_test.go b/tokenizer_test.go index 39507e8..659a352 100644 --- a/tokenizer_test.go +++ b/tokenizer_test.go @@ -6,9 +6,12 @@ import ( "crypto/ed25519" "crypto/hmac" "crypto/rand" + "crypto/rsa" "crypto/sha256" + "crypto/x509" "encoding/hex" "encoding/json" + "encoding/pem" "fmt" "io" "net" @@ -16,6 +19,7 @@ import ( "net/http/httptest" "net/url" "regexp" + "strings" "sync" "testing" "time" @@ -458,6 +462,94 @@ func TestTokenizer(t *testing.T) { assert.Equal(t, http.StatusProxyAuthRequired, resp.StatusCode) }) + t.Run("jwt processor", func(t *testing.T) { + // Mock Google token endpoint that returns an access token + tokenEndpoint := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "bad body", 500) + return + } + bodyStr := string(body) + if !strings.Contains(bodyStr, "grant_type=") || !strings.Contains(bodyStr, "assertion=") { + http.Error(w, "missing grant_type or assertion", 400) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "ya29.test-access-token-from-google", + "expires_in": 3600, + "token_type": "Bearer", + }) + })) + defer tokenEndpoint.Close() + + // Trust the mock token endpoint's certificate + UpstreamTrust.AddCert(tokenEndpoint.Certificate()) + + tokenEndpointURL, err := url.Parse(tokenEndpoint.URL) + assert.NoError(t, err) + tokenEndpointURL.Scheme = "http" // tokenizer upgrades to TLS + tokenHost := tokenEndpointURL.Host + + // Generate RSA key for JWT signing + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NoError(t, err) + pemBytes := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(rsaKey), + }) + + auth := "jwt-auth-secret" + jwtCfg := &JWTProcessorConfig{ + PrivateKey: pemBytes, + Email: "test-sa@my-project.iam.gserviceaccount.com", + Scopes: "https://www.googleapis.com/auth/admin.directory.user", + TokenURL: tokenEndpointURL.String(), + } + + // Seal with allowed_hosts covering both token endpoint and API + secret, err := (&Secret{ + AuthConfig: NewBearerAuthConfig(auth), + ProcessorConfig: jwtCfg, + RequestValidators: []RequestValidator{AllowHosts(tokenHost, appHost)}, + }).Seal(sealKey) + assert.NoError(t, err) + + // Step 1: Exchange - send to token endpoint through tokenizer + client, err = Client(tkzServer.URL, WithAuth(auth), WithSecret(secret, nil)) + assert.NoError(t, err) + + tokenReq, err := http.NewRequest(http.MethodPost, tokenEndpointURL.String(), nil) + assert.NoError(t, err) + + resp, err = client.Do(tokenReq) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Parse the sealed token response + var sealedResp SealedTokenResponse + assert.NoError(t, json.NewDecoder(resp.Body).Decode(&sealedResp)) + assert.Equal(t, "sealed", sealedResp.TokenType) + assert.True(t, sealedResp.ExpiresIn > 0) + assert.NotEqual(t, "", sealedResp.SealedToken) + + // Step 2: Use the sealed access token to hit the app API + client, err = Client(tkzServer.URL, WithAuth(auth), WithSecret(sealedResp.SealedToken, nil)) + assert.NoError(t, err) + assert.Equal(t, &echoResponse{ + Headers: http.Header{"Authorization": {"Bearer ya29.test-access-token-from-google"}}, + Body: "", + }, doEcho(t, client, req)) + + // Step 2b: Sealed token should fail without correct auth + client, err = Client(tkzServer.URL, WithAuth("wrong-password"), WithSecret(sealedResp.SealedToken, nil)) + assert.NoError(t, err) + resp, err = client.Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusProxyAuthRequired, resp.StatusCode) + }) + t.Run("MultiProcessor", func(t *testing.T) { auth := "trustno1" token1 := "supersecret" From add11c2d08014374634528de30bad6a5463211e4 Mon Sep 17 00:00:00 2001 From: Matt Braun Date: Fri, 20 Mar 2026 13:54:21 -0500 Subject: [PATCH 3/4] Document jwt_processor and SigV4 no_swap in README Adds documentation for: - jwt_processor: two-step sealed token exchange flow for Google SA auth - sigv4_processor: documents the no_swap bug-compatibility field --- README.md | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/README.md b/README.md index e547ad9..ba4554f 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,88 @@ secret = { } ``` +### SigV4 processor + +The `sigv4_processor` re-signs AWS requests with SigV4 credentials. It parses the existing `Authorization` header to extract the service, region, and date, then re-signs the request with the sealed AWS credentials. + +```ruby +secret = { + sigv4_processor: { + access_key: "AKIAIOSFODNN7EXAMPLE", + secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + no_swap: true + }, + bearer_auth: { + digest: Digest::SHA256.base64digest('trustno1') + } +} +``` + +Note: the `no_swap` field controls a bug-compatibility mode. When `false` (the default for backward compatibility), the region and service values extracted from the credential scope are swapped before re-signing. Set `no_swap: true` for correct behavior with new secrets. + +### JWT processor + +The `jwt_processor` handles Google Cloud service account authentication (and other OAuth2 JWT-bearer flows per [RFC 7523](https://tools.ietf.org/html/rfc7523)). It signs a JWT with the sealed RSA private key and exchanges it for a short-lived access token at the token endpoint. + +This processor is unique in two ways: +1. **It transforms both the request and the response.** On the request side, it builds the JWT and constructs the token exchange POST body. On the response side, it intercepts the token endpoint's response, extracts the access token, seals it into a new `inject_processor` secret, and replaces the response body. +2. **The caller never sees any plaintext credential.** The private key, the signed JWT, and the access token are all plaintext only inside tokenizer's process memory. The caller receives an opaque sealed blob. + +```ruby +secret = { + jwt_processor: { + private_key: File.read("service-account-key.pem"), + email: "my-sa@my-project.iam.gserviceaccount.com", + scopes: "https://www.googleapis.com/auth/admin.directory.user", + token_url: "https://oauth2.googleapis.com/token", # optional, this is the default + sub: "admin@example.com" # optional, for domain-wide delegation + }, + bearer_auth: { + digest: Digest::SHA256.base64digest('trustno1') + }, + allowed_hosts: ["oauth2.googleapis.com", "admin.googleapis.com"] +} +``` + +**Usage is a two-step flow:** + +Step 1 - Exchange the sealed SA key for a sealed access token: + +```ruby +# Send to the token endpoint through tokenizer +resp = conn.post("http://oauth2.googleapis.com/token") +sealed_access_token = JSON.parse(resp.body)["sealed_token"] +expires_in = JSON.parse(resp.body)["expires_in"] +``` + +The response body is replaced with: +```json +{"sealed_token": "", "expires_in": 3540, "token_type": "sealed"} +``` + +Step 2 - Use the sealed access token for API calls: + +```ruby +conn2 = Faraday.new( + proxy: "http://tokenizer.flycast", + headers: { + proxy_tokenizer: "#{sealed_access_token}", + proxy_authorization: "Bearer trustno1" + } +) + +conn2.get("http://admin.googleapis.com/admin/directory/v1/users") +``` + +The sealed access token is a normal `inject_processor` secret - tokenizer unseals it and injects the `Authorization: Bearer` header. When the token expires (typically after ~1 hour), repeat Step 1. + +The `sub` and `scopes` fields can be overridden at request time via parameters, allowing different requests through the same sealed credential to impersonate different users or request different scopes: + +```ruby +processor_params = { sub: "other-admin@example.com", scopes: "https://www.googleapis.com/auth/gmail.readonly" } +conn.headers[:proxy_tokenizer] = "#{Base64.encode64(sealed_secret)}; #{processor_params.to_json}" +``` + ## Request-time parameters If the destination/formatting might vary between requests, `inject_processor` and `inject_hmac_processor` can specify an allowlist of `dst`/`fmt` parameters that the client can specify at request time. These parameters are supplied as JSON in the `Proxy-Tokenizer` header after the encrypted secret. From 5d19c6869d96c3f0c31adf0894519b769bdd0c79 Mon Sep 17 00:00:00 2001 From: Matt Braun Date: Fri, 20 Mar 2026 13:54:29 -0500 Subject: [PATCH 4/4] Add unsealtoken CLI tool for debugging sealed secrets --- cmd/unsealtoken/main.go | 82 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 cmd/unsealtoken/main.go diff --git a/cmd/unsealtoken/main.go b/cmd/unsealtoken/main.go new file mode 100644 index 0000000..ca9e50a --- /dev/null +++ b/cmd/unsealtoken/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "encoding/base64" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + + "golang.org/x/crypto/curve25519" + "golang.org/x/crypto/nacl/box" + + "github.com/superfly/tokenizer" +) + +func unseal(sealedToken, openKey string) error { + // Decode the private key from hex + privBytes, err := hex.DecodeString(openKey) + if err != nil { + return fmt.Errorf("invalid open key: %w", err) + } + if len(privBytes) != 32 { + return fmt.Errorf("open key must be 32 bytes, got %d", len(privBytes)) + } + priv := (*[32]byte)(privBytes) + + // Derive public key from private key + pub := new([32]byte) + curve25519.ScalarBaseMult(pub, priv) + + // Decode the sealed token from base64 + ctSecret, err := base64.StdEncoding.DecodeString(sealedToken) + if err != nil { + return fmt.Errorf("invalid base64 encoding: %w", err) + } + + // Unseal the token + jsonSecret, ok := box.OpenAnonymous(nil, ctSecret, pub, priv) + if !ok { + return fmt.Errorf("failed to unseal token - invalid key or corrupted data") + } + + // Parse the secret JSON + var secret tokenizer.Secret + if err := json.Unmarshal(jsonSecret, &secret); err != nil { + return fmt.Errorf("invalid secret JSON: %w", err) + } + + // Pretty print the secret + prettyJSON, err := json.MarshalIndent(secret, "", " ") + if err != nil { + return fmt.Errorf("failed to format JSON: %w", err) + } + + fmt.Println(string(prettyJSON)) + return nil +} + +func main() { + openKey := flag.String("openkey", os.Getenv("OPEN_KEY"), "Open key (private key) in hex, or from OPEN_KEY env var") + flag.Parse() + + args := flag.Args() + if len(args) != 1 { + fmt.Fprintf(os.Stderr, "Usage: %s [flags] \n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Flags:\n") + flag.PrintDefaults() + os.Exit(1) + } + + if *openKey == "" { + fmt.Fprintf(os.Stderr, "Error: open key not specified (use -openkey flag or OPEN_KEY env var)\n") + os.Exit(1) + } + + sealedToken := args[0] + if err := unseal(sealedToken, *openKey); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +}