From 30a375e5e5d8033bb3e69fbafa5692b0122fc352 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Fri, 1 May 2026 18:21:28 +0800 Subject: [PATCH 1/5] refactor(examples): bump sdk-go to v0.9.0 and rename Tenant to Domain - Bump go-jwks and go-jwks-multi to sdk-go v0.9.0 with Go 1.25.9 toolchain - Rename Tenant to Domain across go-jwks-multi main.go, claims, env vars, and docs per sdk-go #25 - Rename ISSUER_TENANTS environment variable to ISSUER_DOMAINS - Update testissuer to emit the domain claim and accept ?domain= query parameter - Add /api/admin endpoint to go-jwks demonstrating Domains, ServiceAccounts, and Projects allowlists Co-Authored-By: Claude Opus 4.7 (1M context) --- go-jwks-multi/.env.example | 20 ++++----- go-jwks-multi/README.md | 68 +++++++++++++++--------------- go-jwks-multi/go.mod | 4 +- go-jwks-multi/go.sum | 4 +- go-jwks-multi/main.go | 36 ++++++++-------- go-jwks-multi/testissuer/README.md | 34 +++++++-------- go-jwks-multi/testissuer/main.go | 30 ++++++------- go-jwks/README.md | 36 +++++++++++++--- go-jwks/go.mod | 4 +- go-jwks/go.sum | 4 +- go-jwks/main.go | 29 ++++++++++++- 11 files changed, 160 insertions(+), 109 deletions(-) diff --git a/go-jwks-multi/.env.example b/go-jwks-multi/.env.example index 6d2ce85..f759026 100644 --- a/go-jwks-multi/.env.example +++ b/go-jwks-multi/.env.example @@ -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 @@ -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 diff --git a/go-jwks-multi/README.md b/go-jwks-multi/README.md index 4d57e75..cdc5e67 100644 --- a/go-jwks-multi/README.md +++ b/go-jwks-multi/README.md @@ -1,6 +1,6 @@ # Go Resource Server — Multi-Issuer Offline JWKS Validation -Accept JWT access tokens signed by **multiple AuthGate instances** in a single resource server. Each issuer is discovered independently, gets its own cached JWKS, and routes are dispatched per token's `iss` claim. Per-route allowlists then enforce **custom claims**: `tenant` (tenant short code), `service_account`, and `project`. +Accept JWT access tokens signed by **multiple AuthGate instances** in a single resource server. Each issuer is discovered independently, gets its own cached JWKS, and routes are dispatched per token's `iss` claim. Per-route allowlists then enforce **custom claims**: `domain` (top-level partition short code), `service_account`, and `project`. This is the multi-issuer extension of [`../go-jwks`](../go-jwks). If you only ever need to trust **one** issuer with no claim-based routing, use that simpler example instead. @@ -9,7 +9,7 @@ This is the multi-issuer extension of [`../go-jwks`](../go-jwks). If you only ev | Scenario | Why multi-issuer helps | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | **Multi-region** | One AuthGate per region for latency / data residency; the API accepts users authenticated in any region. | -| **Multi-tenant SaaS** | One AuthGate per tenant (often required for compliance or per-tenant SSO); the shared API accepts tokens from any tenant's AuthGate. | +| **Multi-domain SaaS** | One AuthGate per domain (often required for compliance or per-domain SSO); the shared API accepts tokens from any domain's AuthGate. | | **Migration / cutover** | During the move from old AuthGate → new AuthGate, both must be trusted concurrently so existing tokens don't break. | | **B2B federation** | Trust a partner organization's AuthGate without proxying their auth through your own. | | **Blue/green of AuthGate** | Run two AuthGate revisions side-by-side and shift traffic gradually. | @@ -79,7 +79,7 @@ Same offline benefits as [go-jwks](../go-jwks): zero per-request round-trips, ho | `TRUSTED_ISSUERS` | Yes | Comma-separated list of issuer URLs. Each must match its discovery document's `issuer` field byte-for-byte. Duplicates are rejected. | | `EXPECTED_AUDIENCE` | \* | Required value in the `aud` claim — applied to **all** issuers. Mandatory unless `SKIP_AUDIENCE_CHECK=1` is set. | | `SKIP_AUDIENCE_CHECK` | \* | Set to `1` to explicitly disable `aud` enforcement. Only for issuers that don't emit `aud` on access tokens. | -| `ISSUER_TENANTS` | No | Cross-tenant defense map: `iss1=tenantA,tenantB;iss2=tenantC,tenantD`. When set, every `TRUSTED_ISSUERS` entry must appear with ≥1 tenant. Tenants are lower-cased. Strongly recommended in production multi-tenant deployments — see below. | +| `ISSUER_DOMAINS` | No | Cross-domain defense map: `iss1=domainA,domainB;iss2=domainC,domainD`. When set, every `TRUSTED_ISSUERS` entry must appear with ≥1 domain. Domains are lower-cased. Strongly recommended in production multi-domain deployments — see below. | \* Exactly one of `EXPECTED_AUDIENCE` or `SKIP_AUDIENCE_CHECK=1` must be set — the server refuses to start otherwise. @@ -102,16 +102,16 @@ The server listens on port **8089** (one off from `go-jwks`'s 8088 so you can ru ## API Endpoints -| Endpoint | Auth | Scopes | Tenant allowlist | Service-account allowlist | Project allowlist | +| Endpoint | Auth | Scopes | Domain allowlist | Service-account allowlist | Project allowlist | | ------------------ | ---- | ------- | ---------------- | ------------------------- | ----------------- | | `GET /api/profile` | Yes | — | (any) | (any) | (any) | | `GET /api/data` | Yes | `email` | `oa`, `hwrd` | (any) | (any) | | `GET /api/admin` | Yes | — | (any) | `sync-bot@oa.local` | `admin-tools` | | `GET /health` | No | — | — | — | — | -These rules live in `main()` as `accessRule{...}` literals — replace them with values from your config service if rules need to change without a redeploy. Responses include `issuer` + `tenant` so you can confirm which AuthGate signed the token and which tenant it carries. +These rules live in `main()` as `accessRule{...}` literals — replace them with values from your config service if rules need to change without a redeploy. Responses include `issuer` + `domain` so you can confirm which AuthGate signed the token and which domain it carries. -## Custom-Claim Validation (`tenant` / `service_account` / `project`) +## Custom-Claim Validation (`domain` / `service_account` / `project`) The middleware enforces three custom claims AuthGate puts in the token payload: @@ -119,13 +119,13 @@ The middleware enforces three custom claims AuthGate puts in the token payload: type extraClaims struct { ClientID string `json:"client_id,omitempty"` Scope string `json:"scope,omitempty"` - Tenant string `json:"tenant,omitempty"` // tenant short code, e.g. "oa" + Domain string `json:"domain,omitempty"` // top-level partition short code, e.g. "oa" ServiceAccount string `json:"service_account,omitempty"` // OAuth-app-bound SA identifier Project string `json:"project,omitempty"` // project the OAuth app belongs to } ``` -If your AuthGate uses **namespaced claims** (`https://authgate.example.com/tenant`), update the `json:` tags accordingly. The verifier ignores tags it doesn't recognize, so unused fields stay empty without errors. +If your AuthGate uses **namespaced claims** (`https://authgate.example.com/domain`), update the `json:` tags accordingly. The verifier ignores tags it doesn't recognize, so unused fields stay empty without errors. Per-route policy is expressed via `accessRule`: @@ -133,7 +133,7 @@ Per-route policy is expressed via `accessRule`: mux.Handle("/api/profile", v.middleware(accessRule{})(...)) // any valid token mux.Handle("/api/data", v.middleware(accessRule{ scopes: []string{"email"}, - tenants: []string{"oa", "hwrd"}, // OA + HWRD tenants only + domains: []string{"oa", "hwrd"}, // OA + HWRD domains only })(...)) mux.Handle("/api/admin", v.middleware(accessRule{ serviceAccounts: []string{"sync-bot@oa.local"}, @@ -145,23 +145,23 @@ Semantics: - **Empty slice = "don't check this dimension"** — let users opt in per route. - **AND-combined** — token must pass every configured allowlist. -- **Fail-closed on missing claim** — if a route requires `tenants: []string{"oa"}` and the token has no `tenant` claim, the empty string isn't `"oa"` → reject. -- **Tenant compares case-insensitively** — allowlist values must be lower-case, token side is folded automatically. +- **Fail-closed on missing claim** — if a route requires `domains: []string{"oa"}` and the token has no `domain` claim, the empty string isn't `"oa"` → reject. +- **Domain compares case-insensitively** — allowlist values must be lower-case, token side is folded automatically. - **`service_account` / `project` compared exactly** — they're treated as opaque identifiers, no normalization. - **Reject reasons go to server log only** — clients see a generic `401 invalid_token` so allowlists aren't inferable from outside. -## Cross-Tenant Defense (`ISSUER_TENANTS`) +## Cross-Domain Defense (`ISSUER_DOMAINS`) -Short tenant codes like `oa` / `hwrd` carry no DNS-style trust boundary, so a compromised AuthGate A could otherwise mint a token claiming `tenant=swrd` (which actually belongs to AuthGate B). The optional `ISSUER_TENANTS` map pins each issuer to the tenants it owns: +Short domain codes like `oa` / `hwrd` carry no DNS-style trust boundary, so a compromised AuthGate A could otherwise mint a token claiming `domain=swrd` (which actually belongs to AuthGate B). The optional `ISSUER_DOMAINS` map pins each issuer to the domains it owns: ```bash -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' ``` -When set, after `Verify()` succeeds, the middleware looks up the **issuer that signed the token** in this map and rejects the token if its `tenant` claim isn't in that issuer's allowed set. Strongly recommended for production multi-tenant deployments. Properties: +When set, after `Verify()` succeeds, the middleware looks up the **issuer that signed the token** in this map and rejects the token if its `domain` claim isn't in that issuer's allowed set. Strongly recommended for production multi-domain deployments. Properties: -- **Opt-in.** Unset → no cross-tenant check (suits single-tenant deploys or those where tenants have natural DNS structure). -- **Strict when on.** Every `TRUSTED_ISSUERS` entry must appear in `ISSUER_TENANTS` — a missing entry is a startup error, so a typo can't silently disable the check for one issuer. +- **Opt-in.** Unset → no cross-domain check (suits single-domain deploys or those where domains have natural DNS structure). +- **Strict when on.** Every `TRUSTED_ISSUERS` entry must appear in `ISSUER_DOMAINS` — a missing entry is a startup error, so a typo can't silently disable the check for one issuer. - **Lower-cased.** Allowlist values are folded at parse time; token side is folded before lookup. - **Operates on canonical issuer strings.** The keys must match the `issuer` field returned by each issuer's discovery document (which is what `iss` claims carry). The startup error lists the canonical strings if you typed the wrong one. @@ -173,8 +173,8 @@ When set, after `Verify()` succeeds, the middleware looks up the **issuer that s | Token from a never-trusted issuer | `iss` lookup in `multiValidator.verifiers` map | | Token from trusted issuer A but `iss` claims to be B | `Verify()` re-checks `iss` against the per-issuer verifier | | Token for a different audience reused against this API | `aud` check (`EXPECTED_AUDIENCE`) | -| Compromised issuer A signs a token claiming `tenant=swrd` (owned by issuer B) | `ISSUER_TENANTS` cross-tenant map | -| Valid token from tenant `swrd` calling a route restricted to tenant `oa` | Per-route `accessRule.tenants` | +| Compromised issuer A signs a token claiming `domain=swrd` (owned by issuer B) | `ISSUER_DOMAINS` cross-domain map | +| Valid token from domain `swrd` calling a route restricted to domain `oa` | Per-route `accessRule.domains` | | Valid SA token reused on a route requiring a different SA / project | Per-route `accessRule.serviceAccounts` / `projects` | | Replay of revoked token before `exp` | **Not defended** — keep access-token TTLs short (5–15 min) | @@ -184,7 +184,7 @@ Two options, depending on whether you have real AuthGates handy. ### Option A — local fake issuers (`testissuer/`) -The [`testissuer/`](testissuer/) sub-tool spins up two fake AuthGates (auth-a on `:9001`, auth-b on `:9002`) with ephemeral RSA keypairs and an open `/sign` endpoint that mints arbitrary JWTs. Lets you exercise every code path including the security ones (cross-tenant rejection, route policy, fail-closed on missing claims) without standing up real AuthGates. +The [`testissuer/`](testissuer/) sub-tool spins up two fake AuthGates (auth-a on `:9001`, auth-b on `:9002`) with ephemeral RSA keypairs and an open `/sign` endpoint that mints arbitrary JWTs. Lets you exercise every code path including the security ones (cross-domain rejection, route policy, fail-closed on missing claims) without standing up real AuthGates. ```bash # Terminal 1 — start the two fake issuers @@ -193,15 +193,15 @@ go run ./testissuer # Terminal 2 — start the resource server with the env block testissuer prints 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 . # Terminal 3 — mint tokens and call the API -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 ``` -See [`testissuer/README.md`](testissuer/README.md) for the full scenario list (cross-tenant attack, route policy reject, missing claims, expired tokens, etc.). +See [`testissuer/README.md`](testissuer/README.md) for the full scenario list (cross-domain attack, route policy reject, missing claims, expired tokens, etc.). ### Option B — real AuthGates @@ -223,15 +223,15 @@ curl -H "Authorization: Bearer $TOKEN_A" http://localhost:8089/api/profile curl -H "Authorization: Bearer $TOKEN_B" http://localhost:8089/api/profile ``` -Note: real AuthGate-issued tokens carry whatever `tenant` / `service_account` / `project` claims your AuthGate populates — if you don't control issuance, route allowlists in `main()` may need to match what's actually in the tokens. +Note: real AuthGate-issued tokens carry whatever `domain` / `service_account` / `project` claims your AuthGate populates — if you don't control issuance, route allowlists in `main()` may need to match what's actually in the tokens. ## How It Works 1. **Parallel discovery** — at startup, one goroutine per issuer fetches `/.well-known/openid-configuration` and caches the JWKS via `oidc.NewProvider`. Total discovery is bounded at 15 s; one slow issuer doesn't multiply startup time. 2. **Per-issuer verifier** — a `map[issuer]*oidc.IDTokenVerifier` is built once and is read-only on the hot path (no locking). 3. **Per-request routing** — the middleware decodes the JWT payload (unverified) to read `iss`, looks up the matching verifier, and calls `Verify`. The verifier authoritatively checks signature, `iss`, `aud`, `exp`, `nbf`. -4. **Cross-tenant pin (optional)** — if `ISSUER_TENANTS` is set, the validated token's `tenant` claim is checked against the allowlist for the issuer that signed it. Stops a compromised issuer from minting tokens for a tenant it doesn't own. -5. **Per-route allowlists** — `accessRule` enforces required scopes plus `tenant` / `service_account` / `project` allowlists. Empty slice = "don't check"; non-empty = fail-closed. +4. **Cross-domain pin (optional)** — if `ISSUER_DOMAINS` is set, the validated token's `domain` claim is checked against the allowlist for the issuer that signed it. Stops a compromised issuer from minting tokens for a domain it doesn't own. +5. **Per-route allowlists** — `accessRule` enforces required scopes plus `domain` / `service_account` / `project` allowlists. Empty slice = "don't check"; non-empty = fail-closed. 6. **Untrusted issuer / failed allowlist** → `401 invalid_token` (details logged server-side, never echoed in the response). 7. **Key rotation** — on a token carrying an unknown `kid`, the relevant issuer's JWKS is refreshed transparently. 8. **RFC 6750 errors** — `WWW-Authenticate` challenges for missing/invalid token and insufficient scope (the latter advertises the missing scope). @@ -246,13 +246,13 @@ Note: real AuthGate-issued tokens carry whatever `tenant` / `service_account` / provider.Verifier(&oidc.Config{ClientID: perIssuerAud}) ``` -- **Per-issuer claim policies.** The `issuerTenants` map proves the pattern: the same idea (`map[issuer][]string`) extends to per-issuer allowed projects or service accounts. +- **Per-issuer claim policies.** The `issuerDomains` map proves the pattern: the same idea (`map[issuer][]string`) extends to per-issuer allowed projects or service accounts. - **Dynamic allowlists.** Replace the hard-coded `accessRule` literals in `main()` with a lookup against your config service / database, and cache the result so the hot path stays allocation-free. -- **Namespaced claims.** Update the `json:` tags on `extraClaims` to match your IdP (e.g. `https://authgate.example.com/tenant`). +- **Namespaced claims.** Update the `json:` tags on `extraClaims` to match your IdP (e.g. `https://authgate.example.com/domain`). ## Example Responses -**`GET /api/profile`** (token from AuthGate A, tenant `oa`, SA `sync-bot@oa.local`, project `admin-tools`): +**`GET /api/profile`** (token from AuthGate A, domain `oa`, SA `sync-bot@oa.local`, project `admin-tools`): ```json { @@ -261,7 +261,7 @@ Note: real AuthGate-issued tokens carry whatever `tenant` / `service_account` / "client_id": "app-a", "audience": ["https://api.example.com"], "scope": "email profile", - "tenant": "oa", + "domain": "oa", "service_account": "sync-bot@oa.local", "project": "admin-tools", "expires": "2026-04-25T12:34:56Z" @@ -277,20 +277,20 @@ WWW-Authenticate: Bearer error="invalid_token", error_description="invalid token (Server log: `token verification failed: untrusted issuer: iss="https://attacker.example.com"`.) -**Cross-tenant violation** (token from AuthGate A but `tenant=swrd`, which only AuthGate B owns): +**Cross-domain violation** (token from AuthGate A but `domain=swrd`, which only AuthGate B owns): ```text HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer error="invalid_token", error_description="invalid token" ``` -(Server log: `token verification failed: issuer not permitted for this tenant: iss="https://auth-a.example.com" tenant="swrd" allowed=[oa hwrd]`.) +(Server log: `token verification failed: issuer not permitted for this domain: iss="https://auth-a.example.com" domain="swrd" allowed=[oa hwrd]`.) -**Wrong tenant for route** (`/api/data` allows `oa,hwrd` only, token has `tenant=swrd`): +**Wrong domain for route** (`/api/data` allows `oa,hwrd` only, token has `domain=swrd`): ```text HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer error="invalid_token", error_description="token not authorized for this resource" ``` -(Server log: `policy reject: tenant="swrd" not in allowlist (sub=user-... iss=https://auth-b.example.com)`.) +(Server log: `policy reject: domain="swrd" not in allowlist (sub=user-... iss=https://auth-b.example.com)`.) diff --git a/go-jwks-multi/go.mod b/go-jwks-multi/go.mod index 81cf2bd..ff1ff23 100644 --- a/go-jwks-multi/go.mod +++ b/go-jwks-multi/go.mod @@ -1,9 +1,9 @@ module github.com/go-authgate/examples/go-jwks-multi -go 1.25.8 +go 1.25.9 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 ) diff --git a/go-jwks-multi/go.sum b/go-jwks-multi/go.sum index 7c59a76..1726275 100644 --- a/go-jwks-multi/go.sum +++ b/go-jwks-multi/go.sum @@ -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= diff --git a/go-jwks-multi/main.go b/go-jwks-multi/main.go index c1f6e56..d683f2d 100644 --- a/go-jwks-multi/main.go +++ b/go-jwks-multi/main.go @@ -1,29 +1,29 @@ // 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 +// with per-route allowlists for the `domain`, `service_account`, and // `project` custom claims. 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 @@ -50,7 +50,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") @@ -67,15 +67,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"}, @@ -184,14 +184,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 != "" { @@ -215,7 +215,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), @@ -237,7 +237,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, }) } diff --git a/go-jwks-multi/testissuer/README.md b/go-jwks-multi/testissuer/README.md index e3bef52..b798d50 100644 --- a/go-jwks-multi/testissuer/README.md +++ b/go-jwks-multi/testissuer/README.md @@ -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` | @@ -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' ─────────────────────────────────────────────────────────────── ``` @@ -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 . ``` @@ -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` | @@ -62,35 +62,35 @@ 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" ``` @@ -98,7 +98,7 @@ curl -i -H "Authorization: Bearer $TOK" http://localhost:8089/api/data ### 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 ``` @@ -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..." @@ -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" ``` diff --git a/go-jwks-multi/testissuer/main.go b/go-jwks-multi/testissuer/main.go index 8557d5a..0958d30 100644 --- a/go-jwks-multi/testissuer/main.go +++ b/go-jwks-multi/testissuer/main.go @@ -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. // @@ -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 ( @@ -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")) @@ -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 @@ -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) } @@ -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) } @@ -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 diff --git a/go-jwks/README.md b/go-jwks/README.md index 3d6334c..15a31e8 100644 --- a/go-jwks/README.md +++ b/go-jwks/README.md @@ -101,11 +101,21 @@ The server listens on port **8088**. ## API Endpoints -| Endpoint | Auth Required | Scopes | Description | -| ------------------ | ------------- | ------- | ----------------------------------- | -| `GET /api/profile` | Yes | Any | Returns subject/client/scope info | -| `GET /api/data` | Yes | `email` | Returns data with access-level info | -| `GET /health` | No | — | Health check | +| Endpoint | Auth | Scopes | Domain allowlist | Service-account allowlist | Project allowlist | +| ------------------ | ---- | ------- | ---------------- | ------------------------- | ----------------- | +| `GET /api/profile` | Yes | — | (any) | (any) | (any) | +| `GET /api/data` | Yes | `email` | (any) | (any) | (any) | +| `GET /api/admin` | Yes | — | `oa` | `sync-bot@oa.local` | `admin-tools` | +| `GET /health` | No | — | — | — | — | + +These rules live in `main()` as `jwksauth.AccessRule{...}` literals. The middleware enforces them with the following semantics: + +- **Empty slice = "don't check this dimension"** — let routes opt in. +- **AND-combined** — token must pass every configured allowlist. +- **Fail-closed on missing claim** — if a route requires `Domains: []string{"oa"}` and the token has no `domain` claim, the empty string isn't `"oa"` → reject. +- **`Domain` compares case-insensitively** — allowlist values must be lower-case, the token side is folded automatically. +- **`service_account` / `project` compared exactly** — opaque identifiers, no normalization. +- **Reject reasons go to server log only** — clients see a generic `401 invalid_token` so allowlists aren't inferable from outside. ## Testing @@ -147,6 +157,9 @@ curl -H "Authorization: Bearer $TOKEN" http://localhost:8088/api/profile # Requires "email" scope curl -H "Authorization: Bearer $TOKEN" http://localhost:8088/api/data +# Requires domain=oa, service_account=sync-bot@oa.local, project=admin-tools +curl -H "Authorization: Bearer $TOKEN" http://localhost:8088/api/admin + # No auth curl http://localhost:8088/health ``` @@ -160,7 +173,7 @@ curl http://localhost:8088/health - `iss` equals `ISSUER_URL` - `aud` contains `EXPECTED_AUDIENCE`, or `SKIP_AUDIENCE_CHECK=1` is explicitly set (fail-closed default — the server refuses to start with `aud` validation silently disabled) - `exp` is strict; `nbf` has a built-in 5 min leeway -4. **Scope enforcement** — middleware checks the space-delimited `scope` claim; handlers may call `info.hasScope("profile")` for finer-grained checks. +4. **Per-route allowlists** — `jwksauth.AccessRule` enforces required scopes plus `domain` / `service_account` / `project` allowlists. Empty slice = "don't check"; non-empty = fail-closed. Handlers may also call `info.HasScope("profile")` for finer-grained checks. 5. **RFC 6750 errors** — `401 invalid_token` / `403 insufficient_scope` responses include a proper `WWW-Authenticate` header. ## Example Responses @@ -186,6 +199,17 @@ curl http://localhost:8088/health } ``` +**`GET /api/admin`** (token with `domain=oa`, `service_account=sync-bot@oa.local`, `project=admin-tools`): + +```json +{ + "message": "admin endpoint", + "domain": "oa", + "service_account": "sync-bot@oa.local", + "project": "admin-tools" +} +``` + **Missing token** (no `Authorization` header): ```text diff --git a/go-jwks/go.mod b/go-jwks/go.mod index 42828f2..52d269d 100644 --- a/go-jwks/go.mod +++ b/go-jwks/go.mod @@ -1,9 +1,9 @@ module github.com/go-authgate/examples/go-jwks -go 1.25.8 +go 1.25.9 require ( - github.com/go-authgate/sdk-go v0.7.0 + github.com/go-authgate/sdk-go v0.9.0 github.com/joho/godotenv v1.5.1 ) diff --git a/go-jwks/go.sum b/go-jwks/go.sum index 7c59a76..1726275 100644 --- a/go-jwks/go.sum +++ b/go-jwks/go.sum @@ -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= diff --git a/go-jwks/main.go b/go-jwks/main.go index 488ae49..6411dfd 100644 --- a/go-jwks/main.go +++ b/go-jwks/main.go @@ -20,6 +20,7 @@ // // curl -H "Authorization: Bearer " http://localhost:8088/api/profile // curl -H "Authorization: Bearer " http://localhost:8088/api/data +// curl -H "Authorization: Bearer " http://localhost:8088/api/admin package main import ( @@ -66,8 +67,18 @@ func main() { } mux := http.NewServeMux() + // AccessRule fields are AND-combined and fail-closed: an empty slice + // skips that check, a non-empty slice requires the token to match. + // Reject reasons are server-logged only; clients see a generic 401. mux.Handle("/api/profile", jwksauth.Middleware(v, jwksauth.AccessRule{})(http.HandlerFunc(profileHandler))) - mux.Handle("/api/data", jwksauth.Middleware(v, jwksauth.AccessRule{Scopes: []string{"email"}})(http.HandlerFunc(dataHandler))) + mux.Handle("/api/data", jwksauth.Middleware(v, jwksauth.AccessRule{ + Scopes: []string{"email"}, + })(http.HandlerFunc(dataHandler))) + mux.Handle("/api/admin", jwksauth.Middleware(v, jwksauth.AccessRule{ + Domains: []string{"oa"}, + ServiceAccounts: []string{"sync-bot@oa.local"}, + Projects: []string{"admin-tools"}, + })(http.HandlerFunc(adminHandler))) mux.HandleFunc("/health", healthHandler) srv := &http.Server{ @@ -117,6 +128,7 @@ func profileHandler(w http.ResponseWriter, r *http.Request) { "audience": info.Audience, "scope": info.Claims.Scope, "expires": info.Expiry.UTC().Format(time.RFC3339), + "domain": info.Claims.Domain, }) } @@ -137,6 +149,21 @@ func dataHandler(w http.ResponseWriter, r *http.Request) { }) } +func adminHandler(w http.ResponseWriter, r *http.Request) { + info, ok := jwksauth.TokenInfoFromContext(r.Context()) + if !ok { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + 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, + }) +} + func healthHandler(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) From c7620f02095925560447d549f6e5346711e4b873 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Fri, 1 May 2026 19:03:44 +0800 Subject: [PATCH 2/5] refactor(go-jwks-multi): include domain in admin response and clarify doc comment - Add domain to /api/admin response for parity with go-jwks and the README claim - Reword top doc comment so it does not imply every handler enforces every claim allowlist Co-Authored-By: Claude Opus 4.7 (1M context) --- go-jwks-multi/main.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/go-jwks-multi/main.go b/go-jwks-multi/main.go index d683f2d..f0c1a55 100644 --- a/go-jwks-multi/main.go +++ b/go-jwks-multi/main.go @@ -1,8 +1,9 @@ // 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 `domain`, `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. @@ -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, }) From c87bf5ea905082a109f759e536b52085af294690 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Fri, 1 May 2026 19:08:09 +0800 Subject: [PATCH 3/5] fix(examples): address Copilot review on PR #20 - Revert go directive to 1.25.8 in go-jwks and go-jwks-multi for consistency with the rest of the repo (go-httpretry was pruned by tidy and 1.25.9 is no longer required) - Replace stale accessRule and v.middleware references in go-jwks-multi README with the actual jwksauth.AccessRule and jwksauth.Middleware symbols - Capitalize Domains struct field in the fail-closed bullet to match exported Go field naming Co-Authored-By: Claude Opus 4.7 (1M context) --- go-jwks-multi/README.md | 34 +++++++++++++++++----------------- go-jwks-multi/go.mod | 2 +- go-jwks/go.mod | 2 +- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/go-jwks-multi/README.md b/go-jwks-multi/README.md index cdc5e67..13c3939 100644 --- a/go-jwks-multi/README.md +++ b/go-jwks-multi/README.md @@ -109,7 +109,7 @@ The server listens on port **8089** (one off from `go-jwks`'s 8088 so you can ru | `GET /api/admin` | Yes | — | (any) | `sync-bot@oa.local` | `admin-tools` | | `GET /health` | No | — | — | — | — | -These rules live in `main()` as `accessRule{...}` literals — replace them with values from your config service if rules need to change without a redeploy. Responses include `issuer` + `domain` so you can confirm which AuthGate signed the token and which domain it carries. +These rules live in `main()` as `jwksauth.AccessRule{...}` literals — replace them with values from your config service if rules need to change without a redeploy. Responses include `issuer` + `domain` so you can confirm which AuthGate signed the token and which domain it carries. ## Custom-Claim Validation (`domain` / `service_account` / `project`) @@ -127,25 +127,25 @@ type extraClaims struct { If your AuthGate uses **namespaced claims** (`https://authgate.example.com/domain`), update the `json:` tags accordingly. The verifier ignores tags it doesn't recognize, so unused fields stay empty without errors. -Per-route policy is expressed via `accessRule`: +Per-route policy is expressed via `jwksauth.AccessRule`: ```go -mux.Handle("/api/profile", v.middleware(accessRule{})(...)) // any valid token -mux.Handle("/api/data", v.middleware(accessRule{ - scopes: []string{"email"}, - domains: []string{"oa", "hwrd"}, // OA + HWRD domains only -})(...)) -mux.Handle("/api/admin", v.middleware(accessRule{ - serviceAccounts: []string{"sync-bot@oa.local"}, - projects: []string{"admin-tools"}, -})(...)) +mux.Handle("/api/profile", jwksauth.Middleware(mv, jwksauth.AccessRule{})(http.HandlerFunc(profileHandler))) // any valid token +mux.Handle("/api/data", jwksauth.Middleware(mv, jwksauth.AccessRule{ + Scopes: []string{"email"}, + Domains: []string{"oa", "hwrd"}, // OA + HWRD domains only +})(http.HandlerFunc(dataHandler))) +mux.Handle("/api/admin", jwksauth.Middleware(mv, jwksauth.AccessRule{ + ServiceAccounts: []string{"sync-bot@oa.local"}, + Projects: []string{"admin-tools"}, +})(http.HandlerFunc(adminHandler))) ``` Semantics: - **Empty slice = "don't check this dimension"** — let users opt in per route. - **AND-combined** — token must pass every configured allowlist. -- **Fail-closed on missing claim** — if a route requires `domains: []string{"oa"}` and the token has no `domain` claim, the empty string isn't `"oa"` → reject. +- **Fail-closed on missing claim** — if a route requires `Domains: []string{"oa"}` and the token has no `domain` claim, the empty string isn't `"oa"` → reject. - **Domain compares case-insensitively** — allowlist values must be lower-case, token side is folded automatically. - **`service_account` / `project` compared exactly** — they're treated as opaque identifiers, no normalization. - **Reject reasons go to server log only** — clients see a generic `401 invalid_token` so allowlists aren't inferable from outside. @@ -174,8 +174,8 @@ When set, after `Verify()` succeeds, the middleware looks up the **issuer that s | Token from trusted issuer A but `iss` claims to be B | `Verify()` re-checks `iss` against the per-issuer verifier | | Token for a different audience reused against this API | `aud` check (`EXPECTED_AUDIENCE`) | | Compromised issuer A signs a token claiming `domain=swrd` (owned by issuer B) | `ISSUER_DOMAINS` cross-domain map | -| Valid token from domain `swrd` calling a route restricted to domain `oa` | Per-route `accessRule.domains` | -| Valid SA token reused on a route requiring a different SA / project | Per-route `accessRule.serviceAccounts` / `projects` | +| Valid token from domain `swrd` calling a route restricted to domain `oa` | Per-route `jwksauth.AccessRule.Domains` | +| Valid SA token reused on a route requiring a different SA / project | Per-route `jwksauth.AccessRule.ServiceAccounts` / `Projects` | | Replay of revoked token before `exp` | **Not defended** — keep access-token TTLs short (5–15 min) | ## Testing @@ -231,7 +231,7 @@ Note: real AuthGate-issued tokens carry whatever `domain` / `service_account` / 2. **Per-issuer verifier** — a `map[issuer]*oidc.IDTokenVerifier` is built once and is read-only on the hot path (no locking). 3. **Per-request routing** — the middleware decodes the JWT payload (unverified) to read `iss`, looks up the matching verifier, and calls `Verify`. The verifier authoritatively checks signature, `iss`, `aud`, `exp`, `nbf`. 4. **Cross-domain pin (optional)** — if `ISSUER_DOMAINS` is set, the validated token's `domain` claim is checked against the allowlist for the issuer that signed it. Stops a compromised issuer from minting tokens for a domain it doesn't own. -5. **Per-route allowlists** — `accessRule` enforces required scopes plus `domain` / `service_account` / `project` allowlists. Empty slice = "don't check"; non-empty = fail-closed. +5. **Per-route allowlists** — `jwksauth.AccessRule` enforces required scopes plus `domain` / `service_account` / `project` allowlists. Empty slice = "don't check"; non-empty = fail-closed. 6. **Untrusted issuer / failed allowlist** → `401 invalid_token` (details logged server-side, never echoed in the response). 7. **Key rotation** — on a token carrying an unknown `kid`, the relevant issuer's JWKS is refreshed transparently. 8. **RFC 6750 errors** — `WWW-Authenticate` challenges for missing/invalid token and insufficient scope (the latter advertises the missing scope). @@ -246,8 +246,8 @@ Note: real AuthGate-issued tokens carry whatever `domain` / `service_account` / provider.Verifier(&oidc.Config{ClientID: perIssuerAud}) ``` -- **Per-issuer claim policies.** The `issuerDomains` map proves the pattern: the same idea (`map[issuer][]string`) extends to per-issuer allowed projects or service accounts. -- **Dynamic allowlists.** Replace the hard-coded `accessRule` literals in `main()` with a lookup against your config service / database, and cache the result so the hot path stays allocation-free. +- **Per-issuer claim policies.** `mv.IssuerDomains()` proves the pattern: the same `map[issuer][]string` shape extends to per-issuer allowed projects or service accounts. +- **Dynamic allowlists.** Replace the hard-coded `jwksauth.AccessRule` literals in `main()` with a lookup against your config service / database, and cache the result so the hot path stays allocation-free. - **Namespaced claims.** Update the `json:` tags on `extraClaims` to match your IdP (e.g. `https://authgate.example.com/domain`). ## Example Responses diff --git a/go-jwks-multi/go.mod b/go-jwks-multi/go.mod index ff1ff23..9255cf1 100644 --- a/go-jwks-multi/go.mod +++ b/go-jwks-multi/go.mod @@ -1,6 +1,6 @@ module github.com/go-authgate/examples/go-jwks-multi -go 1.25.9 +go 1.25.8 require ( github.com/go-authgate/sdk-go v0.9.0 diff --git a/go-jwks/go.mod b/go-jwks/go.mod index 52d269d..436d533 100644 --- a/go-jwks/go.mod +++ b/go-jwks/go.mod @@ -1,6 +1,6 @@ module github.com/go-authgate/examples/go-jwks -go 1.25.9 +go 1.25.8 require ( github.com/go-authgate/sdk-go v0.9.0 From a235a53c819c1d3ab589d1c9efd9ad14915ce338 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Fri, 1 May 2026 19:14:51 +0800 Subject: [PATCH 4/5] docs(go-jwks): clarify status code per AccessRule reject reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allowlist (Domain/ServiceAccount/Project) rejects produce a generic 401, but scope failures produce 403 insufficient_scope with details — call this out in the AccessRule comment so it does not read as if every reject is a 401. Co-Authored-By: Claude Opus 4.7 (1M context) --- go-jwks/main.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/go-jwks/main.go b/go-jwks/main.go index 6411dfd..35a4f6f 100644 --- a/go-jwks/main.go +++ b/go-jwks/main.go @@ -69,7 +69,9 @@ func main() { mux := http.NewServeMux() // AccessRule fields are AND-combined and fail-closed: an empty slice // skips that check, a non-empty slice requires the token to match. - // Reject reasons are server-logged only; clients see a generic 401. + // Domain/ServiceAccount/Project reject reasons are server-logged only — + // clients see a generic 401. Scope failures are reported separately as + // 403 insufficient_scope with details in the WWW-Authenticate header. mux.Handle("/api/profile", jwksauth.Middleware(v, jwksauth.AccessRule{})(http.HandlerFunc(profileHandler))) mux.Handle("/api/data", jwksauth.Middleware(v, jwksauth.AccessRule{ Scopes: []string{"email"}, From d0b6cafc076c3f41b1a73497bafbbc3cee89fff0 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Fri, 1 May 2026 19:20:04 +0800 Subject: [PATCH 5/5] docs(go-jwks): add domain to /api/profile example response The handler started returning the domain claim, but the README sample still omitted it. Mirror the actual handler shape and note that the field is empty when AuthGate does not emit a domain claim. Co-Authored-By: Claude Opus 4.7 (1M context) --- go-jwks/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/go-jwks/README.md b/go-jwks/README.md index 15a31e8..ce7cd4a 100644 --- a/go-jwks/README.md +++ b/go-jwks/README.md @@ -186,10 +186,13 @@ curl http://localhost:8088/health "client_id": "your-client-id", "audience": ["https://api.example.com"], "scope": "email profile", - "expires": "2026-04-24T12:34:56Z" + "expires": "2026-04-24T12:34:56Z", + "domain": "oa" } ``` +`domain` is `""` if AuthGate did not emit a `domain` claim on the token. + **`GET /api/data`** (valid token with `email` scope): ```json