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
8 changes: 8 additions & 0 deletions go-jwks-multi/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,11 @@ SKIP_AUDIENCE_CHECK=0
# multi-domain deployments where domain values are short codes like
# "oa" / "hwrd" / "swrd" with no DNS-style trust boundary.
ISSUER_DOMAINS=https://auth-a.example.com=oa,hwrd;https://auth-b.example.com=swrd,cdomain

# ─── Optional: AuthGate private-claim prefix override ────────────────
# Overrides the AuthGate server's JWT_PRIVATE_CLAIM_PREFIX (default
# "extra"), applied uniformly to every entry in TRUSTED_ISSUERS — so
# all issuers in this MultiVerifier must share the same prefix. If your
# fleet runs different prefixes per issuer, build one Verifier per
# prefix and dispatch yourself. Leave blank for the SDK default.
JWT_PRIVATE_CLAIM_PREFIX=
71 changes: 39 additions & 32 deletions go-jwks-multi/README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion go-jwks-multi/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/go-authgate/examples/go-jwks-multi
go 1.25.8

require (
github.com/go-authgate/sdk-go v0.9.0
github.com/go-authgate/sdk-go v0.10.0
github.com/go-jose/go-jose/v4 v4.1.4
github.com/joho/godotenv v1.5.1
)
Expand Down
4 changes: 2 additions & 2 deletions go-jwks-multi/go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/go-authgate/sdk-go v0.9.0 h1:VgQNjcKXtMONNiVf4coC/J69H78CkTt3CJ8maiQSf6Y=
github.com/go-authgate/sdk-go v0.9.0/go.mod h1:Afx/Dbyvf8pw4YeOqVEVdDW2WHhn534Sb2/TaFQktuU=
github.com/go-authgate/sdk-go v0.10.0 h1:MNcfV6XSPs63SWPDdLqoJ9CFiKlXIue1RmiAbTXDAEI=
github.com/go-authgate/sdk-go v0.10.0/go.mod h1:Afx/Dbyvf8pw4YeOqVEVdDW2WHhn534Sb2/TaFQktuU=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
Expand Down
26 changes: 20 additions & 6 deletions go-jwks-multi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ func main() {
expectedAudience := strings.TrimSpace(os.Getenv("EXPECTED_AUDIENCE"))
skipAudience := strings.TrimSpace(os.Getenv("SKIP_AUDIENCE_CHECK")) == "1"
rawIssuerDomains := strings.TrimSpace(os.Getenv("ISSUER_DOMAINS"))
// Optional override of the AuthGate server's JWT_PRIVATE_CLAIM_PREFIX
// (default "extra"). Applied uniformly to every configured issuer; if
// your fleet runs different prefixes per issuer you need one Verifier
// per prefix, not a single MultiVerifier.
privateClaimPrefix := strings.TrimSpace(os.Getenv("JWT_PRIVATE_CLAIM_PREFIX"))

if rawIssuers == "" {
log.Fatal("Set TRUSTED_ISSUERS to a comma-separated list of issuer URLs")
Expand All @@ -64,7 +69,7 @@ func main() {
"or SKIP_AUDIENCE_CHECK=1 to opt out")
}

mv, err := newMultiVerifier(rawIssuers, expectedAudience, skipAudience)
mv, err := newMultiVerifier(rawIssuers, expectedAudience, skipAudience, privateClaimPrefix)
if err != nil {
log.Fatalf("build verifiers: %v", err)
}
Expand Down Expand Up @@ -98,11 +103,11 @@ func main() {
MaxHeaderBytes: 8 << 10,
}

logStartup(mv, expectedAudience)
logStartup(mv, expectedAudience, privateClaimPrefix)
log.Fatal(srv.ListenAndServe())
}

func newMultiVerifier(rawIssuers, audience string, skipAudience bool) (*jwksauth.MultiVerifier, error) {
func newMultiVerifier(rawIssuers, audience string, skipAudience bool, privateClaimPrefix string) (*jwksauth.MultiVerifier, error) {
issuers, err := parseIssuers(rawIssuers)
if err != nil {
return nil, err
Expand All @@ -111,10 +116,14 @@ func newMultiVerifier(rawIssuers, audience string, skipAudience bool) (*jwksauth
// not multiply startup time by N. The SDK runs discovery concurrently.
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// WithPrivateClaimPrefix("") is documented as a no-op that leaves the
// SDK default in place, so passing the env value through unconditionally
// is safe.
opts := []jwksauth.Option{jwksauth.WithPrivateClaimPrefix(privateClaimPrefix)}
if skipAudience {
return jwksauth.NewMultiVerifierSkipAudience(ctx, issuers)
return jwksauth.NewMultiVerifierSkipAudience(ctx, issuers, opts...)
}
return jwksauth.NewMultiVerifier(ctx, issuers, audience)
return jwksauth.NewMultiVerifier(ctx, issuers, audience, opts...)
}

// parseIssuers splits a comma-separated TRUSTED_ISSUERS value into trimmed,
Expand Down Expand Up @@ -184,7 +193,7 @@ func isLoopbackHost(host string) bool {
return ip != nil && ip.IsLoopback()
}

func logStartup(mv *jwksauth.MultiVerifier, audience string) {
func logStartup(mv *jwksauth.MultiVerifier, audience, privateClaimPrefix string) {
domains := mv.IssuerDomains()
issuers := mv.Issuers()
log.Printf("Trusted issuers (%d):", len(issuers))
Expand All @@ -200,6 +209,11 @@ func logStartup(mv *jwksauth.MultiVerifier, audience string) {
} else {
log.Println("Audience: DISABLED (SKIP_AUDIENCE_CHECK=1)")
}
if privateClaimPrefix != "" {
log.Printf("Private claim prefix: %q (overrides SDK default; applied to all issuers)", privateClaimPrefix)
} else {
log.Println("Private claim prefix: \"extra\" (SDK default; applied to all issuers)")
}
log.Println("Listening on :8089 — multi-issuer offline JWKS validation")
}

Expand Down
34 changes: 24 additions & 10 deletions go-jwks-multi/testissuer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,33 @@ go run .

## `/sign` query parameters

| Param | Default | Notes |
| ----------- | ----------------------------- | ------------------------------------------------------ |
| `aud` | `https://api.example.com` | Sets the `aud` claim |
| `sub` | `test-user-1` | Sets the `sub` claim |
| `scope` | `email profile` | Space-separated; URL-encode space as `+` |
| `client_id` | `test-client` | Sets the `client_id` claim |
| `domain` | (omitted) | Custom claim — omit to test fail-closed behavior |
| `sa` | (omitted) | Sets `service_account` — omit to test fail-closed |
| `project` | (omitted) | Sets `project` — omit to test fail-closed |
| `ttl` | `300` (seconds) | `exp` is `iat + ttl` |
| Param | Default | Notes |
| ----------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `aud` | `https://api.example.com` | Sets the `aud` claim |
| `sub` | `test-user-1` | Sets the `sub` claim |
| `scope` | `email profile` | Space-separated; URL-encode space as `+` |
| `client_id` | `test-client` | Sets the `client_id` claim |
| `domain` | (omitted) | Mints `<prefix>_domain` (default `extra_domain`) — omit to test fail-closed behavior |
| `sa` | (omitted) | Mints `<prefix>_service_account` (default `extra_service_account`) — omit to test fail-closed |
| `project` | (omitted) | Mints `<prefix>_project` (default `extra_project`) — omit to test fail-closed |
| `ttl` | `300` (seconds) | `exp` is `iat + ttl` |

`iss` is implicit — it's whichever port you call (`http://127.0.0.1:9001` for auth-a, `9002` for auth-b).

`<prefix>` is set process-wide via the `JWT_PRIVATE_CLAIM_PREFIX` env
var (default `extra`); the resource server in `../main.go` must run with
the same value, otherwise its decoder lands these keys in
`Claims.Extras` instead of the typed fields and every `AccessRule`
covering `Domain` / `ServiceAccount` / `Project` fails closed. The
testissuer's startup banner echoes the resolved prefix so you can spot
mismatches at a glance.

The testissuer also reads a `.env` file from its working directory at
startup. Running `go run ./testissuer` from `go-jwks-multi/` therefore
shares the parent `go-jwks-multi/.env` with the resource server, so a
single `JWT_PRIVATE_CLAIM_PREFIX=acme` line keeps both ends in lock-step
without exporting it on every shell.

## Test scenarios

### Happy path — auth-a domain `oa`
Expand Down
64 changes: 50 additions & 14 deletions go-jwks-multi/testissuer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,25 +52,39 @@ import (
"log"
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"

jose "github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/joho/godotenv"
)

// defaultPrivateClaimPrefix matches the AuthGate server and SDK default
// for JWT_PRIVATE_CLAIM_PREFIX.
const defaultPrivateClaimPrefix = "extra"

type issuer struct {
name string
port int
baseURL string
key *rsa.PrivateKey
kid string
signer jose.Signer
// Pre-resolved server-attested claim keys ("<prefix>_domain" etc.).
// The resource server consuming these tokens must be configured with
// the same JWT_PRIVATE_CLAIM_PREFIX, otherwise its decoder lands these
// keys in Claims.Extras instead of the typed Domain/ServiceAccount/
// Project fields and any AccessRule covering them fails closed.
domainKey string
serviceAccountKey string
projectKey string
}

func newIssuer(name string, port int) (*issuer, error) {
func newIssuer(name string, port int, privateClaimPrefix string) (*issuer, error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("gen key for %s: %w", name, err)
Expand All @@ -94,12 +108,15 @@ func newIssuer(name string, port int) (*issuer, error) {
return nil, fmt.Errorf("new signer for %s: %w", name, err)
}
return &issuer{
name: name,
port: port,
baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
key: key,
kid: kid,
signer: signer,
name: name,
port: port,
baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
key: key,
kid: kid,
signer: signer,
domainKey: privateClaimPrefix + "_domain",
serviceAccountKey: privateClaimPrefix + "_service_account",
projectKey: privateClaimPrefix + "_project",
}, nil
}

Expand Down Expand Up @@ -162,17 +179,17 @@ func (i *issuer) sign(w http.ResponseWriter, r *http.Request) {
"client_id": clientID,
"scope": scope,
}
// Custom claims are only set when explicitly requested, so you can mint
// "missing claim" tokens to verify the resource server's fail-closed
// behavior on routes that require them.
// Server-attested claims are only set when explicitly requested, so you
// can mint "missing claim" tokens to exercise the resource server's
// fail-closed behavior on routes that require them.
if domain != "" {
claims["domain"] = domain
claims[i.domainKey] = domain
}
if sa != "" {
claims["service_account"] = sa
claims[i.serviceAccountKey] = sa
}
if project != "" {
claims["project"] = project
claims[i.projectKey] = project
}

token, err := jwt.Signed(i.signer).Claims(claims).Serialize()
Expand Down Expand Up @@ -203,6 +220,17 @@ func def(v, d string) string {
}

func main() {
// Load a .env from the working directory so the testissuer and the
// resource server can share a single config file when invoked as
// `go run ./testissuer` from the go-jwks-multi/ root. Missing file is
// not an error — real env vars still apply.
_ = godotenv.Load()

// JWT_PRIVATE_CLAIM_PREFIX must agree byte-for-byte with the resource
// server's matching env var; an empty / whitespace-only value falls
// back to the SDK default.
privateClaimPrefix := def(strings.TrimSpace(os.Getenv("JWT_PRIVATE_CLAIM_PREFIX")), defaultPrivateClaimPrefix)

configs := []struct {
name string
port int
Expand All @@ -219,7 +247,7 @@ func main() {
}
bounds := make([]bound, 0, len(configs))
for _, c := range configs {
is, err := newIssuer(c.name, c.port)
is, err := newIssuer(c.name, c.port, privateClaimPrefix)
if err != nil {
log.Fatal(err)
}
Expand All @@ -239,10 +267,18 @@ func main() {
for _, b := range bounds {
urls = append(urls, b.is.baseURL)
}
log.Printf("Private claim prefix: %[1]q (mints %[1]s_domain / %[1]s_service_account / %[1]s_project)",
privateClaimPrefix)
log.Println("─── resource server env (copy-paste) ──────────────────────────")
log.Printf("TRUSTED_ISSUERS=%s", strings.Join(urls, ","))
log.Printf("EXPECTED_AUDIENCE=https://api.example.com")
log.Printf("ISSUER_DOMAINS='%s=oa,hwrd;%s=swrd,cdomain'", urls[0], urls[1])
// Echo the prefix in the env block only when it's not the default, so
// the copy-paste line stays minimal in the common case while still
// reminding the operator to keep both ends in sync under a custom prefix.
if privateClaimPrefix != defaultPrivateClaimPrefix {
log.Printf("JWT_PRIVATE_CLAIM_PREFIX=%s", privateClaimPrefix)
}
log.Println("───────────────────────────────────────────────────────────────")

var wg sync.WaitGroup
Expand Down
8 changes: 8 additions & 0 deletions go-jwks/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ EXPECTED_AUDIENCE=
# regardless of what service the token was issued for.
SKIP_AUDIENCE_CHECK=0

# Optional — overrides the AuthGate server's JWT_PRIVATE_CLAIM_PREFIX
# (default "extra"). Server and SDK must agree byte-for-byte; mismatched
# prefixes silently drop Domain/Project/ServiceAccount into Claims.Extras
# and fail any AccessRule covering those dimensions. Leave blank for the
# default "extra"; set, for example, JWT_PRIVATE_CLAIM_PREFIX=acme to
# read acme_domain / acme_project / acme_service_account.
JWT_PRIVATE_CLAIM_PREFIX=

# ─── Required by get-token.sh (Client Credentials helper) ─────────────
# Not used by main.go — it only validates tokens, never mints them.

Expand Down
Loading