Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ SESSION_SECRET=session-secret-change-in-production
# JWT Signing Algorithm
# Options: HS256 (default, symmetric), RS256 (RSA), ES256 (ECDSA P-256)
# JWT_SIGNING_ALGORITHM=HS256
# JWT_PRIVATE_KEY_PATH= # Required for RS256/ES256: path to PEM private key
# For RS256/ES256 supply the private key via at least one of the following:
# JWT_PRIVATE_KEY_PATH= # Path to PEM private key file
# JWT_PRIVATE_KEY_PEM= # Inline PEM content (for K8s Secrets / GitHub Actions).
# # Takes precedence over JWT_PRIVATE_KEY_PATH when both are set.
# JWT_KEY_ID= # Optional: kid header value (auto-generated from key fingerprint)

# JWT Token Expiration
Expand Down
74 changes: 71 additions & 3 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,18 +290,30 @@ AuthGate supports three JWT signing algorithms:

### Configuration

For RS256/ES256 you must supply the private key via **at least one** of two environment variables:

| Variable | Use when |
| ---------------------- | ------------------------------------------------------------------ |
| `JWT_PRIVATE_KEY_PATH` | Key is available as a file on disk (bare-metal, Docker volume) |
| `JWT_PRIVATE_KEY_PEM` | Key is injected as a string (Kubernetes Secret, GitHub Actions, Fly.io, Cloud Run) |

When both are set, `JWT_PRIVATE_KEY_PEM` wins and AuthGate logs a warning on startup.

```bash
# HS256 (default — no additional config needed)
JWT_SIGNING_ALGORITHM=HS256

# RS256
# RS256 — load key from disk
JWT_SIGNING_ALGORITHM=RS256
JWT_PRIVATE_KEY_PATH=/path/to/rsa-private.pem
JWT_KEY_ID= # Optional: auto-generated from key fingerprint

# ES256
# ES256 — load key from inline PEM (env var holds the full PEM content incl. newlines)
JWT_SIGNING_ALGORITHM=ES256
JWT_PRIVATE_KEY_PATH=/path/to/ec-private.pem
JWT_PRIVATE_KEY_PEM="-----BEGIN EC PRIVATE KEY-----
MHcCAQEEI...<base64 body>...
-----END EC PRIVATE KEY-----
"
JWT_KEY_ID= # Optional: auto-generated from key fingerprint
```

Expand All @@ -315,6 +327,62 @@ openssl genrsa -out rsa-private.pem 2048
openssl ecparam -genkey -name prime256v1 -noout -out ec-private.pem
```

### Loading Keys in Containerized Deployments

`JWT_PRIVATE_KEY_PEM` lets you pass the full PEM string through environment variables,
which is the native secret-delivery mechanism on most container platforms. Both
GitHub Actions Secrets and Kubernetes Secrets preserve newlines, so no base64 encoding
is required.

**GitHub Actions**

Store the PEM in a repository secret (e.g. `JWT_SIGNING_KEY`) — GitHub's secret editor
preserves multi-line input as-is. Then inject it at runtime:

```yaml
- name: Run AuthGate
env:
JWT_SIGNING_ALGORITHM: RS256
JWT_PRIVATE_KEY_PEM: ${{ secrets.JWT_SIGNING_KEY }}
run: ./bin/authgate server
```

**Kubernetes**

Store the PEM in a `Secret` and expose it via `env.valueFrom.secretKeyRef`:

```yaml
apiVersion: v1
kind: Secret
metadata:
name: authgate-jwt
type: Opaque
stringData:
private-key.pem: |
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEI...
-----END EC PRIVATE KEY-----
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: authgate
spec:
template:
spec:
containers:
- name: authgate
image: authgate:latest
env:
- name: JWT_SIGNING_ALGORITHM
value: ES256
- name: JWT_PRIVATE_KEY_PEM
valueFrom:
secretKeyRef:
name: authgate-jwt
key: private-key.pem
```

### JWKS Endpoint

When using RS256 or ES256, AuthGate exposes the public key at:
Expand Down
26 changes: 22 additions & 4 deletions internal/bootstrap/providers.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package bootstrap

import (
"crypto"
"log"

"github.com/go-authgate/authgate/internal/auth"
Expand Down Expand Up @@ -52,10 +53,27 @@ func initializeTokenProvider(cfg *config.Config) *token.LocalTokenProvider {
log.Fatalf("Unsupported JWT_SIGNING_ALGORITHM: %q", cfg.JWTSigningAlgorithm)
}

// Load asymmetric key
privateKey, err := token.LoadSigningKey(cfg.JWTPrivateKeyPath)
if err != nil {
log.Fatalf("Failed to load JWT private key from %s: %v", cfg.JWTPrivateKeyPath, err)
// Prefer inline PEM over file path so containerized deployments (K8s Secrets,
// GitHub Actions) can override an image-baked default without rewriting the file.
var (
privateKey crypto.Signer
err error
)
if cfg.JWTPrivateKeyPEM != "" {
if cfg.JWTPrivateKeyPath != "" {
log.Printf(
"Warning: both JWT_PRIVATE_KEY_PEM and JWT_PRIVATE_KEY_PATH are set; using PEM",
)
}
privateKey, err = token.ParseSigningKey([]byte(cfg.JWTPrivateKeyPEM))
if err != nil {
log.Fatalf("Failed to parse JWT_PRIVATE_KEY_PEM: %v", err)
}
} else {
privateKey, err = token.LoadSigningKey(cfg.JWTPrivateKeyPath)
if err != nil {
log.Fatalf("Failed to load JWT private key from %s: %v", cfg.JWTPrivateKeyPath, err)
}
}

// Derive kid if not explicitly set
Expand Down
8 changes: 5 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ type Config struct {
JWTSecret string
JWTExpiration time.Duration
JWTSigningAlgorithm string // "HS256" (default), "RS256", or "ES256"
JWTPrivateKeyPath string // PEM file path (required for RS256/ES256)
JWTPrivateKeyPath string // PEM file path (required for RS256/ES256 when PEM content is not set)
JWTPrivateKeyPEM string // PEM content (alternative to JWTPrivateKeyPath; takes precedence if both are set)
JWTKeyID string // "kid" header for JWKS key rotation (auto-generated if empty)
JWTExpirationJitter time.Duration // Max random jitter added to access token expiry (default: 30m)

Expand Down Expand Up @@ -308,6 +309,7 @@ func Load() *Config {
JWTExpiration: jwtExpiration,
JWTSigningAlgorithm: getEnv("JWT_SIGNING_ALGORITHM", AlgHS256),
JWTPrivateKeyPath: getEnv("JWT_PRIVATE_KEY_PATH", ""),
JWTPrivateKeyPEM: getEnv("JWT_PRIVATE_KEY_PEM", ""),
JWTKeyID: getEnv("JWT_KEY_ID", ""),
JWTExpirationJitter: getEnvDuration("JWT_EXPIRATION_JITTER", 30*time.Minute),
SessionSecret: getEnv("SESSION_SECRET", "session-secret-change-in-production"),
Expand Down Expand Up @@ -604,9 +606,9 @@ func (c *Config) Validate() error {
case "", AlgHS256:
// default, no key file required
case AlgRS256, AlgES256:
if c.JWTPrivateKeyPath == "" {
if c.JWTPrivateKeyPath == "" && c.JWTPrivateKeyPEM == "" {
return fmt.Errorf(
"JWT_PRIVATE_KEY_PATH is required when JWT_SIGNING_ALGORITHM=%s",
"JWT_PRIVATE_KEY_PATH or JWT_PRIVATE_KEY_PEM is required when JWT_SIGNING_ALGORITHM=%s",
c.JWTSigningAlgorithm,
)
}
Expand Down
29 changes: 25 additions & 4 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ func TestConfig_Validate_JWTSigningAlgorithm(t *testing.T) {
name string
algorithm string
keyPath string
keyPEM string
expectError bool
errorMsg string
}{
Expand All @@ -115,10 +116,10 @@ func TestConfig_Validate_JWTSigningAlgorithm(t *testing.T) {
expectError: false,
},
{
name: "RS256 requires key path",
name: "RS256 requires key path or PEM",
algorithm: "RS256",
expectError: true,
errorMsg: "JWT_PRIVATE_KEY_PATH is required",
errorMsg: "JWT_PRIVATE_KEY_PATH or JWT_PRIVATE_KEY_PEM is required",
},
{
name: "RS256 with key path OK",
Expand All @@ -127,17 +128,36 @@ func TestConfig_Validate_JWTSigningAlgorithm(t *testing.T) {
expectError: false,
},
{
name: "ES256 requires key path",
name: "RS256 with inline PEM OK",
algorithm: "RS256",
keyPEM: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n",
expectError: false,
},
{
name: "RS256 with both path and PEM OK (PEM takes precedence at runtime)",
algorithm: "RS256",
keyPath: "/some/key.pem",
keyPEM: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n",
expectError: false,
},
{
name: "ES256 requires key path or PEM",
algorithm: "ES256",
expectError: true,
errorMsg: "JWT_PRIVATE_KEY_PATH is required",
errorMsg: "JWT_PRIVATE_KEY_PATH or JWT_PRIVATE_KEY_PEM is required",
},
{
name: "ES256 with key path OK",
algorithm: "ES256",
keyPath: "/some/key.pem",
expectError: false,
},
{
name: "ES256 with inline PEM OK",
algorithm: "ES256",
keyPEM: "-----BEGIN EC PRIVATE KEY-----\nabc\n-----END EC PRIVATE KEY-----\n",
expectError: false,
},
{
name: "unsupported algorithm",
algorithm: "PS256",
Expand All @@ -151,6 +171,7 @@ func TestConfig_Validate_JWTSigningAlgorithm(t *testing.T) {
cfg := validBaseConfig()
cfg.JWTSigningAlgorithm = tt.algorithm
cfg.JWTPrivateKeyPath = tt.keyPath
cfg.JWTPrivateKeyPEM = tt.keyPEM
err := cfg.Validate()
if tt.expectError {
require.Error(t, err)
Expand Down
30 changes: 20 additions & 10 deletions internal/token/key.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,15 @@ import (
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"os"
)

// LoadSigningKey reads a PEM file and returns the parsed private key.
// ParseSigningKey parses PEM-encoded data into a supported private key.
// Supports RSA (PKCS#1 / PKCS#8) and ECDSA (SEC1 / PKCS#8).
// All PEM blocks in the file are tried in order until a supported key is found.
func LoadSigningKey(path string) (crypto.Signer, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read key file: %w", err)
}

// All PEM blocks are tried in order until a supported key is found.
func ParseSigningKey(data []byte) (crypto.Signer, error) {
rest := data
foundBlocks := false
for {
Expand Down Expand Up @@ -56,9 +52,23 @@ func LoadSigningKey(path string) (crypto.Signer, error) {
}

if !foundBlocks {
return nil, fmt.Errorf("no PEM block found in %s", path)
return nil, errors.New("no PEM block found in key data")
}
return nil, errors.New("no supported private key found in key data")
}

// LoadSigningKey reads a PEM file and returns the parsed private key.
// Supports RSA (PKCS#1 / PKCS#8) and ECDSA (SEC1 / PKCS#8).
func LoadSigningKey(path string) (crypto.Signer, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read key file: %w", err)
}
key, err := ParseSigningKey(data)
if err != nil {
return nil, fmt.Errorf("parse key file %s: %w", path, err)
}
return nil, fmt.Errorf("no supported private key found in %s", path)
return key, nil
}

// DeriveKeyID computes a deterministic kid from the SHA-256 hash of the
Expand Down
92 changes: 92 additions & 0 deletions internal/token/key_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,98 @@ func TestLoadSigningKey_MultiBlock_ECAfterUnknown(t *testing.T) {
assert.True(t, ok, "expected *ecdsa.PrivateKey from multi-block PEM")
}

func TestParseSigningKey_RSA_PKCS1(t *testing.T) {
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
pemBytes := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(rsaKey),
})

key, err := ParseSigningKey(pemBytes)
require.NoError(t, err)
_, ok := key.(*rsa.PrivateKey)
assert.True(t, ok, "expected *rsa.PrivateKey, got %T", key)
}

func TestParseSigningKey_RSA_PKCS8(t *testing.T) {
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
der, err := x509.MarshalPKCS8PrivateKey(rsaKey)
require.NoError(t, err)
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})

key, err := ParseSigningKey(pemBytes)
require.NoError(t, err)
_, ok := key.(*rsa.PrivateKey)
assert.True(t, ok, "expected *rsa.PrivateKey, got %T", key)
}

func TestParseSigningKey_ECDSA_SEC1(t *testing.T) {
ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
der, err := x509.MarshalECPrivateKey(ecKey)
require.NoError(t, err)
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})

key, err := ParseSigningKey(pemBytes)
require.NoError(t, err)
loaded, ok := key.(*ecdsa.PrivateKey)
assert.True(t, ok, "expected *ecdsa.PrivateKey, got %T", key)
assert.Equal(t, elliptic.P256(), loaded.Curve)
}

func TestParseSigningKey_ECDSA_PKCS8(t *testing.T) {
ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
der, err := x509.MarshalPKCS8PrivateKey(ecKey)
require.NoError(t, err)
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})

key, err := ParseSigningKey(pemBytes)
require.NoError(t, err)
_, ok := key.(*ecdsa.PrivateKey)
assert.True(t, ok, "expected *ecdsa.PrivateKey, got %T", key)
}

func TestParseSigningKey_EmptyInput(t *testing.T) {
_, err := ParseSigningKey(nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "no PEM block found")
}

func TestParseSigningKey_InvalidPEM(t *testing.T) {
_, err := ParseSigningKey([]byte("not a pem blob"))
require.Error(t, err)
assert.Contains(t, err.Error(), "no PEM block found")
}

func TestParseSigningKey_UnsupportedFormat(t *testing.T) {
pemBytes := pem.EncodeToMemory(
&pem.Block{Type: "UNKNOWN KEY", Bytes: []byte("garbage-key-data")},
)

_, err := ParseSigningKey(pemBytes)
require.Error(t, err)
assert.Contains(t, err.Error(), "no supported private key found")
}

func TestParseSigningKey_MultiBlock(t *testing.T) {
// PEM with a non-key block first (e.g. EC PARAMETERS), followed by the real EC key.
ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
ecDER, err := x509.MarshalECPrivateKey(ecKey)
require.NoError(t, err)

buf := pem.EncodeToMemory(&pem.Block{Type: "EC PARAMETERS", Bytes: []byte("params")})
buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: ecDER})...)

key, err := ParseSigningKey(buf)
require.NoError(t, err)
_, ok := key.(*ecdsa.PrivateKey)
assert.True(t, ok, "expected *ecdsa.PrivateKey from multi-block PEM")
}

func TestDeriveKeyID_RSA(t *testing.T) {
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
Expand Down
Loading