|
| 1 | +# Distributed cache client with OIDC auth |
| 2 | + |
| 3 | +A runnable example showing a backend service connecting to a `hypercache-server` cluster, authenticating via |
| 4 | +OIDC client credentials, and exercising the full client API. |
| 5 | + |
| 6 | +This is a **service-to-service** flow (no browser, no user redirect). The application proves its identity to |
| 7 | +the IdP with a client ID and secret, gets back a short-lived JWT, and presents that JWT to the cache. The |
| 8 | +cache validates the JWT against the same IdP and resolves the caller's identity + scopes. The same model fits |
| 9 | +Keycloak, Auth0, Okta, Entra ID, Google, and any RFC 6749 §4.4 compliant IdP. |
| 10 | + |
| 11 | +## What the example does |
| 12 | + |
| 13 | +1. Discovers the IdP's token endpoint from `$OIDC_ISSUER/.well-known/openid-configuration`. |
| 14 | +1. Uses `golang.org/x/oauth2/clientcredentials` to exchange the client ID + secret for an access token. The |
| 15 | + library caches the token in memory and transparently refreshes it before expiry — every cache call below is |
| 16 | + a plain `http.Client.Do` with no header bookkeeping. |
| 17 | +1. Hits `GET /v1/me` to verify the bound identity + scopes (canary: "is my token actually valid against this |
| 18 | + cluster?"). |
| 19 | +1. Exercises `PUT /v1/cache/:key`, `GET` with raw bytes, `GET` with the JSON envelope (metadata view), |
| 20 | + `DELETE`, and `POST /v1/cache/batch/put`. |
| 21 | + |
| 22 | +## Requirements |
| 23 | + |
| 24 | +- Go 1.26+ (see [`go.mod`](../../go.mod)) |
| 25 | +- A reachable `hypercache-server` running with OIDC enabled (i.e. `HYPERCACHE_OIDC_ISSUER` and |
| 26 | + `HYPERCACHE_OIDC_AUDIENCE` set on the server). See |
| 27 | + [`cmd/hypercache-server/README.md`](../../cmd/hypercache-server/README.md). |
| 28 | +- An OIDC client registered in your IdP with -. The **client_credentials** grant type enabled. -. A scope (or |
| 29 | + audience claim mapper) that produces the scopes the cache expects — see [Scope mapping](#scope-mapping) |
| 30 | + below. |
| 31 | + |
| 32 | +## Environment variables |
| 33 | + |
| 34 | +| Variable | Required | Default | Description | |
| 35 | +| --------------------- | -------- | ----------------------- | ------------------------------------------------------------------------ | |
| 36 | +| `HYPERCACHE_ENDPOINT` | no | `http://localhost:8080` | Cache server base URL (client API port). | |
| 37 | +| `OIDC_ISSUER` | **yes** | — | IdP base URL (no trailing `/.well-known`). | |
| 38 | +| `OIDC_AUDIENCE` | **yes** | — | Must match the server's `HYPERCACHE_OIDC_AUDIENCE`. | |
| 39 | +| `OIDC_CLIENT_ID` | **yes** | — | OAuth2 client ID registered for this service in the IdP. | |
| 40 | +| `OIDC_CLIENT_SECRET` | **yes** | — | OAuth2 client secret. Treat as a secret — never commit. | |
| 41 | +| `OIDC_SCOPES` | no | `openid` | Space-separated scope list. See [Scope mapping](#scope-mapping). | |
| 42 | +| `OIDC_TOKEN_INSECURE` | no | `0` | Set to `1` to skip TLS verification on the token endpoint. **Dev only.** | |
| 43 | + |
| 44 | +## Run |
| 45 | + |
| 46 | +```sh |
| 47 | +export HYPERCACHE_ENDPOINT=https://cache.example.com:8080 |
| 48 | +export OIDC_ISSUER=https://keycloak.example.com/realms/cache |
| 49 | +export OIDC_AUDIENCE=hypercache-cluster |
| 50 | +export OIDC_CLIENT_ID=my-service |
| 51 | +export OIDC_CLIENT_SECRET=... |
| 52 | +export OIDC_SCOPES="openid cache:read cache:write" |
| 53 | + |
| 54 | +go run ./__examples/distributed-oidc-client/ |
| 55 | +``` |
| 56 | + |
| 57 | +Expected output: |
| 58 | + |
| 59 | +```text |
| 60 | +== /v1/me (verify bound identity) == |
| 61 | + resolved identity: my-service |
| 62 | + granted scopes: [read write] |
| 63 | +
|
| 64 | +== PUT /v1/cache/example-key (5 min TTL) == |
| 65 | + stored |
| 66 | +
|
| 67 | +== GET /v1/cache/example-key (raw bytes) == |
| 68 | + value: "hello from oidc client" |
| 69 | +
|
| 70 | +== GET /v1/cache/example-key (JSON envelope) == |
| 71 | + key: example-key |
| 72 | + version: 1 |
| 73 | + owners: [cache-0 cache-1 cache-2] |
| 74 | + encoding: base64 |
| 75 | +
|
| 76 | +== DELETE /v1/cache/example-key == |
| 77 | + deleted |
| 78 | +
|
| 79 | +== batch PUT /v1/cache/batch/put (3 keys) == |
| 80 | + stored 3 keys |
| 81 | +``` |
| 82 | + |
| 83 | +## Scope mapping |
| 84 | + |
| 85 | +The cache treats scopes as a closed set: `read`, `write`, `admin`. Your IdP's scope/claim values must map to |
| 86 | +those three strings for the cache to grant access. |
| 87 | + |
| 88 | +Two configuration knobs on the server control this mapping: |
| 89 | + |
| 90 | +- `HYPERCACHE_OIDC_SCOPE_CLAIM` (default `scope`) — which JWT claim to read. Standard OAuth2 uses `scope` |
| 91 | + (space-separated string); some IdPs use a custom array claim like `cache_scopes`. |
| 92 | +- The values inside that claim must be exactly `read`, `write`, or `admin`. Anything else is dropped silently. |
| 93 | + |
| 94 | +**Pattern 1: OAuth2 standard scopes.** Register scopes `read` and `write` in your IdP, grant them to the |
| 95 | +service client, and request them via `OIDC_SCOPES="openid read write"`. The cache reads them from the standard |
| 96 | +`scope` claim. |
| 97 | + |
| 98 | +**Pattern 2: Mapped scopes** (when your IdP namespaces scopes, e.g. `cache:read`). Use the IdP's claim-mapper |
| 99 | +feature to project the `cache:read` scope into a custom claim, then set |
| 100 | +`HYPERCACHE_OIDC_SCOPE_CLAIM=cache_scopes` server-side. Map `cache:read` → `read`, `cache:write` → `write` at |
| 101 | +the mapper level. |
| 102 | + |
| 103 | +**Pattern 3: Role-based.** Map IdP roles (e.g. Keycloak realm roles `cache-reader`, `cache-writer`) into the |
| 104 | +custom claim. Same shape as Pattern 2. |
| 105 | + |
| 106 | +## Coexistence with static bearer tokens |
| 107 | + |
| 108 | +A cluster can run with both OIDC verification and static bearer tokens configured at the same time. The auth |
| 109 | +chain resolves in this order: |
| 110 | + |
| 111 | +1. `Authorization: Bearer <token>` matched against `HYPERCACHE_AUTH_CONFIG`'s `tokens` list → static identity. |
| 112 | +1. If no static match, the OIDC verifier runs → OIDC identity. |
| 113 | +1. If neither matches and `AllowAnonymous: true`, request runs as anonymous. Otherwise 401. |
| 114 | + |
| 115 | +This means a single deployment can have humans signing in via OIDC (through the monitor's redirect flow) and |
| 116 | +machine integrations using long-lived static bearers — both succeed against the same cache. See |
| 117 | +[`pkg/httpauth/policy.go`](../../pkg/httpauth/policy.go) for the implementation. |
| 118 | + |
| 119 | +## Production caveats |
| 120 | + |
| 121 | +This example is intentionally minimal. For real services: |
| 122 | + |
| 123 | +- **Pool HTTP connections.** `oauth2.NewClient` returns a fresh `http.Client`; in production you want a |
| 124 | + configured `Transport` with `MaxIdleConnsPerHost`, keepalives, and connection-level timeouts. |
| 125 | +- **Retry policy.** This example does no retries; transient 5xx or network errors surface as failures. Wrap |
| 126 | + the cache calls in a bounded exponential-backoff retry for production. |
| 127 | +- **Observability.** The cache emits OpenTelemetry traces if the server is configured with a tracer provider; |
| 128 | + propagate the trace context by setting `traceparent` headers on your requests. |
| 129 | +- **Token caching across processes.** `clientcredentials` caches tokens per-process. If your service spawns |
| 130 | + many short-lived workers, consider a shared cache (e.g. Redis-backed) to avoid re-hitting the IdP on every |
| 131 | + process start. |
| 132 | + |
| 133 | +## See also |
| 134 | + |
| 135 | +- [`docs/auth.md`](../../docs/auth.md) — server-side auth surface (when present; otherwise |
| 136 | + [`cmd/hypercache-server/README.md`](../../cmd/hypercache-server/README.md) is the current source of truth). |
| 137 | +- [`docs/oncall.md`](../../docs/oncall.md#auth-failures) — debugging 401/403s when the client surface is |
| 138 | + misbehaving. |
| 139 | +- [`cmd/hypercache-server/oidc.go`](../../cmd/hypercache-server/oidc.go) — the cache's OIDC verifier closure, |
| 140 | + for reference on what's enforced server-side. |
0 commit comments