Skip to content

Commit ec9e857

Browse files
abarisainclaude
andauthored
feat: support injecting cryptographic keys via env vars (#119)
Add AUTH_HMAC_SECRET and JWT_PRIVATE_KEY environment variables to allow injecting stable signing keys from external secret stores (e.g. Kubernetes Secrets). This eliminates the need for persistent volumes just to preserve keys across pod restarts, which previously caused 401 errors when keys were regenerated. Also fixes a bug where os.MkdirAll was called after LoadOrGenerateSecret, meaning the secret file write would fail if the data directory didn't exist yet. Co-authored-by: Claude Opus 4.6 <[email protected]>
1 parent 118ced7 commit ec9e857

5 files changed

Lines changed: 174 additions & 23 deletions

File tree

docs/docs/configuration.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,23 @@ You can use both exact matching and glob patterns for OIDC user authorization:
8787
--oidc-allowed-users-glob "*@example.com"
8888
```
8989

90+
### Cryptographic Key Options
91+
92+
- **`AUTH_HMAC_SECRET`** — Base64-encoded 32-byte secret for HMAC/cookie signing. Default: auto-generated.
93+
- **`JWT_PRIVATE_KEY`** — PEM-encoded RSA private key (PKCS8) for JWT signing. Default: auto-generated.
94+
95+
When set, these environment variables take precedence over the file-based keys in `{data-path}/secret` and `{data-path}/private_key.pem`. This is useful for deployments without persistent volumes (e.g., Kubernetes) where pod restarts would otherwise regenerate keys and invalidate all existing OAuth tokens.
96+
97+
To generate suitable values:
98+
99+
```bash
100+
# Generate AUTH_HMAC_SECRET (base64-encoded 32 random bytes)
101+
openssl rand -base64 32
102+
103+
# Generate JWT_PRIVATE_KEY (RSA 2048-bit PKCS8 PEM)
104+
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048
105+
```
106+
90107
### Server Options
91108

92109
| Option | Environment Variable | Default | Description |

docs/docs/examples.md

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,17 @@ spec:
8686
secretKeyRef:
8787
name: mcp-auth-proxy-secrets
8888
key: password
89-
volumeMounts:
90-
- name: data
91-
mountPath: /data
89+
- name: AUTH_HMAC_SECRET
90+
valueFrom:
91+
secretKeyRef:
92+
name: mcp-auth-proxy-keys
93+
key: auth-hmac-secret
94+
- name: JWT_PRIVATE_KEY
95+
valueFrom:
96+
secretKeyRef:
97+
name: mcp-auth-proxy-keys
98+
key: jwt-private-key
9299
args: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "./"]
93-
volumes:
94-
- name: data
95-
persistentVolumeClaim:
96-
claimName: mcp-auth-proxy-data
97100
---
98101
apiVersion: v1
99102
kind: Service
@@ -165,9 +168,16 @@ spec:
165168
secretKeyRef:
166169
name: mcp-auth-proxy-secrets
167170
key: password
168-
volumeMounts:
169-
- name: data
170-
mountPath: /data
171+
- name: AUTH_HMAC_SECRET
172+
valueFrom:
173+
secretKeyRef:
174+
name: mcp-auth-proxy-keys
175+
key: auth-hmac-secret
176+
- name: JWT_PRIVATE_KEY
177+
valueFrom:
178+
secretKeyRef:
179+
name: mcp-auth-proxy-keys
180+
key: jwt-private-key
171181

172182
# Your MCP server
173183
- name: mcp-server
@@ -176,8 +186,4 @@ spec:
176186
- containerPort: 8000
177187
# Your MCP server configuration here
178188
# ...
179-
180-
volumes:
181-
- name: data
182-
emptyDir: {}
183189
```

pkg/mcp-proxy/main.go

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package mcpproxy
22

33
import (
44
"context"
5+
"crypto/rsa"
56
"crypto/tls"
67
"errors"
78
"fmt"
@@ -99,9 +100,21 @@ func Run(
99100
return fmt.Errorf("tlsHost requires automatic TLS; remove noAutoTLS or provide certificate files instead")
100101
}
101102

102-
secret, err := utils.LoadOrGenerateSecret(path.Join(dataPath, "secret"))
103-
if err != nil {
104-
return fmt.Errorf("failed to load or generate secret: %w", err)
103+
if err := os.MkdirAll(dataPath, os.ModePerm); err != nil {
104+
return fmt.Errorf("failed to create data directory: %w", err)
105+
}
106+
107+
var secret []byte
108+
if envSecret := os.Getenv("AUTH_HMAC_SECRET"); envSecret != "" {
109+
secret, err = utils.SecretFromBase64(envSecret)
110+
if err != nil {
111+
return fmt.Errorf("failed to parse AUTH_HMAC_SECRET environment variable: %w", err)
112+
}
113+
} else {
114+
secret, err = utils.LoadOrGenerateSecret(path.Join(dataPath, "secret"))
115+
if err != nil {
116+
return fmt.Errorf("failed to load or generate secret: %w", err)
117+
}
105118
}
106119

107120
var config zap.Config
@@ -116,9 +129,6 @@ func Run(
116129
if err != nil {
117130
return fmt.Errorf("failed to build logger: %w", err)
118131
}
119-
if err := os.MkdirAll(dataPath, os.ModePerm); err != nil {
120-
return fmt.Errorf("failed to create database directory: %w", err)
121-
}
122132

123133
if len(proxyTarget) == 0 {
124134
return fmt.Errorf("proxy target must be specified")
@@ -201,9 +211,17 @@ func Run(
201211
}
202212
}()
203213

204-
privKey, err := utils.LoadOrGeneratePrivateKey(path.Join(dataPath, "private_key.pem"))
205-
if err != nil {
206-
return fmt.Errorf("failed to load or generate private key: %w", err)
214+
var privKey *rsa.PrivateKey
215+
if envKey := os.Getenv("JWT_PRIVATE_KEY"); envKey != "" {
216+
privKey, err = utils.PrivateKeyFromPEM(envKey)
217+
if err != nil {
218+
return fmt.Errorf("failed to parse JWT_PRIVATE_KEY environment variable: %w", err)
219+
}
220+
} else {
221+
privKey, err = utils.LoadOrGeneratePrivateKey(path.Join(dataPath, "private_key.pem"))
222+
if err != nil {
223+
return fmt.Errorf("failed to load or generate private key: %w", err)
224+
}
207225
}
208226
var providers []auth.Provider
209227

pkg/utils/keys.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"crypto/rand"
55
"crypto/rsa"
66
"crypto/x509"
7+
"encoding/base64"
78
"encoding/pem"
89
"fmt"
910
"os"
@@ -82,3 +83,30 @@ func LoadPrivateKey(keyPath string) (*rsa.PrivateKey, error) {
8283

8384
return privateKey.(*rsa.PrivateKey), nil
8485
}
86+
87+
func SecretFromBase64(encoded string) ([]byte, error) {
88+
secret, err := base64.StdEncoding.DecodeString(encoded)
89+
if err != nil {
90+
return nil, fmt.Errorf("failed to decode base64 secret: %w", err)
91+
}
92+
if len(secret) != SecretSize {
93+
return nil, fmt.Errorf("decoded secret must be exactly %d bytes, got %d", SecretSize, len(secret))
94+
}
95+
return secret, nil
96+
}
97+
98+
func PrivateKeyFromPEM(pemStr string) (*rsa.PrivateKey, error) {
99+
block, _ := pem.Decode([]byte(pemStr))
100+
if block == nil {
101+
return nil, fmt.Errorf("failed to decode PEM block")
102+
}
103+
privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
104+
if err != nil {
105+
return nil, fmt.Errorf("failed to parse private key: %w", err)
106+
}
107+
rsaKey, ok := privateKey.(*rsa.PrivateKey)
108+
if !ok {
109+
return nil, fmt.Errorf("private key is not RSA")
110+
}
111+
return rsaKey, nil
112+
}

pkg/utils/keys_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package utils
2+
3+
import (
4+
"crypto/ecdsa"
5+
"crypto/elliptic"
6+
"crypto/rand"
7+
"crypto/rsa"
8+
"crypto/x509"
9+
"encoding/base64"
10+
"encoding/pem"
11+
"testing"
12+
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func TestSecretFromBase64_Valid(t *testing.T) {
17+
secret := make([]byte, SecretSize)
18+
_, err := rand.Read(secret)
19+
require.NoError(t, err)
20+
21+
encoded := base64.StdEncoding.EncodeToString(secret)
22+
decoded, err := SecretFromBase64(encoded)
23+
require.NoError(t, err)
24+
require.Equal(t, secret, decoded)
25+
}
26+
27+
func TestSecretFromBase64_InvalidBase64(t *testing.T) {
28+
_, err := SecretFromBase64("not-valid-base64!!!")
29+
require.Error(t, err)
30+
require.Contains(t, err.Error(), "failed to decode base64")
31+
}
32+
33+
func TestSecretFromBase64_WrongLength(t *testing.T) {
34+
short := make([]byte, 16)
35+
_, err := rand.Read(short)
36+
require.NoError(t, err)
37+
38+
encoded := base64.StdEncoding.EncodeToString(short)
39+
_, err = SecretFromBase64(encoded)
40+
require.Error(t, err)
41+
require.Contains(t, err.Error(), "must be exactly 32 bytes")
42+
}
43+
44+
func TestPrivateKeyFromPEM_Valid(t *testing.T) {
45+
key, err := rsa.GenerateKey(rand.Reader, 2048)
46+
require.NoError(t, err)
47+
48+
keyBytes, err := x509.MarshalPKCS8PrivateKey(key)
49+
require.NoError(t, err)
50+
51+
pemStr := string(pem.EncodeToMemory(&pem.Block{
52+
Type: "PRIVATE KEY",
53+
Bytes: keyBytes,
54+
}))
55+
56+
parsed, err := PrivateKeyFromPEM(pemStr)
57+
require.NoError(t, err)
58+
require.True(t, key.Equal(parsed))
59+
}
60+
61+
func TestPrivateKeyFromPEM_InvalidPEM(t *testing.T) {
62+
_, err := PrivateKeyFromPEM("not a pem")
63+
require.Error(t, err)
64+
require.Contains(t, err.Error(), "failed to decode PEM block")
65+
}
66+
67+
func TestPrivateKeyFromPEM_NonRSAKey(t *testing.T) {
68+
ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
69+
require.NoError(t, err)
70+
71+
keyBytes, err := x509.MarshalPKCS8PrivateKey(ecKey)
72+
require.NoError(t, err)
73+
74+
pemStr := string(pem.EncodeToMemory(&pem.Block{
75+
Type: "PRIVATE KEY",
76+
Bytes: keyBytes,
77+
}))
78+
79+
_, err = PrivateKeyFromPEM(pemStr)
80+
require.Error(t, err)
81+
require.Contains(t, err.Error(), "not RSA")
82+
}

0 commit comments

Comments
 (0)