Skip to content
Merged
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Go SDK for AuthGate — currently provides the `credstore` package for secure cr

Module: `github.com/go-authgate/sdk-go` (Go 1.25+)

The `jwksauth` package's default private-claim prefix is `"extra"`, matching the upstream AuthGate `JWT_PRIVATE_CLAIM_PREFIX` default; deployments that override the server-side value must pass the same string via `jwksauth.WithPrivateClaimPrefix(...)`.

## Common Commands

```bash
Expand Down
91 changes: 62 additions & 29 deletions jwksauth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,29 +60,54 @@ func profile(w http.ResponseWriter, r *http.Request) {
}
```

## Domain and Tenant hierarchy
## Server-attested private claims and the prefix

AuthGate partitions tokens along two dimensions:
AuthGate may emit up to three private claims on a token: **Domain**,
**Project**, **ServiceAccount**. Each is optional — tokens that don't
need a given dimension simply omit the claim. When present they appear
in the payload under a configurable prefix (default `extra`), so the
JWT keys are `extra_domain`, `extra_project`, `extra_service_account`.
The SDK reads them out of the box.

- **Domain** (`domain` claim, e.g. `oa`, `swrd`, `hwrd`) is the top-level
partition. Every token has exactly one Domain.
- **Tenant** (`tenant` claim, e.g. `a76`, `a78`) is an *optional* sub-room
inside a Domain. Domains that have no sub-room concept simply omit the
claim, and the SDK exposes that as `info.Tenant() == ""`.
```json
{
"iss": "https://auth.example.com",
"extra_domain": "oa",
"extra_project": "p1",
"extra_service_account": "sync-bot@oa.local"
}
```

Both claims appear independently in the JWT payload:
If your AuthGate deployment overrides `JWT_PRIVATE_CLAIM_PREFIX`, pass the
same value to the verifier:

```json
// Domain-only token (Domain has no Tenant concept)
{ "iss": "https://auth.example.com", "domain": "oa" }
```go
v, err := jwksauth.NewVerifier(ctx, issuerURL, audience,
jwksauth.WithPrivateClaimPrefix("acme")) // reads acme_domain, acme_project, acme_service_account
```

Server and SDK must agree byte-for-byte. Reading with the wrong prefix
yields empty Domain / Project / ServiceAccount and (when `AccessRule`
covers those dimensions) fails closed.

// Domain + Tenant token (sub-room inside a Domain)
{ "iss": "https://auth.example.com", "domain": "oa", "tenant": "a76" }
### Caller-supplied keys (Extras)

Any other non-standard payload keys — for example a caller-supplied
`tenant` — are surfaced on `Claims.Extras`. Read them with
`TokenInfo.Extra`:

```go
if v, ok := info.Extra("tenant"); ok {
if s, ok := v.(string); ok {
// use the caller-supplied tenant value
_ = s
}
}
```

`AccessRule` filters on Domain only; the optional Tenant value is exposed
on `TokenInfo` for application code to read but is not enforced at the
allowlist level.
`AccessRule` and the cross-issuer Domain pinning never look at Extras —
caller-supplied keys are not server-attested, so they cannot be used to
gate access. Apply your own checks in the handler when needed.

## Multiple issuers

Expand All @@ -101,8 +126,8 @@ if err != nil { log.Fatal(err) }
// domain codes ("oa" / "hwrd" / "swrd") this stops a compromised issuer
// from minting tokens that claim a Domain owned by another issuer.
//
// Tenants live entirely inside a Domain, so they are not part of the
// cross-issuer pinning encoding.
// Caller-supplied keys (surfaced via Claims.Extras) are not part of the
// cross-issuer pinning — only the server-attested Domain participates.
if err := mv.SetIssuerDomains(
"https://auth-a.example.com=oa,hwrd;https://auth-b.example.com=swrd",
); err != nil {
Expand All @@ -126,17 +151,25 @@ mux.Handle("/api/admin", jwksauth.Middleware(mv, jwksauth.AccessRule{
## AccessRule

Per-route policy; an empty slice means "this dimension is not checked".

| Field | Required claim | Match | Notes |
| ----------------- | ----------------- | --------- | ------------------------------------------------------------------------------- |
| `Scopes` | `scope` | space-set | Reports `403 insufficient_scope` and advertises the scope on `WWW-Authenticate` |
| `Domains` | `domain` | case-fold | SDK lower-cases the rule on registration |
| `ServiceAccounts` | `service_account` | exact | Case-sensitive |
| `Projects` | `project` | exact | Case-sensitive |

The optional `tenant` claim is read from the JWT and exposed on
`TokenInfo.Tenant()` (case-folded) and `TokenInfo.Claims.Tenant` (raw), but
is not part of the allowlist surface.
The "Required claim" column shows the JWT payload key under the default
prefix (`extra`); under a custom prefix the keys become
`<prefix>_domain` etc.

| Field | Required claim | Match | Notes |
| ----------------- | ----------------------- | --------- | ------------------------------------------------------------------------------- |
| `Scopes` | `scope` | space-set | Reports `403 insufficient_scope` and advertises the scope on `WWW-Authenticate` |
| `Domains` | `extra_domain` | case-fold | SDK lower-cases the rule on registration |
| `ServiceAccounts` | `extra_service_account` | exact | Case-sensitive |
| `Projects` | `extra_project` | exact | Case-sensitive |

Caller-supplied keys (any payload key not in the SDK's reserved-key set
and not one of the three server-attested `<prefix>_domain` /
`<prefix>_project` / `<prefix>_service_account` keys) are not part of the
allowlist surface. They surface on `Claims.Extras`; read individual values
with `TokenInfo.Extra(key)` and apply your own logic in the handler when
needed. Note that OIDC standard claims the SDK does not name explicitly
(for example `email`, `name`) will also land in Extras if the issuer
emits them.

Allowlist mismatches return `401 invalid_token` (generic) so the allowlist
itself is not probeable. The full reason is logged server-side via the
Expand Down
9 changes: 5 additions & 4 deletions jwksauth/access_rule.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import (
)

// AccessRule is a per-route policy: the OAuth scopes the caller must hold
// plus optional allowlists for the domain / service_account / project
// claims AuthGate may emit. Filtering on the optional sub-room Tenant claim
// is intentionally out of scope at the rule level — Domains are the
// allowlist dimension.
// plus optional allowlists for the three server-attested private claims
// (Domain, ServiceAccount, Project) that AuthGate emits under the
// configured prefix. Caller-supplied keys surfaced via [Claims.Extras] do
// not participate in AccessRule comparisons; if you need to filter on a
// custom dimension, read it from Extras and check it in your handler.
//
// Semantics:
// - An empty slice means "this dimension is not checked".
Expand Down
43 changes: 22 additions & 21 deletions jwksauth/access_rule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ package jwksauth

import "testing"

func newInfo(domain, tenant, sa, project string) *TokenInfo {
func newInfo(domain, sa, project string) *TokenInfo {
return &TokenInfo{
Claims: Claims{
Domain: domain,
Tenant: tenant,
ServiceAccount: sa,
Project: project,
},
Expand All @@ -15,62 +14,64 @@ func newInfo(domain, tenant, sa, project string) *TokenInfo {

func TestAccessRule_EmptyAllowsAll(t *testing.T) {
rule := AccessRule{}.canonical()
if reason, ok := rule.checkClaims(newInfo("", "", "", "")); !ok {
if reason, ok := rule.checkClaims(newInfo("", "", "")); !ok {
t.Errorf("empty rule should accept; reason=%q", reason)
}
}

func TestAccessRule_DomainAllowlistCaseInsensitive(t *testing.T) {
rule := AccessRule{Domains: []string{"OA", "HwRd"}}.canonical()

if _, ok := rule.checkClaims(newInfo("oa", "", "", "")); !ok {
if _, ok := rule.checkClaims(newInfo("oa", "", "")); !ok {
t.Error("domain=oa should match (input was OA)")
}
if _, ok := rule.checkClaims(newInfo("HWRD", "", "", "")); !ok {
if _, ok := rule.checkClaims(newInfo("HWRD", "", "")); !ok {
t.Error("domain=HWRD should match (input was HwRd)")
}
if _, ok := rule.checkClaims(newInfo("swrd", "", "", "")); ok {
if _, ok := rule.checkClaims(newInfo("swrd", "", "")); ok {
t.Error("domain=swrd should not match")
}
}

func TestAccessRule_FailClosedOnMissingClaim(t *testing.T) {
rule := AccessRule{Domains: []string{"oa"}}.canonical()
if _, ok := rule.checkClaims(newInfo("", "", "", "")); ok {
if _, ok := rule.checkClaims(newInfo("", "", "")); ok {
t.Error("missing domain should be rejected when allowlist is set")
}
}

// TestAccessRule_TenantNotFiltered pins the contract that the optional
// sub-room Tenant claim is intentionally not part of AccessRule. A token
// with Domain in the allowlist passes regardless of whether it carries a
// Tenant value.
func TestAccessRule_TenantNotFiltered(t *testing.T) {
// TestAccessRule_ExtrasNotFiltered pins the contract that caller-supplied
// keys surfaced via Claims.Extras are intentionally not part of AccessRule.
// A token with Domain in the allowlist passes regardless of which keys
// it carries in Extras.
func TestAccessRule_ExtrasNotFiltered(t *testing.T) {
rule := AccessRule{Domains: []string{"oa"}}.canonical()
if _, ok := rule.checkClaims(newInfo("oa", "a76", "", "")); !ok {
t.Error("Domain match with Tenant present should accept")
withExtras := newInfo("oa", "", "")
withExtras.Claims.Extras = map[string]any{"tenant": "a76"}
if _, ok := rule.checkClaims(withExtras); !ok {
t.Error("Domain match with Extras present should accept")
}
if _, ok := rule.checkClaims(newInfo("oa", "", "", "")); !ok {
t.Error("Domain match with Tenant absent should accept")
if _, ok := rule.checkClaims(newInfo("oa", "", "")); !ok {
t.Error("Domain match with Extras absent should accept")
}
}

func TestAccessRule_ServiceAccountExactMatch(t *testing.T) {
rule := AccessRule{ServiceAccounts: []string{"sync-bot@oa.local"}}.canonical()
if _, ok := rule.checkClaims(newInfo("", "", "sync-bot@oa.local", "")); !ok {
if _, ok := rule.checkClaims(newInfo("", "sync-bot@oa.local", "")); !ok {
t.Error("exact match should accept")
}
if _, ok := rule.checkClaims(newInfo("", "", "SYNC-BOT@OA.LOCAL", "")); ok {
if _, ok := rule.checkClaims(newInfo("", "SYNC-BOT@OA.LOCAL", "")); ok {
t.Error("ServiceAccounts must be case-sensitive")
}
}

func TestAccessRule_ProjectAllowlist(t *testing.T) {
rule := AccessRule{Projects: []string{"admin-tools"}}.canonical()
if _, ok := rule.checkClaims(newInfo("", "", "", "admin-tools")); !ok {
if _, ok := rule.checkClaims(newInfo("", "", "admin-tools")); !ok {
t.Error("project should match")
}
if _, ok := rule.checkClaims(newInfo("", "", "", "other")); ok {
if _, ok := rule.checkClaims(newInfo("", "", "other")); ok {
t.Error("project should not match")
}
}
Expand Down Expand Up @@ -104,7 +105,7 @@ func TestAccessRule_CanonicalDropsEmpty(t *testing.T) {
}

// Token with missing claims must NOT pass the allowlists.
if _, ok := rule.checkClaims(newInfo("", "", "", "")); ok {
if _, ok := rule.checkClaims(newInfo("", "", "")); ok {
t.Error("missing claims passed allowlists — fail-closed broken")
}
}
Expand Down
Loading
Loading