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
20 changes: 10 additions & 10 deletions go-jwks-multi/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
#
# Use cases:
# - Multi-region: one AuthGate per region.
# - Multi-tenant: one AuthGate per tenant under a shared API.
# - Multi-domain: one AuthGate per domain under a shared API.
# - Migration: keep both old + new AuthGate during cutover.
# - Federation: trust a partner organization's AuthGate.
TRUSTED_ISSUERS=https://auth-a.example.com,https://auth-b.example.com
Expand All @@ -21,17 +21,17 @@ EXPECTED_AUDIENCE=
# any holder of a valid token can call this API.
SKIP_AUDIENCE_CHECK=0

# ─── Optional: cross-tenant defense for short tenant codes ───────────
# Map: which `tenant` codes is each issuer permitted to sign for?
# Format: iss1=tenantA,tenantB;iss2=tenantC,tenantD
# Tenant values are lower-cased before comparison.
# ─── Optional: cross-domain defense for short domain codes ───────────
# Map: which `domain` codes is each issuer permitted to sign for?
# Format: iss1=domainA,domainB;iss2=domainC,domainD
# Domain values are lower-cased before comparison.
#
# When set, every issuer in TRUSTED_ISSUERS MUST appear here (with at
# least one tenant) — a missing entry is a startup error so a typo never
# least one domain) — a missing entry is a startup error so a typo never
# silently disables the check for one issuer.
#
# When unset, the cross-tenant check is skipped (any trusted issuer may
# sign a token for any tenant). Strongly recommended for production
# multi-tenant deployments where tenant values are short codes like
# When unset, the cross-domain check is skipped (any trusted issuer may
# sign a token for any domain). Strongly recommended for production
# multi-domain deployments where domain values are short codes like
# "oa" / "hwrd" / "swrd" with no DNS-style trust boundary.
ISSUER_TENANTS=https://auth-a.example.com=oa,hwrd;https://auth-b.example.com=swrd,cdomain
ISSUER_DOMAINS=https://auth-a.example.com=oa,hwrd;https://auth-b.example.com=swrd,cdomain
90 changes: 45 additions & 45 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.7.0
github.com/go-authgate/sdk-go v0.9.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.7.0 h1:hUqUMzsDkb+l5EiL+aX2LaFon/3mbjHmxm97qHHHL3k=
github.com/go-authgate/sdk-go v0.7.0/go.mod h1:Afx/Dbyvf8pw4YeOqVEVdDW2WHhn534Sb2/TaFQktuU=
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-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
42 changes: 22 additions & 20 deletions go-jwks-multi/main.go
Original file line number Diff line number Diff line change
@@ -1,29 +1,30 @@
// Resource server example — accepts AuthGate-issued access tokens from
// MULTIPLE trusted issuers, validated offline against each issuer's JWKS,
// with per-route allowlists for the `tenant`, `service_account`, and
// `project` custom claims. The validation core lives in the SDK's
// jwksauth package; this file shows configuration + routing.
// with per-route allowlists drawn from `scope` plus the `domain`,
// `service_account`, and `project` custom claims (see main() for which
// routes apply which). The validation core lives in the SDK's jwksauth
// package; this file shows configuration + routing.
//
// Use cases:
// - Multi-region: one AuthGate per region; any region's tokens accepted.
// - Multi-tenant: one AuthGate per tenant, mounted under a shared API.
// - Multi-domain: one AuthGate per domain, mounted under a shared API.
// - Migration: accept the old and new AuthGate concurrently during cutover.
// - Federation: trust tokens from a partner organization's AuthGate.
//
// Why ISSUER_TENANTS matters with short tenant codes:
// Why ISSUER_DOMAINS matters with short domain codes:
//
// Short codes like "oa" / "hwrd" carry no DNS-style trust boundary, so a
// compromised issuer A could otherwise sign a token claiming
// `tenant=swrd` (which actually belongs to issuer B). The optional
// ISSUER_TENANTS map pins each issuer to the tenants it owns and rejects
// cross-tenant claims at the resource server.
// `domain=swrd` (which actually belongs to issuer B). The optional
// ISSUER_DOMAINS map pins each issuer to the domains it owns and rejects
// cross-domain claims at the resource server.
//
// Usage:
//
// export TRUSTED_ISSUERS=https://auth-a.example.com,https://auth-b.example.com
// export EXPECTED_AUDIENCE=https://api.example.com # or SKIP_AUDIENCE_CHECK=1
// # Optional cross-tenant defense — strongly recommended with short codes:
// export ISSUER_TENANTS='https://auth-a.example.com=oa,hwrd;https://auth-b.example.com=swrd,cdomain'
// # Optional cross-domain defense — strongly recommended with short codes:
// export ISSUER_DOMAINS='https://auth-a.example.com=oa,hwrd;https://auth-b.example.com=swrd,cdomain'
// go run main.go
package main

Expand All @@ -50,7 +51,7 @@ func main() {
rawIssuers := strings.TrimSpace(os.Getenv("TRUSTED_ISSUERS"))
expectedAudience := strings.TrimSpace(os.Getenv("EXPECTED_AUDIENCE"))
skipAudience := strings.TrimSpace(os.Getenv("SKIP_AUDIENCE_CHECK")) == "1"
rawIssuerTenants := strings.TrimSpace(os.Getenv("ISSUER_TENANTS"))
rawIssuerDomains := strings.TrimSpace(os.Getenv("ISSUER_DOMAINS"))

if rawIssuers == "" {
log.Fatal("Set TRUSTED_ISSUERS to a comma-separated list of issuer URLs")
Expand All @@ -67,15 +68,15 @@ func main() {
if err != nil {
log.Fatalf("build verifiers: %v", err)
}
if err := mv.SetIssuerTenants(rawIssuerTenants); err != nil {
log.Fatalf("parse ISSUER_TENANTS: %v", err)
if err := mv.SetIssuerDomains(rawIssuerDomains); err != nil {
log.Fatalf("parse ISSUER_DOMAINS: %v", err)
}

mux := http.NewServeMux()
mux.Handle("/api/profile", jwksauth.Middleware(mv, jwksauth.AccessRule{})(http.HandlerFunc(profileHandler)))
mux.Handle("/api/data", jwksauth.Middleware(mv, jwksauth.AccessRule{
Scopes: []string{"email"},
Tenants: []string{"oa", "hwrd"},
Domains: []string{"oa", "hwrd"},
})(http.HandlerFunc(dataHandler)))
mux.Handle("/api/admin", jwksauth.Middleware(mv, jwksauth.AccessRule{
ServiceAccounts: []string{"sync-bot@oa.local"},
Expand Down Expand Up @@ -184,14 +185,14 @@ func isLoopbackHost(host string) bool {
}

func logStartup(mv *jwksauth.MultiVerifier, audience string) {
tenants := mv.IssuerTenants()
domains := mv.IssuerDomains()
issuers := mv.Issuers()
log.Printf("Trusted issuers (%d):", len(issuers))
for _, iss := range issuers {
if t := tenants[iss]; t != nil {
log.Printf(" - %s → tenants: %v", iss, t)
if d := domains[iss]; d != nil {
log.Printf(" - %s → domains: %v", iss, d)
} else {
log.Printf(" - %s → tenants: (any — ISSUER_TENANTS not set)", iss)
log.Printf(" - %s → domains: (any — ISSUER_DOMAINS not set)", iss)
}
}
if audience != "" {
Expand All @@ -215,7 +216,7 @@ func profileHandler(w http.ResponseWriter, r *http.Request) {
"client_id": info.Claims.ClientID,
"audience": info.Audience,
"scope": info.Claims.Scope,
"tenant": info.Claims.Tenant,
"domain": info.Claims.Domain,
"service_account": info.Claims.ServiceAccount,
"project": info.Claims.Project,
"expires": info.Expiry.UTC().Format(time.RFC3339),
Expand All @@ -237,7 +238,7 @@ func dataHandler(w http.ResponseWriter, r *http.Request) {
"message": msg,
"issuer": info.Issuer,
"subject": info.Subject,
"tenant": info.Claims.Tenant,
"domain": info.Claims.Domain,
})
}

Expand All @@ -250,6 +251,7 @@ func adminHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"message": "admin endpoint",
"domain": info.Claims.Domain,
"service_account": info.Claims.ServiceAccount,
"project": info.Claims.Project,
})
Expand Down
34 changes: 17 additions & 17 deletions go-jwks-multi/testissuer/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# testissuer — local fake AuthGates for `go-jwks-multi`

Spins up two HTTP issuers that **sign your test tokens locally** so you can exercise the resource server's multi-issuer + multi-tenant code paths (happy path, cross-tenant defense, route policy reject) without standing up real AuthGates.
Spins up two HTTP issuers that **sign your test tokens locally** so you can exercise the resource server's multi-issuer + multi-domain code paths (happy path, cross-domain defense, route policy reject) without standing up real AuthGates.

> ⚠️ This server signs **anything** you ask for. It's a test tool — bind it to localhost only, never expose it.

## What you get

| Issuer | URL | Default allowed tenants |
| Issuer | URL | Default allowed domains |
| ------ | ------------------------- | -------------------------- |
| auth-a | `http://127.0.0.1:9001` | `oa`, `hwrd` |
| auth-b | `http://127.0.0.1:9002` | `swrd`, `cdomain` |
Expand All @@ -32,7 +32,7 @@ The startup banner prints a copy-paste-ready env block:
─── resource server env (copy-paste) ──────────────────────────
TRUSTED_ISSUERS=http://127.0.0.1:9001,http://127.0.0.1:9002
EXPECTED_AUDIENCE=https://api.example.com
ISSUER_TENANTS='http://127.0.0.1:9001=oa,hwrd;http://127.0.0.1:9002=swrd,cdomain'
ISSUER_DOMAINS='http://127.0.0.1:9001=oa,hwrd;http://127.0.0.1:9002=swrd,cdomain'
───────────────────────────────────────────────────────────────
```

Expand All @@ -41,7 +41,7 @@ ISSUER_TENANTS='http://127.0.0.1:9001=oa,hwrd;http://127.0.0.1:9002=swrd,cdomain
cd go-jwks-multi
TRUSTED_ISSUERS=http://127.0.0.1:9001,http://127.0.0.1:9002 \
EXPECTED_AUDIENCE=https://api.example.com \
ISSUER_TENANTS='http://127.0.0.1:9001=oa,hwrd;http://127.0.0.1:9002=swrd,cdomain' \
ISSUER_DOMAINS='http://127.0.0.1:9001=oa,hwrd;http://127.0.0.1:9002=swrd,cdomain' \
go run .
```

Expand All @@ -53,7 +53,7 @@ go run .
| `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 |
| `tenant` | (omitted) | Custom claim — omit to test fail-closed behavior |
| `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` |
Expand All @@ -62,43 +62,43 @@ go run .

## Test scenarios

### Happy path — auth-a tenant `oa`
### Happy path — auth-a domain `oa`

```bash
TOK=$(curl -s 'http://127.0.0.1:9001/sign?tenant=oa&sa=sync-bot@oa.local&project=admin-tools&scope=email+profile')
TOK=$(curl -s 'http://127.0.0.1:9001/sign?domain=oa&sa=sync-bot@oa.local&project=admin-tools&scope=email+profile')
curl -i -H "Authorization: Bearer $TOK" http://localhost:8089/api/profile
# → 200; response shows issuer=auth-a, tenant=oa, all claims populated
# → 200; response shows issuer=auth-a, domain=oa, all claims populated
```

### Cross-tenant attack — auth-a tries to sign for `swrd`
### Cross-domain attack — auth-a tries to sign for `swrd`

```bash
TOK=$(curl -s 'http://127.0.0.1:9001/sign?tenant=swrd')
TOK=$(curl -s 'http://127.0.0.1:9001/sign?domain=swrd')
curl -i -H "Authorization: Bearer $TOK" http://localhost:8089/api/profile
# → 401; resource server log: "token verification failed: issuer not permitted for this tenant: iss=...:9001 tenant=\"swrd\" allowed=[oa hwrd]"
# → 401; resource server log: "token verification failed: issuer not permitted for this domain: iss=...:9001 domain=\"swrd\" allowed=[oa hwrd]"
```

### Route policy reject — `/api/data` only allows `oa`, `hwrd`

```bash
TOK=$(curl -s 'http://127.0.0.1:9002/sign?tenant=swrd&scope=email') # legitimate auth-b token
TOK=$(curl -s 'http://127.0.0.1:9002/sign?domain=swrd&scope=email') # legitimate auth-b token
curl -i -H "Authorization: Bearer $TOK" http://localhost:8089/api/data
# → 401 "token not authorized for this resource"
# → resource server log: "policy reject: tenant=\"swrd\" not in allowlist"
# → resource server log: "policy reject: domain=\"swrd\" not in allowlist"
```

### Insufficient scope — `/api/data` requires `email`

```bash
TOK=$(curl -s 'http://127.0.0.1:9001/sign?tenant=oa&scope=profile') # email scope missing
TOK=$(curl -s 'http://127.0.0.1:9001/sign?domain=oa&scope=profile') # email scope missing
curl -i -H "Authorization: Bearer $TOK" http://localhost:8089/api/data
# → 403; WWW-Authenticate: ... error="insufficient_scope", scope="email"
```

### Missing required custom claim (fail-closed)

```bash
TOK=$(curl -s 'http://127.0.0.1:9001/sign?tenant=oa') # no `sa` or `project`
TOK=$(curl -s 'http://127.0.0.1:9001/sign?domain=oa') # no `sa` or `project`
curl -i -H "Authorization: Bearer $TOK" http://localhost:8089/api/admin
# → 401; /api/admin requires sync-bot@oa.local SA + admin-tools project
```
Expand All @@ -114,7 +114,7 @@ curl -i -H "Authorization: Bearer $TOK" http://localhost:8089/api/admin
### Expired token (server doesn't auto-rotate; just request a tiny TTL)

```bash
TOK=$(curl -s 'http://127.0.0.1:9001/sign?tenant=oa&ttl=2')
TOK=$(curl -s 'http://127.0.0.1:9001/sign?domain=oa&ttl=2')
sleep 3
curl -i -H "Authorization: Bearer $TOK" http://localhost:8089/api/profile
# → 401; resource server log: "token verification failed: ...token is expired..."
Expand All @@ -125,7 +125,7 @@ curl -i -H "Authorization: Bearer $TOK" http://localhost:8089/api/profile
JWTs use base64url encoding (`-`/`_` instead of `+`/`/`) and omit padding, so plain `base64 -d` fails on most tokens. The robust path is the helper bundled with the sibling example, which handles URL-safe alphabet + padding for you:

```bash
TOK=$(curl -s 'http://127.0.0.1:9001/sign?tenant=oa')
TOK=$(curl -s 'http://127.0.0.1:9001/sign?domain=oa')
bash ../../go-jwks/get-token.sh --decode "$TOK"
```

Expand Down
30 changes: 15 additions & 15 deletions go-jwks-multi/testissuer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@
// auto-discover and cache the public key.
// - Each issuer exposes a `/sign` endpoint that mints arbitrary JWTs
// signed by THAT issuer's key. You set `iss` implicitly by choosing
// the port; everything else (`aud`, `tenant`, `sa`,
// the port; everything else (`aud`, `domain`, `sa`,
// `project`, `scope`, `sub`, `client_id`, `ttl`) is a query param.
//
// Why this exists: ../get-token.sh in ../../go-jwks/ talks to a real
// AuthGate via Client Credentials. For multi-issuer + multi-tenant
// AuthGate via Client Credentials. For multi-issuer + multi-domain
// testing you typically need to mint tokens with arbitrary `iss` and
// `tenant` to exercise both happy paths and security paths (cross-tenant
// `domain` to exercise both happy paths and security paths (cross-domain
// rejection, untrusted issuer, etc.) without standing up two real
// AuthGates.
//
Expand All @@ -30,17 +30,17 @@
// 2. Point the resource server at them:
// TRUSTED_ISSUERS=http://127.0.0.1:9001,http://127.0.0.1:9002 \
// EXPECTED_AUDIENCE=https://api.example.com \
// ISSUER_TENANTS='http://127.0.0.1:9001=oa,hwrd;http://127.0.0.1:9002=swrd,cdomain' \
// ISSUER_DOMAINS='http://127.0.0.1:9001=oa,hwrd;http://127.0.0.1:9002=swrd,cdomain' \
// go run .
//
// 3. Mint a token and call the API:
// TOK=$(curl -s 'http://127.0.0.1:9001/sign?tenant=oa&scope=email+profile&sa=sync-bot@oa.local&project=admin-tools')
// TOK=$(curl -s 'http://127.0.0.1:9001/sign?domain=oa&scope=email+profile&sa=sync-bot@oa.local&project=admin-tools')
// curl -i -H "Authorization: Bearer $TOK" http://localhost:8089/api/profile
//
// 4. Try a cross-tenant attack — should be rejected by ISSUER_TENANTS:
// TOK=$(curl -s 'http://127.0.0.1:9001/sign?tenant=swrd')
// 4. Try a cross-domain attack — should be rejected by ISSUER_DOMAINS:
// TOK=$(curl -s 'http://127.0.0.1:9001/sign?domain=swrd')
// curl -i -H "Authorization: Bearer $TOK" http://localhost:8089/api/profile
// # → 401; resource server log shows "token verification failed: issuer not permitted for this tenant: ..."
// # → 401; resource server log shows "token verification failed: issuer not permitted for this domain: ..."
package main

import (
Expand Down Expand Up @@ -143,7 +143,7 @@ func (i *issuer) sign(w http.ResponseWriter, r *http.Request) {
sub := def(q.Get("sub"), "test-user-1")
scope := def(q.Get("scope"), "email profile")
clientID := def(q.Get("client_id"), "test-client")
tenant := q.Get("tenant")
domain := q.Get("domain")
sa := q.Get("sa")
project := q.Get("project")
ttlSec, err := strconv.Atoi(def(q.Get("ttl"), "300"))
Expand All @@ -165,8 +165,8 @@ func (i *issuer) sign(w http.ResponseWriter, r *http.Request) {
// 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.
if tenant != "" {
claims["tenant"] = tenant
if domain != "" {
claims["domain"] = domain
}
if sa != "" {
claims["service_account"] = sa
Expand All @@ -180,8 +180,8 @@ func (i *issuer) sign(w http.ResponseWriter, r *http.Request) {
http.Error(w, "sign: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[%s] signed: sub=%q aud=%q tenant=%q sa=%q project=%q scope=%q ttl=%ds",
i.name, sub, aud, tenant, sa, project, scope, ttlSec)
log.Printf("[%s] signed: sub=%q aud=%q domain=%q sa=%q project=%q scope=%q ttl=%ds",
i.name, sub, aud, domain, sa, project, scope, ttlSec)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintln(w, token)
}
Expand All @@ -191,7 +191,7 @@ func (i *issuer) index(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintf(w, "test issuer %q at %s\n\nendpoints:\n"+
" GET /.well-known/openid-configuration\n"+
" GET /jwks.json\n"+
" GET /sign?aud=...&sub=...&tenant=...&sa=...&project=...&scope=...&ttl=...\n",
" GET /sign?aud=...&sub=...&domain=...&sa=...&project=...&scope=...&ttl=...\n",
i.name, i.baseURL)
}

Expand Down Expand Up @@ -242,7 +242,7 @@ func main() {
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_TENANTS='%s=oa,hwrd;%s=swrd,cdomain'", urls[0], urls[1])
log.Printf("ISSUER_DOMAINS='%s=oa,hwrd;%s=swrd,cdomain'", urls[0], urls[1])
log.Println("───────────────────────────────────────────────────────────────")

var wg sync.WaitGroup
Expand Down
Loading
Loading