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..13c3939 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 `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 (`tenant` / `service_account` / `project`) +## Custom-Claim Validation (`domain` / `service_account` / `project`) The middleware enforces three custom claims AuthGate puts in the token payload: @@ -119,49 +119,49 @@ 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`: +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"}, - tenants: []string{"oa", "hwrd"}, // OA + HWRD tenants 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 `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,9 +173,9 @@ 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` | -| Valid SA token reused on a route requiring a different SA / project | Per-route `accessRule.serviceAccounts` / `projects` | +| 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 `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 @@ -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** — `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,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. -- **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`). +- **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 -**`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..9255cf1 100644 --- a/go-jwks-multi/go.mod +++ b/go-jwks-multi/go.mod @@ -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 ) 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..f0c1a55 100644 --- a/go-jwks-multi/main.go +++ b/go-jwks-multi/main.go @@ -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 @@ -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") @@ -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"}, @@ -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 != "" { @@ -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), @@ -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, }) } @@ -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, }) 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..ce7cd4a 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 @@ -173,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 @@ -186,6 +202,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..436d533 100644 --- a/go-jwks/go.mod +++ b/go-jwks/go.mod @@ -3,7 +3,7 @@ module github.com/go-authgate/examples/go-jwks 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/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..35a4f6f 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,20 @@ 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. + // 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"}})(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 +130,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 +151,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"})