diff --git a/charts/ncps/templates/configmap.yaml b/charts/ncps/templates/configmap.yaml
index 83391018..d4bcec03 100644
--- a/charts/ncps/templates/configmap.yaml
+++ b/charts/ncps/templates/configmap.yaml
@@ -51,6 +51,24 @@ data:
allow-delete-verb: true
{{- end }}
+ {{- if .Values.config.oidc.policies }}
+ oidc:
+ policies:
+ {{- range .Values.config.oidc.policies }}
+ - issuer: {{ .issuer | quote }}
+ audience: {{ .audience | quote }}
+ {{- if .claims }}
+ claims:
+ {{- range $key, $values := .claims }}
+ {{ $key }}:
+ {{- range $values }}
+ - {{ . | quote }}
+ {{- end }}
+ {{- end }}
+ {{- end }}
+ {{- end }}
+ {{- end }}
+
sign-narinfo: {{ ne .Values.config.signing.enabled false }}
upstream:
diff --git a/charts/ncps/tests/configmap_test.yaml b/charts/ncps/tests/configmap_test.yaml
index d5b11e94..1f47973e 100644
--- a/charts/ncps/tests/configmap_test.yaml
+++ b/charts/ncps/tests/configmap_test.yaml
@@ -343,3 +343,69 @@ tests:
- matchRegex:
path: data["config.yaml"]
pattern: 'allow-degraded-mode: true'
+
+ - it: should not render OIDC section when no policies configured
+ set:
+ config.hostname: test.example.com
+ config.oidc.policies: []
+ asserts:
+ - notMatchRegex:
+ path: data["config.yaml"]
+ pattern: 'oidc:'
+
+ - it: should render OIDC section with single provider
+ set:
+ config.hostname: test.example.com
+ config.oidc.policies:
+ - issuer: "https://token.actions.githubusercontent.com"
+ audience: "ncps.example.com"
+ asserts:
+ - matchRegex:
+ path: data["config.yaml"]
+ pattern: 'oidc:'
+ - matchRegex:
+ path: data["config.yaml"]
+ pattern: 'issuer: "https://token.actions.githubusercontent.com"'
+ - matchRegex:
+ path: data["config.yaml"]
+ pattern: 'audience: "ncps.example.com"'
+
+ - it: should render OIDC section with multiple policies and claims
+ set:
+ config.hostname: test.example.com
+ config.oidc.policies:
+ - issuer: "https://token.actions.githubusercontent.com"
+ audience: "ncps.example.com"
+ claims:
+ sub:
+ - "repo:myorg/*"
+ ref:
+ - "refs/heads/main"
+ - "refs/tags/*"
+ - issuer: "https://gitlab.example.com"
+ audience: "ncps.internal"
+ asserts:
+ - matchRegex:
+ path: data["config.yaml"]
+ pattern: 'issuer: "https://token.actions.githubusercontent.com"'
+ - matchRegex:
+ path: data["config.yaml"]
+ pattern: 'issuer: "https://gitlab.example.com"'
+ - matchRegex:
+ path: data["config.yaml"]
+ pattern: 'claims:'
+ - matchRegex:
+ path: data["config.yaml"]
+ pattern: 'sub:'
+ - matchRegex:
+ path: data["config.yaml"]
+ pattern: '- "repo:myorg/\*"'
+ - matchRegex:
+ path: data["config.yaml"]
+ pattern: 'ref:'
+ - matchRegex:
+ path: data["config.yaml"]
+ pattern: '- "refs/heads/main"'
+ - matchRegex:
+ path: data["config.yaml"]
+ pattern: '- "refs/tags/\*"'
diff --git a/charts/ncps/values.yaml b/charts/ncps/values.yaml
index 0078b2be..aeac5698 100644
--- a/charts/ncps/values.yaml
+++ b/charts/ncps/values.yaml
@@ -98,6 +98,29 @@ config:
# Allow DELETE verb to delete narInfo and nar files
allowDelete: false
+ # OIDC push authorization (optional)
+ # When configured, PUT and DELETE requests require a valid OIDC Bearer token.
+ # Useful for securing push access from CI/CD systems (GitHub Actions, GitLab CI).
+ # If not configured, existing behavior is preserved.
+ #
+ # Each provider requires an issuer (for OIDC discovery) and audience (must
+ # match the token's aud claim). The optional claims field restricts access
+ # based on token claim values using glob patterns (* matches any characters).
+ # Within a claim key, values are ORed. Across claim keys, conditions are ANDed.
+ oidc:
+ policies: []
+ # Example:
+ # - issuer: "https://token.actions.githubusercontent.com"
+ # audience: "ncps.example.com"
+ # claims:
+ # sub:
+ # - "repo:myorg/*"
+ # ref:
+ # - "refs/heads/main"
+ # - "refs/tags/*"
+ # - issuer: "https://gitlab.example.com"
+ # audience: "ncps.internal"
+
# NAR info signing
signing:
# Whether to sign narInfo files
diff --git a/config.example.yaml b/config.example.yaml
index bf73ea8b..cba66d8c 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -32,6 +32,28 @@ cache:
allow-delete-verb: true
# Whether to allow the PUT verb to push narInfo and nar files directly
allow-put-verb: true
+ # OIDC push authorization (optional)
+ # When configured, PUT and DELETE requests require a valid OIDC Bearer token.
+ # Useful for securing push access from CI/CD systems (GitHub Actions, GitLab CI)
+ # that support native OIDC tokens - no stored secrets needed.
+ # If not configured, existing behavior is preserved (no token required).
+ #
+ # Each provider requires an issuer (for OIDC discovery) and audience (must
+ # match the token's aud claim). The optional claims field restricts access
+ # based on token claim values using glob patterns (* matches any characters).
+ # Within a claim key, values are ORed. Across claim keys, conditions are ANDed.
+ # oidc:
+ # policies:
+ # - issuer: "https://token.actions.githubusercontent.com"
+ # audience: "ncps.mycompany.tld"
+ # claims:
+ # sub:
+ # - "repo:myorg/*"
+ # ref:
+ # - "refs/heads/main"
+ # - "refs/tags/*"
+ # - issuer: "https://gitlab.internal.company.com"
+ # audience: "ncps.internal"
# The hostname of the cache server
hostname: "ncps.mycompany.tld"
# Download configuration
diff --git a/docs/docs/User Guide.md b/docs/docs/User Guide.md
index 9ddc26c0..9cad19f2 100644
--- a/docs/docs/User Guide.md
+++ b/docs/docs/User Guide.md
@@ -46,6 +46,7 @@ Welcome to the comprehensive documentation for ncps (Nix Cache Proxy Server). Th
**Features**
- [Content-Defined Chunking (CDC)](User%20Guide/Features/CDC.md)
+- [OIDC Push Authorization](User%20Guide/Features/OIDC%20Push%20Authorization.md)
**Deploy for Production**
diff --git a/docs/docs/User Guide/Configuration/Reference.md b/docs/docs/User Guide/Configuration/Reference.md
index b4bfbb0a..23f122ba 100644
--- a/docs/docs/User Guide/Configuration/Reference.md
+++ b/docs/docs/User Guide/Configuration/Reference.md
@@ -201,6 +201,34 @@ ncps serve \
--netrc-file=/etc/ncps/netrc
```
+## OIDC Push Authorization
+
+OIDC token validation for securing PUT and DELETE requests. Configured in the config file only (not via CLI flags). If no policies are configured, existing behavior is preserved.
+
+```yaml
+cache:
+ oidc:
+ policies:
+ - issuer: "https://token.actions.githubusercontent.com"
+ audience: "ncps.example.com"
+ claims:
+ sub:
+ - "repo:myorg/*"
+```
+
+Each policy requires:
+
+- **`issuer`** — The OIDC provider URL (used for discovery)
+- **`audience`** — Must match the token's `aud` claim
+
+Optional:
+
+- **`claims`** — Map of claim names to glob patterns. Within a key, patterns are ORed. Across keys, conditions are ANDed.
+
+Accepts both `Bearer` and `Basic` auth headers (JWT in password field for netrc compatibility).
+
+See OIDC Push Authorization for details.
+
## Upstream Connection Timeouts
Configure timeout values for upstream cache connections. Increase these if experiencing timeout errors with slow or remote upstreams.
@@ -484,5 +512,6 @@ See [config.example.yaml](https://github.com/kalbasit/ncps/blob/main/config.exam
- Storage - Storage backend details
- Database - Database backend details
- Observability - Monitoring and logging
+- OIDC Push Authorization - Securing push access with OIDC tokens
- High Availability - HA configuration
- Distributed Locking - Lock tuning
diff --git a/docs/docs/User Guide/Features/OIDC Push Authorization.md b/docs/docs/User Guide/Features/OIDC Push Authorization.md
new file mode 100644
index 00000000..7183d103
--- /dev/null
+++ b/docs/docs/User Guide/Features/OIDC Push Authorization.md
@@ -0,0 +1,229 @@
+# OIDC Push Authorization
+
+## Overview
+
+OIDC Push Authorization secures PUT and DELETE requests to your ncps cache using OpenID Connect tokens. This lets CI/CD systems like GitHub Actions and GitLab CI push to the cache using their native OIDC tokens — no stored secrets needed.
+
+When configured, ncps validates the JWT token against the provider's published signing keys (via OIDC discovery) and optionally checks claim values against glob patterns. GET and HEAD requests are never affected.
+
+If no OIDC policies are configured, existing behavior is preserved — PUT and DELETE are controlled solely by the `allow-put-verb` and `allow-delete-verb` flags.
+
+## How It Works
+
+1. **OIDC Discovery**: At startup, ncps contacts each configured issuer's `/.well-known/openid-configuration` endpoint to discover the JSON Web Key Set (JWKS) used for token verification. JWKS are cached and refreshed automatically.
+1. **Token Extraction**: The middleware extracts the JWT from the `Authorization` header. Both `Bearer ` and `Basic ` (with the JWT in the password field) are supported. Basic auth support enables tools like `nix copy` to authenticate via netrc files.
+1. **Signature Verification**: The JWT is verified against each configured policy's issuer and audience until one matches.
+1. **Claims Matching**: If the matching policy has `claims` configured, the token's claim values are checked against the required patterns. All claim keys must match (AND), and within a key, any pattern can match (OR).
+1. **Authorization**: On success, the verified claims are stored in the request context and the request proceeds. On failure, the middleware returns 401 (invalid/missing token) or 403 (valid token but claims don't match).
+
+## Configuration
+
+OIDC policies are configured in the `cache.oidc` section of your configuration file. Each policy requires an `issuer` (the OIDC provider URL) and an `audience` (which must match the token's `aud` claim). An optional `claims` map restricts access based on token claim values.
+
+### Basic Configuration
+
+A minimal configuration with a single GitHub Actions policy:
+
+```yaml
+cache:
+ allow-put-verb: true
+ oidc:
+ policies:
+ - issuer: "https://token.actions.githubusercontent.com"
+ audience: "ncps.example.com"
+```
+
+### Claims Matching
+
+The `claims` field lets you restrict which tokens are authorized based on their claim values. Each key is a JWT claim name, and each value is a list of glob patterns.
+
+- **Within a claim key**, patterns are ORed — any pattern matching grants access for that key.
+- **Across claim keys**, conditions are ANDed — all keys must match.
+- **Across policies**, the first matching policy wins.
+
+```yaml
+cache:
+ allow-put-verb: true
+ oidc:
+ policies:
+ - issuer: "https://token.actions.githubusercontent.com"
+ audience: "ncps.example.com"
+ claims:
+ sub:
+ - "repo:myorg/*"
+ ref:
+ - "refs/heads/main"
+ - "refs/tags/*"
+```
+
+This policy allows pushes from any repository in `myorg`, but only from the `main` branch or any tag.
+
+### Glob Patterns
+
+The `*` wildcard matches any sequence of characters, including `/` and the empty string. This is intentionally simpler than filesystem globbing to work naturally with claim values like `repo:org/repo:ref:refs/heads/main`.
+
+| Pattern | Matches | Doesn't Match |
+| --- | --- | --- |
+| `repo:myorg/*` | `repo:myorg/foo`, `repo:myorg/foo/bar` | `repo:other/foo` |
+| `refs/heads/*` | `refs/heads/main`, `refs/heads/feature/x` | `refs/tags/v1.0` |
+| `*` | anything | (matches everything) |
+| `repo:myorg/myrepo` | `repo:myorg/myrepo` (exact) | `repo:myorg/other` |
+
+### Multiple Policies
+
+You can configure multiple policies. The first policy whose issuer and audience match the token is used for verification:
+
+```yaml
+cache:
+ allow-put-verb: true
+ oidc:
+ policies:
+ - issuer: "https://token.actions.githubusercontent.com"
+ audience: "ncps.example.com"
+ claims:
+ sub:
+ - "repo:myorg/*"
+ - issuer: "https://gitlab.example.com"
+ audience: "ncps.internal"
+```
+
+## GitHub Actions Example
+
+### Server Configuration
+
+```yaml
+cache:
+ hostname: "ncps.example.com"
+ allow-put-verb: true
+ oidc:
+ policies:
+ - issuer: "https://token.actions.githubusercontent.com"
+ audience: "ncps.example.com"
+ claims:
+ sub:
+ - "repo:myorg/*"
+```
+
+### Workflow
+
+The workflow requests an OIDC token with your cache hostname as the audience, writes it to a netrc file, and uses `nix copy` to push:
+
+```yaml
+name: Build and Push to Cache
+on:
+ push:
+ branches: [main]
+
+permissions:
+ id-token: write
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: cachix/install-nix-action@v30
+
+ - name: Build
+ run: nix build .#default
+
+ - name: Push to cache
+ run: |
+ TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
+ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=ncps.example.com" | jq -r '.value')
+
+ echo "machine ncps.example.com password $TOKEN" > ~/.netrc
+
+ nix copy --to https://ncps.example.com ./result
+```
+
+The `permissions.id-token: write` setting is required for the workflow to request OIDC tokens. The `audience` parameter must match the `audience` configured in your ncps policy.
+
+## Authentication Methods
+
+The middleware accepts JWTs in two forms:
+
+**Bearer token** (standard):
+
+```
+Authorization: Bearer
+```
+
+**Basic auth** (for netrc compatibility):
+
+```
+Authorization: Basic
+```
+
+The username is ignored — only the password (JWT) is validated. This makes ncps compatible with tools like `nix copy` that use netrc files for authentication, since netrc produces Basic auth headers.
+
+### Netrc Format
+
+```
+machine ncps.example.com password
+```
+
+Or with an explicit login:
+
+```
+machine ncps.example.com login bearer password
+```
+
+Both formats work. The login value is ignored by ncps.
+
+## Helm Chart Configuration
+
+```yaml
+config:
+ permissions:
+ allowPut: true
+ oidc:
+ policies:
+ - issuer: "https://token.actions.githubusercontent.com"
+ audience: "ncps.example.com"
+ claims:
+ sub:
+ - "repo:myorg/*"
+```
+
+See Helm Chart for full chart documentation.
+
+## HTTP Response Codes
+
+| Status | Meaning |
+| --- | --- |
+| 401 | Missing or invalid token (expired, wrong signature, wrong audience) |
+| 403 | Valid token but claims don't match any allowed pattern |
+| 405 | PUT/DELETE not enabled (`allow-put-verb` / `allow-delete-verb` is false) |
+
+## Troubleshooting
+
+**401 "missing authorization header"**
+
+The request has no `Authorization` header. Ensure your client is sending the token. For `nix copy`, verify the netrc file is in the right location and has the correct machine name.
+
+**401 "token validation failed"**
+
+The JWT couldn't be verified by any configured policy. Common causes:
+
+- The token's audience doesn't match the policy's `audience`
+- The token's issuer doesn't match the policy's `issuer`
+- The token has expired
+- The issuer's JWKS endpoint is unreachable
+
+**403 "claim mismatch"**
+
+The token was verified successfully, but its claims don't match the required patterns. Check the `claims` section of your policy and the actual values in your token. You can decode a JWT at [jwt.io](https://jwt.io) to inspect its claims.
+
+**Startup error: "initializing OIDC policy"**
+
+ncps couldn't reach the issuer's OIDC discovery endpoint at startup. Verify the issuer URL is correct and reachable from the server.
+
+## Related Documentation
+
+- Configuration Reference
+- Helm Chart
+- Single Instance Deployment
+- High Availability Setup
diff --git a/go.mod b/go.mod
index 67e9191f..3d9fb5e9 100644
--- a/go.mod
+++ b/go.mod
@@ -3,9 +3,12 @@ module github.com/kalbasit/ncps
go 1.25.6
require (
+ github.com/BurntSushi/toml v1.6.0
github.com/XSAM/otelsql v0.41.0
github.com/andybalholm/brotli v1.2.0
+ github.com/coreos/go-oidc/v3 v3.17.0
github.com/go-chi/chi/v5 v5.2.5
+ github.com/go-jose/go-jose/v4 v4.1.3
github.com/go-redsync/redsync/v4 v4.15.0
github.com/go-sql-driver/mysql v1.9.3
github.com/google/uuid v1.6.0
@@ -47,13 +50,13 @@ require (
go.opentelemetry.io/otel/sdk/metric v1.40.0
go.opentelemetry.io/otel/trace v1.40.0
go.uber.org/automaxprocs v1.6.0
+ go.yaml.in/yaml/v3 v3.0.4
golang.org/x/sync v0.19.0
golang.org/x/term v0.40.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
- github.com/BurntSushi/toml v1.6.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -90,9 +93,9 @@ require (
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
- go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
+ golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
diff --git a/go.sum b/go.sum
index 090dd77f..cbef17e0 100644
--- a/go.sum
+++ b/go.sum
@@ -16,6 +16,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
+github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -30,6 +32,8 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
+github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
+github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -225,6 +229,8 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
+golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
+golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/pkg/ncps/serve.go b/pkg/ncps/serve.go
index 817f270e..0b10c622 100644
--- a/pkg/ncps/serve.go
+++ b/pkg/ncps/serve.go
@@ -10,6 +10,7 @@ import (
"os"
"path/filepath"
"regexp"
+ "strings"
"time"
"github.com/google/uuid"
@@ -37,6 +38,7 @@ import (
"github.com/kalbasit/ncps/pkg/lock/postgres"
"github.com/kalbasit/ncps/pkg/lock/redis"
"github.com/kalbasit/ncps/pkg/maxprocs"
+ "github.com/kalbasit/ncps/pkg/oidc"
"github.com/kalbasit/ncps/pkg/otel"
"github.com/kalbasit/ncps/pkg/prometheus"
"github.com/kalbasit/ncps/pkg/server"
@@ -582,7 +584,28 @@ func serveAction(registerShutdown registerShutdownFn) cli.ActionFunc {
analyticsReporter.GetLogger().Emit(ctx, record)
- srv := server.New(cache)
+ var serverOpts []server.Option
+
+ if oidcCfg, err := parseOIDCConfig(cmd.Root().String("config")); err != nil {
+ logger.Error().Err(err).Msg("error parsing OIDC config")
+
+ return fmt.Errorf("error parsing OIDC config: %w", err)
+ } else if oidcCfg != nil && len(oidcCfg.Policies) > 0 {
+ v, err := oidc.New(ctx, oidcCfg)
+ if err != nil {
+ logger.Error().Err(err).Msg("error initializing OIDC policies")
+
+ return fmt.Errorf("error initializing OIDC policies: %w", err)
+ }
+
+ serverOpts = append(serverOpts, server.WithOIDCVerifier(v))
+
+ logger.Info().
+ Int("policy_count", len(oidcCfg.Policies)).
+ Msg("OIDC push authorization enabled")
+ }
+
+ srv := server.New(cache, serverOpts...)
srv.SetDeletePermitted(cmd.Bool("cache-allow-delete-verb"))
srv.SetPutPermitted(cmd.Bool("cache-allow-put-verb"))
@@ -1197,3 +1220,36 @@ func getLockers(
return locker, rwLocker, nil
}
+
+// parseOIDCConfig reads the config file once and extracts the OIDC section.
+// Returns nil, nil if the path is empty, the file doesn't exist, or no OIDC
+// section is present (backwards-compatible).
+func parseOIDCConfig(configFilePath string) (*oidc.Config, error) {
+ if configFilePath == "" {
+ return nil, nil //nolint:nilnil
+ }
+
+ data, err := os.ReadFile(configFilePath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil //nolint:nilnil
+ }
+
+ return nil, fmt.Errorf("reading config file: %w", err)
+ }
+
+ var format string
+
+ switch strings.ToLower(filepath.Ext(configFilePath)) {
+ case ".yaml", ".yml":
+ format = "yaml"
+ case ".toml":
+ format = "toml"
+ case ".json":
+ format = "json"
+ default:
+ format = "yaml" // default to YAML, matching urfave/cli-altsrc behavior
+ }
+
+ return oidc.ParseConfigData(data, format)
+}
diff --git a/pkg/oidc/config.go b/pkg/oidc/config.go
new file mode 100644
index 00000000..686500f7
--- /dev/null
+++ b/pkg/oidc/config.go
@@ -0,0 +1,94 @@
+package oidc
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/BurntSushi/toml"
+ "go.yaml.in/yaml/v3"
+)
+
+var (
+ // ErrMissingIssuer is returned when a policy config has no issuer.
+ ErrMissingIssuer = errors.New("oidc policy must have a non-empty issuer")
+
+ // ErrMissingAudience is returned when a policy config has no audience.
+ ErrMissingAudience = errors.New("oidc policy must have a non-empty audience")
+
+ // ErrUnsupportedFormat is returned for unsupported config format strings.
+ ErrUnsupportedFormat = errors.New("unsupported config format")
+)
+
+// PolicyConfig holds the configuration for a single OIDC authorization policy.
+type PolicyConfig struct {
+ Issuer string `json:"issuer" yaml:"issuer" toml:"issuer"`
+ Audience string `json:"audience" yaml:"audience" toml:"audience"`
+ Claims map[string][]string `json:"claims" yaml:"claims" toml:"claims"`
+}
+
+// Config holds the OIDC configuration section.
+type Config struct {
+ Policies []PolicyConfig `json:"policies" yaml:"policies" toml:"policies"`
+}
+
+// Validate checks that all policies have required fields.
+func (c *Config) Validate() error {
+ for i, p := range c.Policies {
+ if strings.TrimSpace(p.Issuer) == "" {
+ return fmt.Errorf("policy %d: %w", i, ErrMissingIssuer)
+ }
+
+ if strings.TrimSpace(p.Audience) == "" {
+ return fmt.Errorf("policy %d: %w", i, ErrMissingAudience)
+ }
+ }
+
+ return nil
+}
+
+// configFile is the intermediate struct used for parsing the config file.
+type configFile struct {
+ Cache struct {
+ OIDC *Config `json:"oidc" yaml:"oidc" toml:"oidc"`
+ } `json:"cache" yaml:"cache" toml:"cache"`
+}
+
+// ParseConfigData extracts the cache.oidc section from raw config bytes.
+// The format parameter should be "yaml", "toml", or "json".
+// Returns nil with no error if the section is absent (backwards-compatible).
+func ParseConfigData(data []byte, format string) (*Config, error) {
+ if len(data) == 0 {
+ return nil, nil //nolint:nilnil
+ }
+
+ var cf configFile
+
+ switch format {
+ case "yaml":
+ if err := yaml.Unmarshal(data, &cf); err != nil {
+ return nil, fmt.Errorf("parsing YAML config: %w", err)
+ }
+ case "toml":
+ if err := toml.Unmarshal(data, &cf); err != nil {
+ return nil, fmt.Errorf("parsing TOML config: %w", err)
+ }
+ case "json":
+ if err := json.Unmarshal(data, &cf); err != nil {
+ return nil, fmt.Errorf("parsing JSON config: %w", err)
+ }
+ default:
+ return nil, fmt.Errorf("%w: %s", ErrUnsupportedFormat, format)
+ }
+
+ if cf.Cache.OIDC == nil || len(cf.Cache.OIDC.Policies) == 0 {
+ return nil, nil //nolint:nilnil
+ }
+
+ if err := cf.Cache.OIDC.Validate(); err != nil {
+ return nil, fmt.Errorf("invalid oidc config: %w", err)
+ }
+
+ return cf.Cache.OIDC, nil
+}
diff --git a/pkg/oidc/config_test.go b/pkg/oidc/config_test.go
new file mode 100644
index 00000000..1871a585
--- /dev/null
+++ b/pkg/oidc/config_test.go
@@ -0,0 +1,179 @@
+package oidc_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/kalbasit/ncps/pkg/oidc"
+)
+
+func TestParseConfigData(t *testing.T) {
+ t.Parallel()
+
+ t.Run("empty data returns nil", func(t *testing.T) {
+ t.Parallel()
+
+ cfg, err := oidc.ParseConfigData(nil, "yaml")
+ require.NoError(t, err)
+ assert.Nil(t, cfg)
+ })
+
+ t.Run("YAML with no oidc section returns nil", func(t *testing.T) {
+ t.Parallel()
+
+ data := []byte(`cache:
+ hostname: "test.example.com"
+`)
+
+ cfg, err := oidc.ParseConfigData(data, "yaml")
+ require.NoError(t, err)
+ assert.Nil(t, cfg)
+ })
+
+ t.Run("YAML with empty policies returns nil", func(t *testing.T) {
+ t.Parallel()
+
+ data := []byte(`cache:
+ oidc:
+ policies: []
+`)
+
+ cfg, err := oidc.ParseConfigData(data, "yaml")
+ require.NoError(t, err)
+ assert.Nil(t, cfg)
+ })
+
+ t.Run("YAML with single policy", func(t *testing.T) {
+ t.Parallel()
+
+ data := []byte(`cache:
+ oidc:
+ policies:
+ - issuer: "https://token.actions.githubusercontent.com"
+ audience: "ncps.example.com"
+`)
+
+ cfg, err := oidc.ParseConfigData(data, "yaml")
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+ require.Len(t, cfg.Policies, 1)
+ assert.Equal(t, "https://token.actions.githubusercontent.com", cfg.Policies[0].Issuer)
+ assert.Equal(t, "ncps.example.com", cfg.Policies[0].Audience)
+ assert.Empty(t, cfg.Policies[0].Claims)
+ })
+
+ t.Run("YAML with multiple policies and claims", func(t *testing.T) {
+ t.Parallel()
+
+ data := []byte(`cache:
+ oidc:
+ policies:
+ - issuer: "https://token.actions.githubusercontent.com"
+ audience: "ncps.example.com"
+ claims:
+ sub:
+ - "repo:myorg/*"
+ ref:
+ - "refs/heads/main"
+ - "refs/tags/*"
+ - issuer: "https://gitlab.example.com"
+ audience: "ncps.internal"
+`)
+
+ cfg, err := oidc.ParseConfigData(data, "yaml")
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+ require.Len(t, cfg.Policies, 2)
+ assert.Equal(t, "https://token.actions.githubusercontent.com", cfg.Policies[0].Issuer)
+ assert.Equal(t, map[string][]string{
+ "sub": {"repo:myorg/*"},
+ "ref": {"refs/heads/main", "refs/tags/*"},
+ }, cfg.Policies[0].Claims)
+ assert.Equal(t, "https://gitlab.example.com", cfg.Policies[1].Issuer)
+ assert.Empty(t, cfg.Policies[1].Claims)
+ })
+
+ t.Run("JSON config", func(t *testing.T) {
+ t.Parallel()
+
+ data := []byte(`{
+ "cache": {
+ "oidc": {
+ "policies": [
+ {
+ "issuer": "https://accounts.google.com",
+ "audience": "my-app",
+ "claims": {
+ "sub": ["user@example.com"]
+ }
+ }
+ ]
+ }
+ }
+}`)
+
+ cfg, err := oidc.ParseConfigData(data, "json")
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+ require.Len(t, cfg.Policies, 1)
+ assert.Equal(t, "https://accounts.google.com", cfg.Policies[0].Issuer)
+ assert.Equal(t, map[string][]string{"sub": {"user@example.com"}}, cfg.Policies[0].Claims)
+ })
+
+ t.Run("TOML config", func(t *testing.T) {
+ t.Parallel()
+
+ data := []byte(`[cache.oidc]
+[[cache.oidc.policies]]
+issuer = "https://token.actions.githubusercontent.com"
+audience = "ncps.example.com"
+`)
+
+ cfg, err := oidc.ParseConfigData(data, "toml")
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+ require.Len(t, cfg.Policies, 1)
+ assert.Equal(t, "https://token.actions.githubusercontent.com", cfg.Policies[0].Issuer)
+ })
+
+ t.Run("unsupported format returns error", func(t *testing.T) {
+ t.Parallel()
+
+ cfg, err := oidc.ParseConfigData([]byte("[section]\nkey=value\n"), "ini")
+ require.Error(t, err)
+ assert.Nil(t, cfg)
+ assert.ErrorIs(t, err, oidc.ErrUnsupportedFormat)
+ })
+
+ t.Run("missing issuer returns error", func(t *testing.T) {
+ t.Parallel()
+
+ data := []byte(`cache:
+ oidc:
+ policies:
+ - audience: "ncps.example.com"
+`)
+
+ cfg, err := oidc.ParseConfigData(data, "yaml")
+ require.Error(t, err)
+ assert.Nil(t, cfg)
+ assert.ErrorIs(t, err, oidc.ErrMissingIssuer)
+ })
+
+ t.Run("missing audience returns error", func(t *testing.T) {
+ t.Parallel()
+
+ data := []byte(`cache:
+ oidc:
+ policies:
+ - issuer: "https://token.actions.githubusercontent.com"
+`)
+
+ cfg, err := oidc.ParseConfigData(data, "yaml")
+ require.Error(t, err)
+ assert.Nil(t, cfg)
+ assert.ErrorIs(t, err, oidc.ErrMissingAudience)
+ })
+}
diff --git a/pkg/oidc/middleware.go b/pkg/oidc/middleware.go
new file mode 100644
index 00000000..9a656ca7
--- /dev/null
+++ b/pkg/oidc/middleware.go
@@ -0,0 +1,141 @@
+package oidc
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+
+ "github.com/rs/zerolog"
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/trace"
+)
+
+const middlewareOtelPackageName = "github.com/kalbasit/ncps/pkg/oidc"
+
+//nolint:gochecknoglobals
+var middlewareTracer trace.Tracer
+
+//nolint:gochecknoinits
+func init() {
+ middlewareTracer = otel.Tracer(middlewareOtelPackageName)
+}
+
+type claimsContextKey struct{}
+
+// ClaimsFromContext retrieves the OIDC claims stored in the request context.
+func ClaimsFromContext(ctx context.Context) *Claims {
+ claims, _ := ctx.Value(claimsContextKey{}).(*Claims)
+
+ return claims
+}
+
+// Middleware returns a Chi-compatible HTTP middleware that validates OIDC tokens.
+// Accepts both Bearer tokens and Basic auth (password field used as the JWT).
+// Basic auth support allows tools like nix copy to authenticate via netrc.
+// On success, the verified Claims are stored in the request context.
+func (v *Verifier) Middleware() func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx, span := middlewareTracer.Start(
+ r.Context(),
+ "oidc.verifyToken",
+ trace.WithSpanKind(trace.SpanKindServer),
+ )
+ defer span.End()
+
+ authHeader := r.Header.Get("Authorization")
+ if authHeader == "" {
+ writeJSONError(w, http.StatusUnauthorized, "missing authorization header")
+
+ zerolog.Ctx(ctx).Warn().Msg("OIDC: missing authorization header")
+
+ return
+ }
+
+ rawToken, ok := extractToken(authHeader)
+ if !ok {
+ writeJSONError(w, http.StatusUnauthorized, "invalid authorization header format")
+
+ zerolog.Ctx(ctx).Warn().Msg("OIDC: invalid authorization header format")
+
+ return
+ }
+
+ claims, err := v.Verify(ctx, rawToken)
+ if err != nil {
+ if errors.Is(err, ErrClaimMismatch) {
+ writeJSONError(w, http.StatusForbidden, "claim mismatch")
+
+ zerolog.Ctx(ctx).Warn().Err(err).Msg("OIDC: claim mismatch")
+
+ return
+ }
+
+ writeJSONError(w, http.StatusUnauthorized, "token validation failed")
+
+ zerolog.Ctx(ctx).Warn().Err(err).Msg("OIDC: token validation failed")
+
+ return
+ }
+
+ span.SetAttributes(
+ attribute.String("oidc.issuer", claims.Issuer),
+ attribute.String("oidc.subject", claims.Subject),
+ )
+
+ zerolog.Ctx(ctx).Info().
+ Str("oidc_issuer", claims.Issuer).
+ Str("oidc_subject", claims.Subject).
+ Msg("OIDC: token verified")
+
+ ctx = context.WithValue(ctx, claimsContextKey{}, claims)
+
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
+
+// extractToken extracts the JWT from an Authorization header.
+// Supports "Bearer " and "Basic " (using the password as the JWT).
+func extractToken(authHeader string) (string, bool) {
+ parts := strings.SplitN(authHeader, " ", 2)
+ if len(parts) != 2 {
+ return "", false
+ }
+
+ scheme := parts[0]
+ credentials := parts[1]
+
+ switch {
+ case strings.EqualFold(scheme, "Bearer"):
+ return credentials, true
+
+ case strings.EqualFold(scheme, "Basic"):
+ decoded, err := base64.StdEncoding.DecodeString(credentials)
+ if err != nil {
+ return "", false
+ }
+
+ // Basic auth format is "username:password" — the JWT is in the password field.
+ _, password, ok := strings.Cut(string(decoded), ":")
+ if !ok || password == "" {
+ return "", false
+ }
+
+ return password, true
+
+ default:
+ return "", false
+ }
+}
+
+func writeJSONError(w http.ResponseWriter, code int, message string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(code)
+
+ _ = json.NewEncoder(w).Encode(map[string]string{"error": message})
+}
diff --git a/pkg/oidc/middleware_test.go b/pkg/oidc/middleware_test.go
new file mode 100644
index 00000000..024d6e68
--- /dev/null
+++ b/pkg/oidc/middleware_test.go
@@ -0,0 +1,309 @@
+package oidc_test
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/go-jose/go-jose/v4/jwt"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/kalbasit/ncps/pkg/oidc"
+)
+
+func TestMiddleware(t *testing.T) {
+ t.Parallel()
+
+ t.Run("no auth header returns 401", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, nil)
+
+ handler := v.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest(http.MethodPut, "/test", nil)
+ rec := httptest.NewRecorder()
+
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
+
+ var body map[string]string
+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&body))
+ assert.Equal(t, "missing authorization header", body["error"])
+ })
+
+ t.Run("unsupported auth scheme returns 401", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, nil)
+
+ handler := v.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest(http.MethodPut, "/test", nil)
+ req.Header.Set("Authorization", "Digest realm=\"test\"")
+
+ rec := httptest.NewRecorder()
+
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
+ })
+
+ t.Run("invalid Bearer token returns 401", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, nil)
+
+ handler := v.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest(http.MethodPut, "/test", nil)
+ req.Header.Set("Authorization", "Bearer invalid-token")
+
+ rec := httptest.NewRecorder()
+
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
+
+ var body map[string]string
+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&body))
+ assert.Equal(t, "token validation failed", body["error"])
+ })
+
+ t.Run("valid Bearer token passes through", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, nil)
+
+ var capturedClaims *oidc.Claims
+
+ handler := v.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedClaims = oidc.ClaimsFromContext(r.Context())
+
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ token := mk.issueToken(t, jwt.Claims{
+ Issuer: mk.issuer(),
+ Audience: jwt.Audience{"test-audience"},
+ Subject: "repo:org/repo:ref:refs/heads/main",
+ Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ }, nil)
+
+ req := httptest.NewRequest(http.MethodPut, "/test", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+
+ rec := httptest.NewRecorder()
+
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusOK, rec.Code)
+ require.NotNil(t, capturedClaims)
+ assert.Equal(t, "repo:org/repo:ref:refs/heads/main", capturedClaims.Subject)
+ assert.Equal(t, mk.issuer(), capturedClaims.Issuer)
+ })
+
+ t.Run("valid Basic auth token passes through", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, nil)
+
+ var capturedClaims *oidc.Claims
+
+ handler := v.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedClaims = oidc.ClaimsFromContext(r.Context())
+
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ token := mk.issueToken(t, jwt.Claims{
+ Issuer: mk.issuer(),
+ Audience: jwt.Audience{"test-audience"},
+ Subject: "repo:myorg/myrepo",
+ Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ }, nil)
+
+ // Encode as Basic auth with empty username (netrc password-only style).
+ basicCreds := base64.StdEncoding.EncodeToString([]byte(":" + token))
+
+ req := httptest.NewRequest(http.MethodPut, "/test", nil)
+ req.Header.Set("Authorization", "Basic "+basicCreds)
+
+ rec := httptest.NewRecorder()
+
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusOK, rec.Code)
+ require.NotNil(t, capturedClaims)
+ assert.Equal(t, "repo:myorg/myrepo", capturedClaims.Subject)
+ })
+
+ t.Run("Basic auth with username and token in password passes through", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, nil)
+
+ handler := v.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ token := mk.issueToken(t, jwt.Claims{
+ Issuer: mk.issuer(),
+ Audience: jwt.Audience{"test-audience"},
+ Subject: "repo:myorg/myrepo",
+ Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ }, nil)
+
+ // Encode as Basic auth with a username (netrc login+password style).
+ basicCreds := base64.StdEncoding.EncodeToString([]byte("bearer:" + token))
+
+ req := httptest.NewRequest(http.MethodPut, "/test", nil)
+ req.Header.Set("Authorization", "Basic "+basicCreds)
+
+ rec := httptest.NewRecorder()
+
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusOK, rec.Code)
+ })
+
+ t.Run("Basic auth with empty password returns 401", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, nil)
+
+ handler := v.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ basicCreds := base64.StdEncoding.EncodeToString([]byte("user:"))
+
+ req := httptest.NewRequest(http.MethodPut, "/test", nil)
+ req.Header.Set("Authorization", "Basic "+basicCreds)
+
+ rec := httptest.NewRecorder()
+
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
+ })
+
+ t.Run("Basic auth with invalid JWT in password returns 401", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, nil)
+
+ handler := v.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ basicCreds := base64.StdEncoding.EncodeToString([]byte("user:not-a-jwt"))
+
+ req := httptest.NewRequest(http.MethodPut, "/test", nil)
+ req.Header.Set("Authorization", "Basic "+basicCreds)
+
+ rec := httptest.NewRecorder()
+
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
+
+ var body map[string]string
+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&body))
+ assert.Equal(t, "token validation failed", body["error"])
+ })
+
+ t.Run("claim mismatch returns 403", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, map[string][]string{
+ "sub": {"repo:allowed-org/*"},
+ })
+
+ handler := v.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ token := mk.issueToken(t, jwt.Claims{
+ Issuer: mk.issuer(),
+ Audience: jwt.Audience{"test-audience"},
+ Subject: "repo:other-org/repo",
+ Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ }, nil)
+
+ req := httptest.NewRequest(http.MethodPut, "/test", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+
+ rec := httptest.NewRecorder()
+
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusForbidden, rec.Code)
+
+ var body map[string]string
+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&body))
+ assert.Equal(t, "claim mismatch", body["error"])
+ })
+
+ t.Run("matching claims pass through", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, map[string][]string{
+ "sub": {"repo:myorg/*"},
+ })
+
+ handler := v.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ token := mk.issueToken(t, jwt.Claims{
+ Issuer: mk.issuer(),
+ Audience: jwt.Audience{"test-audience"},
+ Subject: "repo:myorg/myrepo",
+ Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ }, nil)
+
+ req := httptest.NewRequest(http.MethodPut, "/test", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+
+ rec := httptest.NewRecorder()
+
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusOK, rec.Code)
+ })
+
+ t.Run("ClaimsFromContext returns nil when no claims", func(t *testing.T) {
+ t.Parallel()
+
+ claims := oidc.ClaimsFromContext(context.Background())
+ assert.Nil(t, claims)
+ })
+}
diff --git a/pkg/oidc/oidc.go b/pkg/oidc/oidc.go
new file mode 100644
index 00000000..4ef9a2c4
--- /dev/null
+++ b/pkg/oidc/oidc.go
@@ -0,0 +1,28 @@
+package oidc
+
+import (
+ "context"
+ "fmt"
+)
+
+// New creates a Verifier that validates tokens against all configured OIDC
+// policies. It performs OIDC discovery for each unique issuer at startup and
+// returns an error if any issuer is unreachable.
+func New(ctx context.Context, cfg *Config) (*Verifier, error) {
+ if cfg == nil || len(cfg.Policies) == 0 {
+ return nil, ErrNoPolicies
+ }
+
+ policies := make([]*policy, 0, len(cfg.Policies))
+
+ for _, pc := range cfg.Policies {
+ p, err := newPolicy(ctx, pc)
+ if err != nil {
+ return nil, fmt.Errorf("initializing OIDC policy: %w", err)
+ }
+
+ policies = append(policies, p)
+ }
+
+ return &Verifier{policies: policies}, nil
+}
diff --git a/pkg/oidc/policy.go b/pkg/oidc/policy.go
new file mode 100644
index 00000000..a73f2f3d
--- /dev/null
+++ b/pkg/oidc/policy.go
@@ -0,0 +1,32 @@
+package oidc
+
+import (
+ "context"
+ "fmt"
+
+ gooidc "github.com/coreos/go-oidc/v3/oidc"
+)
+
+// policy wraps a single OIDC authorization policy with its token verifier and config.
+type policy struct {
+ config PolicyConfig
+ verifier *gooidc.IDTokenVerifier
+}
+
+// newPolicy performs OIDC discovery for the given issuer and creates a token
+// verifier. It fails if the issuer is unreachable (startup error).
+func newPolicy(ctx context.Context, cfg PolicyConfig) (*policy, error) {
+ p, err := gooidc.NewProvider(ctx, cfg.Issuer)
+ if err != nil {
+ return nil, fmt.Errorf("OIDC discovery for issuer %q: %w", cfg.Issuer, err)
+ }
+
+ verifier := p.Verifier(&gooidc.Config{
+ ClientID: cfg.Audience,
+ })
+
+ return &policy{
+ config: cfg,
+ verifier: verifier,
+ }, nil
+}
diff --git a/pkg/oidc/verifier.go b/pkg/oidc/verifier.go
new file mode 100644
index 00000000..6c2a34d0
--- /dev/null
+++ b/pkg/oidc/verifier.go
@@ -0,0 +1,161 @@
+package oidc
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+)
+
+var (
+ // ErrNoPolicies is returned when the verifier has no policies configured.
+ ErrNoPolicies = errors.New("no OIDC policies configured")
+
+ // ErrTokenValidationFailed is returned when no provider could validate the token.
+ ErrTokenValidationFailed = errors.New("token validation failed")
+
+ // ErrClaimMismatch is returned when the token's claims do not satisfy the required patterns.
+ ErrClaimMismatch = errors.New("claim mismatch")
+)
+
+// Claims represents the verified claims from an OIDC token.
+type Claims struct {
+ Issuer string
+ Subject string
+ Audience []string
+ Extra map[string]any
+}
+
+// Verifier validates OIDC tokens against one or more policies.
+type Verifier struct {
+ policies []*policy
+}
+
+// Verify validates the raw JWT token against all configured policies.
+// The first policy that successfully verifies the token wins.
+// After signature validation, required claims are checked.
+func (v *Verifier) Verify(ctx context.Context, rawToken string) (*Claims, error) {
+ for _, p := range v.policies {
+ idToken, err := p.verifier.Verify(ctx, rawToken)
+ if err != nil {
+ continue
+ }
+
+ // Extract all claims into a map for claim matching and extras.
+ var allClaims map[string]any
+ if err := idToken.Claims(&allClaims); err != nil {
+ return nil, fmt.Errorf("extracting claims: %w", err)
+ }
+
+ // Check required claims if configured.
+ if len(p.config.Claims) > 0 {
+ if err := checkClaims(allClaims, p.config.Claims); err != nil {
+ return nil, err
+ }
+ }
+
+ return &Claims{
+ Issuer: idToken.Issuer,
+ Subject: idToken.Subject,
+ Audience: idToken.Audience,
+ Extra: allClaims,
+ }, nil
+ }
+
+ return nil, ErrTokenValidationFailed
+}
+
+// checkClaims verifies the token's claims satisfy all required patterns.
+// Each key in required must be present in the token. Within a key, at least one
+// pattern must match (OR). Across keys, all must match (AND).
+func checkClaims(tokenClaims map[string]any, required map[string][]string) error {
+ for claimKey, patterns := range required {
+ tokenVal, ok := tokenClaims[claimKey]
+ if !ok {
+ return fmt.Errorf("%w: missing claim %q", ErrClaimMismatch, claimKey)
+ }
+
+ tokenStrings := claimValueToStrings(tokenVal)
+ if !anyPatternMatches(patterns, tokenStrings) {
+ return fmt.Errorf("%w: claim %q does not match any allowed pattern", ErrClaimMismatch, claimKey)
+ }
+ }
+
+ return nil
+}
+
+// claimValueToStrings converts a claim value to a slice of strings for matching.
+// Handles string, []any (array of strings), and falls back to fmt.Sprint.
+func claimValueToStrings(val any) []string {
+ switch v := val.(type) {
+ case string:
+ return []string{v}
+ case []any:
+ strs := make([]string, 0, len(v))
+
+ for _, item := range v {
+ if s, ok := item.(string); ok {
+ strs = append(strs, s)
+ } else {
+ strs = append(strs, fmt.Sprint(item))
+ }
+ }
+
+ return strs
+ default:
+ return []string{fmt.Sprint(v)}
+ }
+}
+
+// anyPatternMatches returns true if any pattern matches any of the token values.
+func anyPatternMatches(patterns, values []string) bool {
+ for _, pattern := range patterns {
+ for _, value := range values {
+ if MatchGlob(pattern, value) {
+ return true
+ }
+ }
+ }
+
+ return false
+}
+
+// MatchGlob performs simple glob matching where * matches any sequence of characters
+// (including / and empty string). This is intentionally simpler than filepath.Match
+// because claim values like "repo:org/repo" contain slashes that should be matchable.
+func MatchGlob(pattern, value string) bool {
+ // Fast path: no wildcards means exact match.
+ if !strings.Contains(pattern, "*") {
+ return pattern == value
+ }
+
+ // Normalize consecutive wildcards to a single one since they are equivalent.
+ for strings.Contains(pattern, "**") {
+ pattern = strings.ReplaceAll(pattern, "**", "*")
+ }
+
+ parts := strings.Split(pattern, "*")
+
+ // Check that the value starts with the first segment and ends with the last.
+ if !strings.HasPrefix(value, parts[0]) {
+ return false
+ }
+
+ if !strings.HasSuffix(value, parts[len(parts)-1]) {
+ return false
+ }
+
+ // Walk through remaining segments ensuring they appear in order.
+ remaining := value[len(parts[0]):]
+
+ for _, part := range parts[1:] {
+ idx := strings.Index(remaining, part)
+ if idx < 0 {
+ return false
+ }
+
+ remaining = remaining[idx+len(part):]
+ }
+
+ return true
+}
diff --git a/pkg/oidc/verifier_test.go b/pkg/oidc/verifier_test.go
new file mode 100644
index 00000000..d29b2bb1
--- /dev/null
+++ b/pkg/oidc/verifier_test.go
@@ -0,0 +1,462 @@
+package oidc_test
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/go-jose/go-jose/v4"
+ "github.com/go-jose/go-jose/v4/jwt"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/kalbasit/ncps/pkg/oidc"
+)
+
+func TestVerifier_Verify(t *testing.T) {
+ t.Parallel()
+
+ t.Run("valid token", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, nil)
+ token := mk.issueToken(t, jwt.Claims{
+ Issuer: mk.issuer(),
+ Audience: jwt.Audience{"test-audience"},
+ Subject: "user:123",
+ Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ }, nil)
+
+ claims, err := v.Verify(context.Background(), token)
+ require.NoError(t, err)
+ assert.Equal(t, mk.issuer(), claims.Issuer)
+ assert.Equal(t, "user:123", claims.Subject)
+ assert.Equal(t, []string{"test-audience"}, claims.Audience)
+ })
+
+ t.Run("expired token returns error", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, nil)
+ token := mk.issueToken(t, jwt.Claims{
+ Issuer: mk.issuer(),
+ Audience: jwt.Audience{"test-audience"},
+ Subject: "user:123",
+ Expiry: jwt.NewNumericDate(time.Now().Add(-time.Hour)),
+ IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
+ }, nil)
+
+ claims, err := v.Verify(context.Background(), token)
+ require.Error(t, err)
+ assert.Nil(t, claims)
+ assert.ErrorIs(t, err, oidc.ErrTokenValidationFailed)
+ })
+
+ t.Run("wrong audience returns error", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, nil)
+ token := mk.issueToken(t, jwt.Claims{
+ Issuer: mk.issuer(),
+ Audience: jwt.Audience{"wrong-audience"},
+ Subject: "user:123",
+ Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ }, nil)
+
+ claims, err := v.Verify(context.Background(), token)
+ require.Error(t, err)
+ assert.Nil(t, claims)
+ assert.ErrorIs(t, err, oidc.ErrTokenValidationFailed)
+ })
+
+ t.Run("claim matching with exact sub", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, map[string][]string{
+ "sub": {"repo:myorg/myrepo:ref:refs/heads/main"},
+ })
+ token := mk.issueToken(t, jwt.Claims{
+ Issuer: mk.issuer(),
+ Audience: jwt.Audience{"test-audience"},
+ Subject: "repo:myorg/myrepo:ref:refs/heads/main",
+ Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ }, nil)
+
+ claims, err := v.Verify(context.Background(), token)
+ require.NoError(t, err)
+ assert.Equal(t, "repo:myorg/myrepo:ref:refs/heads/main", claims.Subject)
+ })
+
+ t.Run("claim matching with glob pattern", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, map[string][]string{
+ "sub": {"repo:myorg/*"},
+ })
+ token := mk.issueToken(t, jwt.Claims{
+ Issuer: mk.issuer(),
+ Audience: jwt.Audience{"test-audience"},
+ Subject: "repo:myorg/myrepo:ref:refs/heads/main",
+ Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ }, nil)
+
+ claims, err := v.Verify(context.Background(), token)
+ require.NoError(t, err)
+ assert.Equal(t, "repo:myorg/myrepo:ref:refs/heads/main", claims.Subject)
+ })
+
+ t.Run("claim matching with multiple patterns (OR within key)", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, map[string][]string{
+ "ref": {"refs/heads/main", "refs/tags/*"},
+ })
+ token := mk.issueToken(t, jwt.Claims{
+ Issuer: mk.issuer(),
+ Audience: jwt.Audience{"test-audience"},
+ Subject: "user:123",
+ Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ }, map[string]any{
+ "ref": "refs/tags/v1.0.0",
+ })
+
+ claims, err := v.Verify(context.Background(), token)
+ require.NoError(t, err)
+ assert.Equal(t, "user:123", claims.Subject)
+ })
+
+ t.Run("claim matching with multiple keys (AND across keys)", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, map[string][]string{
+ "sub": {"repo:myorg/*"},
+ "ref": {"refs/heads/main"},
+ })
+ token := mk.issueToken(t, jwt.Claims{
+ Issuer: mk.issuer(),
+ Audience: jwt.Audience{"test-audience"},
+ Subject: "repo:myorg/myrepo",
+ Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ }, map[string]any{
+ "ref": "refs/heads/main",
+ })
+
+ claims, err := v.Verify(context.Background(), token)
+ require.NoError(t, err)
+ assert.Equal(t, "repo:myorg/myrepo", claims.Subject)
+ })
+
+ t.Run("claim mismatch on sub returns error", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, map[string][]string{
+ "sub": {"repo:myorg/*"},
+ })
+ token := mk.issueToken(t, jwt.Claims{
+ Issuer: mk.issuer(),
+ Audience: jwt.Audience{"test-audience"},
+ Subject: "repo:otherorg/repo",
+ Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ }, nil)
+
+ claims, err := v.Verify(context.Background(), token)
+ require.Error(t, err)
+ assert.Nil(t, claims)
+ assert.ErrorIs(t, err, oidc.ErrClaimMismatch)
+ })
+
+ t.Run("claim mismatch on one key of multiple returns error", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, map[string][]string{
+ "sub": {"repo:myorg/*"},
+ "ref": {"refs/heads/main"},
+ })
+ token := mk.issueToken(t, jwt.Claims{
+ Issuer: mk.issuer(),
+ Audience: jwt.Audience{"test-audience"},
+ Subject: "repo:myorg/myrepo",
+ Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ }, map[string]any{
+ "ref": "refs/heads/feature",
+ })
+
+ claims, err := v.Verify(context.Background(), token)
+ require.Error(t, err)
+ assert.Nil(t, claims)
+ assert.ErrorIs(t, err, oidc.ErrClaimMismatch)
+ })
+
+ t.Run("missing claim returns error", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, map[string][]string{
+ "ref": {"refs/heads/main"},
+ })
+ token := mk.issueToken(t, jwt.Claims{
+ Issuer: mk.issuer(),
+ Audience: jwt.Audience{"test-audience"},
+ Subject: "user:123",
+ Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ }, nil)
+
+ claims, err := v.Verify(context.Background(), token)
+ require.Error(t, err)
+ assert.Nil(t, claims)
+ assert.ErrorIs(t, err, oidc.ErrClaimMismatch)
+ })
+
+ t.Run("claim matching with array claim value", func(t *testing.T) {
+ t.Parallel()
+
+ mk := newMockOIDC(t)
+ v := mk.newVerifier(t, map[string][]string{
+ "groups": {"admin-*"},
+ })
+ token := mk.issueToken(t, jwt.Claims{
+ Issuer: mk.issuer(),
+ Audience: jwt.Audience{"test-audience"},
+ Subject: "user:123",
+ Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ }, map[string]any{
+ "groups": []any{"dev-team", "admin-ops"},
+ })
+
+ claims, err := v.Verify(context.Background(), token)
+ require.NoError(t, err)
+ assert.Equal(t, "user:123", claims.Subject)
+ })
+
+ t.Run("multi-provider second match works", func(t *testing.T) {
+ t.Parallel()
+
+ mk1 := newMockOIDC(t)
+ mk2 := newMockOIDC(t)
+
+ cfg := &oidc.Config{
+ Policies: []oidc.PolicyConfig{
+ {
+ Issuer: mk1.issuer(),
+ Audience: "test-audience",
+ },
+ {
+ Issuer: mk2.issuer(),
+ Audience: "test-audience",
+ },
+ },
+ }
+
+ v, err := oidc.New(context.Background(), cfg)
+ require.NoError(t, err)
+
+ // Issue token from second provider.
+ token := mk2.issueToken(t, jwt.Claims{
+ Issuer: mk2.issuer(),
+ Audience: jwt.Audience{"test-audience"},
+ Subject: "second-provider-user",
+ Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ }, nil)
+
+ claims, err := v.Verify(context.Background(), token)
+ require.NoError(t, err)
+ assert.Equal(t, "second-provider-user", claims.Subject)
+ assert.Equal(t, mk2.issuer(), claims.Issuer)
+ })
+
+ t.Run("multi-provider no match returns error", func(t *testing.T) {
+ t.Parallel()
+
+ mk1 := newMockOIDC(t)
+ mk2 := newMockOIDC(t)
+ mkOther := newMockOIDC(t)
+
+ cfg := &oidc.Config{
+ Policies: []oidc.PolicyConfig{
+ {
+ Issuer: mk1.issuer(),
+ Audience: "test-audience",
+ },
+ {
+ Issuer: mk2.issuer(),
+ Audience: "test-audience",
+ },
+ },
+ }
+
+ v, err := oidc.New(context.Background(), cfg)
+ require.NoError(t, err)
+
+ // Issue token from an unknown provider.
+ token := mkOther.issueToken(t, jwt.Claims{
+ Issuer: mkOther.issuer(),
+ Audience: jwt.Audience{"test-audience"},
+ Subject: "unknown-user",
+ Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ }, nil)
+
+ claims, err := v.Verify(context.Background(), token)
+ require.Error(t, err)
+ assert.Nil(t, claims)
+ assert.ErrorIs(t, err, oidc.ErrTokenValidationFailed)
+ })
+}
+
+func TestMatchGlob(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ pattern string
+ value string
+ want bool
+ }{
+ {"exact match", "repo:org/repo", "repo:org/repo", true},
+ {"exact mismatch", "repo:org/repo", "repo:org/other", false},
+ {"trailing wildcard", "repo:org/*", "repo:org/myrepo", true},
+ {"trailing wildcard with slashes", "repo:org/*", "repo:org/my/deep/repo", true},
+ {"leading wildcard", "*:refs/heads/main", "ref:refs/heads/main", true},
+ {"middle wildcard", "repo:org/*/ref:*", "repo:org/myrepo/ref:refs/heads/main", true},
+ {"star matches empty", "repo:org/*", "repo:org/", true},
+ {"full wildcard", "*", "anything-goes", true},
+ {"no match prefix", "repo:other/*", "repo:org/myrepo", false},
+ {"consecutive wildcards", "repo:**/main", "repo:org/repo/main", true},
+ {"many consecutive wildcards", "a***b", "aXYZb", true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, tt.want, oidc.MatchGlob(tt.pattern, tt.value))
+ })
+ }
+}
+
+// mockOIDC is a test OIDC provider that serves discovery and JWKS endpoints.
+type mockOIDC struct {
+ server *httptest.Server
+ key *rsa.PrivateKey
+ keyID string
+}
+
+func newMockOIDC(t *testing.T) *mockOIDC {
+ t.Helper()
+
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+
+ mk := &mockOIDC{
+ key: key,
+ keyID: "test-key-1",
+ }
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/.well-known/openid-configuration", mk.handleDiscovery)
+ mux.HandleFunc("/jwks", mk.handleJWKS)
+
+ mk.server = httptest.NewServer(mux)
+ t.Cleanup(mk.server.Close)
+
+ return mk
+}
+
+func (m *mockOIDC) issuer() string {
+ return m.server.URL
+}
+
+func (m *mockOIDC) handleDiscovery(w http.ResponseWriter, _ *http.Request) {
+ doc := map[string]any{
+ "issuer": m.issuer(),
+ "jwks_uri": m.server.URL + "/jwks",
+ "id_token_signing_alg_values_supported": []string{"RS256"},
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(doc)
+}
+
+func (m *mockOIDC) handleJWKS(w http.ResponseWriter, _ *http.Request) {
+ jwks := jose.JSONWebKeySet{
+ Keys: []jose.JSONWebKey{
+ {
+ Key: &m.key.PublicKey,
+ KeyID: m.keyID,
+ Algorithm: string(jose.RS256),
+ Use: "sig",
+ },
+ },
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(jwks)
+}
+
+func (m *mockOIDC) issueToken(t *testing.T, claims jwt.Claims, extraClaims map[string]any) string {
+ t.Helper()
+
+ signerOpts := jose.SignerOptions{}
+ signerOpts.WithType("JWT")
+ signerOpts.WithHeader(jose.HeaderKey("kid"), m.keyID)
+
+ signer, err := jose.NewSigner(
+ jose.SigningKey{Algorithm: jose.RS256, Key: m.key},
+ &signerOpts,
+ )
+ require.NoError(t, err)
+
+ builder := jwt.Signed(signer).Claims(claims)
+ if extraClaims != nil {
+ builder = builder.Claims(extraClaims)
+ }
+
+ token, err := builder.Serialize()
+ require.NoError(t, err)
+
+ return token
+}
+
+func (m *mockOIDC) newVerifier(t *testing.T, claims map[string][]string) *oidc.Verifier {
+ t.Helper()
+
+ cfg := &oidc.Config{
+ Policies: []oidc.PolicyConfig{
+ {
+ Issuer: m.issuer(),
+ Audience: "test-audience",
+ Claims: claims,
+ },
+ },
+ }
+
+ v, err := oidc.New(context.Background(), cfg)
+ require.NoError(t, err)
+
+ return v
+}
diff --git a/pkg/server/oidc_test.go b/pkg/server/oidc_test.go
new file mode 100644
index 00000000..1966136d
--- /dev/null
+++ b/pkg/server/oidc_test.go
@@ -0,0 +1,361 @@
+package server_test
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/go-jose/go-jose/v4"
+ "github.com/go-jose/go-jose/v4/jwt"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ locklocal "github.com/kalbasit/ncps/pkg/lock/local"
+
+ "github.com/kalbasit/ncps/pkg/cache"
+ "github.com/kalbasit/ncps/pkg/cache/upstream"
+ "github.com/kalbasit/ncps/pkg/database"
+ "github.com/kalbasit/ncps/pkg/oidc"
+ "github.com/kalbasit/ncps/pkg/server"
+ "github.com/kalbasit/ncps/pkg/storage/local"
+ "github.com/kalbasit/ncps/testhelper"
+)
+
+const nixCacheInfoPath = "/nix-cache-info"
+
+// oidcTestServer is a mock OIDC provider for integration tests.
+type oidcTestServer struct {
+ server *httptest.Server
+ key *rsa.PrivateKey
+ keyID string
+}
+
+func newOIDCTestServer(t *testing.T) *oidcTestServer {
+ t.Helper()
+
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+
+ ots := &oidcTestServer{
+ key: key,
+ keyID: "test-key-1",
+ }
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/.well-known/openid-configuration", ots.handleDiscovery)
+ mux.HandleFunc("/jwks", ots.handleJWKS)
+
+ ots.server = httptest.NewServer(mux)
+ t.Cleanup(ots.server.Close)
+
+ return ots
+}
+
+func (o *oidcTestServer) issuer() string {
+ return o.server.URL
+}
+
+func (o *oidcTestServer) handleDiscovery(w http.ResponseWriter, _ *http.Request) {
+ doc := map[string]any{
+ "issuer": o.issuer(),
+ "jwks_uri": o.server.URL + "/jwks",
+ "id_token_signing_alg_values_supported": []string{"RS256"},
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(doc)
+}
+
+func (o *oidcTestServer) handleJWKS(w http.ResponseWriter, _ *http.Request) {
+ jwks := jose.JSONWebKeySet{
+ Keys: []jose.JSONWebKey{
+ {
+ Key: &o.key.PublicKey,
+ KeyID: o.keyID,
+ Algorithm: string(jose.RS256),
+ Use: "sig",
+ },
+ },
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(jwks)
+}
+
+func (o *oidcTestServer) issueToken(t *testing.T, subject string) string {
+ t.Helper()
+
+ signerOpts := jose.SignerOptions{}
+ signerOpts.WithType("JWT")
+ signerOpts.WithHeader(jose.HeaderKey("kid"), o.keyID)
+
+ signer, err := jose.NewSigner(
+ jose.SigningKey{Algorithm: jose.RS256, Key: o.key},
+ &signerOpts,
+ )
+ require.NoError(t, err)
+
+ token, err := jwt.Signed(signer).Claims(jwt.Claims{
+ Issuer: o.issuer(),
+ Audience: jwt.Audience{"test-audience"},
+ Subject: subject,
+ Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ }).Serialize()
+ require.NoError(t, err)
+
+ return token
+}
+
+func (o *oidcTestServer) newVerifier(t *testing.T) *oidc.Verifier {
+ t.Helper()
+
+ cfg := &oidc.Config{
+ Policies: []oidc.PolicyConfig{
+ {
+ Issuer: o.issuer(),
+ Audience: "test-audience",
+ },
+ },
+ }
+
+ v, err := oidc.New(context.Background(), cfg)
+ require.NoError(t, err)
+
+ return v
+}
+
+func setupOIDCTestServer(t *testing.T) (*httptest.Server, *oidcTestServer) {
+ t.Helper()
+
+ // Setup a dummy upstream server
+ upstreamSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == nixCacheInfoPath {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("StoreDir: /nix/store\nWantMassQuery: 1\nPriority: 40"))
+
+ return
+ }
+
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ t.Cleanup(upstreamSrv.Close)
+
+ // Setup database and storage
+ dir, err := os.MkdirTemp("", "ncps-oidc-test-")
+ require.NoError(t, err)
+ t.Cleanup(func() { os.RemoveAll(dir) })
+
+ dbFile := filepath.Join(dir, "db.sqlite")
+ testhelper.CreateMigrateDatabase(t, dbFile)
+
+ db, err := database.Open("sqlite:"+dbFile, nil)
+ require.NoError(t, err)
+
+ ls, err := local.New(context.Background(), dir)
+ require.NoError(t, err)
+
+ c, err := cache.New(
+ context.Background(), "localhost", db, ls, ls, ls, "",
+ locklocal.NewLocker(), locklocal.NewRWLocker(),
+ time.Minute, 30*time.Second, time.Minute,
+ )
+ require.NoError(t, err)
+
+ uc, err := upstream.New(context.Background(), testhelper.MustParseURL(t, upstreamSrv.URL), nil)
+ require.NoError(t, err)
+
+ c.AddUpstreamCaches(context.Background(), uc)
+ <-c.GetHealthChecker().Trigger()
+
+ // Setup OIDC mock
+ ots := newOIDCTestServer(t)
+ v := ots.newVerifier(t)
+
+ // Create server with OIDC verifier
+ srv := server.New(c, server.WithOIDCVerifier(v))
+ srv.SetPutPermitted(true)
+ srv.SetDeletePermitted(true)
+
+ ts := httptest.NewServer(srv)
+ t.Cleanup(ts.Close)
+
+ return ts, ots
+}
+
+//nolint:paralleltest
+func TestOIDCIntegration(t *testing.T) {
+ t.Run("PUT with OIDC enabled and no token returns 401", func(t *testing.T) {
+ ts, _ := setupOIDCTestServer(t)
+
+ req, err := http.NewRequestWithContext(
+ context.Background(),
+ http.MethodPut,
+ ts.URL+"/upload/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.narinfo",
+ strings.NewReader("fake-narinfo"),
+ )
+ require.NoError(t, err)
+
+ resp, err := ts.Client().Do(req)
+ require.NoError(t, err)
+
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
+ })
+
+ t.Run("PUT with OIDC enabled and valid token reaches handler", func(t *testing.T) {
+ ts, ots := setupOIDCTestServer(t)
+
+ token := ots.issueToken(t, "repo:org/repo:ref:refs/heads/main")
+
+ req, err := http.NewRequestWithContext(
+ context.Background(),
+ http.MethodPut,
+ ts.URL+"/upload/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2.narinfo",
+ strings.NewReader("fake-narinfo"),
+ )
+ require.NoError(t, err)
+
+ req.Header.Set("Authorization", "Bearer "+token)
+
+ resp, err := ts.Client().Do(req)
+ require.NoError(t, err)
+
+ defer resp.Body.Close()
+
+ // Should reach handler (may fail due to invalid narinfo content, but not 401)
+ assert.NotEqual(t, http.StatusUnauthorized, resp.StatusCode)
+ assert.NotEqual(t, http.StatusForbidden, resp.StatusCode)
+ })
+
+ t.Run("DELETE with OIDC enabled and no token returns 401", func(t *testing.T) {
+ ts, _ := setupOIDCTestServer(t)
+
+ req, err := http.NewRequestWithContext(
+ context.Background(),
+ http.MethodDelete,
+ ts.URL+"/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa3.narinfo",
+ nil,
+ )
+ require.NoError(t, err)
+
+ resp, err := ts.Client().Do(req)
+ require.NoError(t, err)
+
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
+ })
+
+ t.Run("GET with OIDC enabled and no token still works", func(t *testing.T) {
+ ts, _ := setupOIDCTestServer(t)
+
+ req, err := http.NewRequestWithContext(
+ context.Background(),
+ http.MethodGet,
+ ts.URL+nixCacheInfoPath,
+ nil,
+ )
+ require.NoError(t, err)
+
+ resp, err := ts.Client().Do(req)
+ require.NoError(t, err)
+
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ })
+
+ t.Run("GET narinfo with OIDC enabled and no token still works", func(t *testing.T) {
+ ts, _ := setupOIDCTestServer(t)
+
+ req, err := http.NewRequestWithContext(
+ context.Background(),
+ http.MethodGet,
+ ts.URL+"/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa4.narinfo",
+ nil,
+ )
+ require.NoError(t, err)
+
+ resp, err := ts.Client().Do(req)
+ require.NoError(t, err)
+
+ defer resp.Body.Close()
+
+ // Should NOT be 401 (it's a GET). Will be 404 since narinfo doesn't exist.
+ assert.NotEqual(t, http.StatusUnauthorized, resp.StatusCode)
+ })
+
+ t.Run("no OIDC verifier allows PUT without token", func(t *testing.T) {
+ // Setup without OIDC verifier
+ upstreamSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == nixCacheInfoPath {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("StoreDir: /nix/store\nWantMassQuery: 1\nPriority: 40"))
+
+ return
+ }
+
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ t.Cleanup(upstreamSrv.Close)
+
+ dir, err := os.MkdirTemp("", "ncps-no-oidc-")
+ require.NoError(t, err)
+ t.Cleanup(func() { os.RemoveAll(dir) })
+
+ dbFile := filepath.Join(dir, "db.sqlite")
+ testhelper.CreateMigrateDatabase(t, dbFile)
+
+ db, err := database.Open("sqlite:"+dbFile, nil)
+ require.NoError(t, err)
+
+ ls, err := local.New(context.Background(), dir)
+ require.NoError(t, err)
+
+ c, err := cache.New(
+ context.Background(), "localhost", db, ls, ls, ls, "",
+ locklocal.NewLocker(), locklocal.NewRWLocker(),
+ time.Minute, 30*time.Second, time.Minute,
+ )
+ require.NoError(t, err)
+
+ uc, err := upstream.New(context.Background(), testhelper.MustParseURL(t, upstreamSrv.URL), nil)
+ require.NoError(t, err)
+
+ c.AddUpstreamCaches(context.Background(), uc)
+ <-c.GetHealthChecker().Trigger()
+
+ // No OIDC verifier
+ srv := server.New(c)
+ srv.SetPutPermitted(true)
+
+ ts := httptest.NewServer(srv)
+ t.Cleanup(ts.Close)
+
+ req, err := http.NewRequestWithContext(
+ context.Background(),
+ http.MethodPut,
+ ts.URL+"/upload/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa5.narinfo",
+ strings.NewReader("fake-narinfo"),
+ )
+ require.NoError(t, err)
+
+ resp, err := ts.Client().Do(req)
+ require.NoError(t, err)
+
+ defer resp.Body.Close()
+
+ // Should NOT be 401 (no OIDC configured)
+ assert.NotEqual(t, http.StatusUnauthorized, resp.StatusCode)
+ })
+}
diff --git a/pkg/server/server.go b/pkg/server/server.go
index ed62741e..58c3ed3c 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -29,6 +29,7 @@ import (
"github.com/kalbasit/ncps/pkg/cache/upstream"
"github.com/kalbasit/ncps/pkg/nar"
"github.com/kalbasit/ncps/pkg/narinfo"
+ "github.com/kalbasit/ncps/pkg/oidc"
"github.com/kalbasit/ncps/pkg/storage"
"github.com/kalbasit/ncps/pkg/zstd"
)
@@ -65,6 +66,17 @@ func init() {
tracer = otel.Tracer(otelPackageName)
}
+// Option configures optional Server behavior.
+type Option func(*Server)
+
+// WithOIDCVerifier configures the server with an OIDC token verifier
+// that gates write (PUT/DELETE) routes.
+func WithOIDCVerifier(v *oidc.Verifier) Option {
+ return func(s *Server) {
+ s.oidcVerifier = v
+ }
+}
+
// Server represents the main HTTP server.
type Server struct {
cache *cache.Cache
@@ -72,15 +84,20 @@ type Server struct {
deletePermitted bool
putPermitted bool
+ oidcVerifier *oidc.Verifier
}
// SetPrometheusGatherer configures the server with a Prometheus gatherer for /metrics endpoint.
func SetPrometheusGatherer(gatherer promclient.Gatherer) { prometheusGatherer = gatherer }
// New returns a new server.
-func New(cache *cache.Cache) *Server {
+func New(cache *cache.Cache, opts ...Option) *Server {
s := &Server{cache: cache}
+ for _, opt := range opts {
+ opt(s)
+ }
+
s.createRouter()
return s
@@ -107,12 +124,18 @@ func (s *Server) createRouter() {
// 1. Register standard routes at the root
s.registerRoutes(s.router)
- // 2. Register DELETE routes at the root
- s.router.Delete(routeNarInfo, s.deleteNarInfo)
- s.router.Delete(routeNarCompression, s.deleteNar)
- s.router.Delete(routeNar, s.deleteNar)
+ // 2. Register DELETE routes at the root (optional OIDC gate)
+ s.router.Group(func(r chi.Router) {
+ if s.oidcVerifier != nil {
+ r.Use(s.oidcVerifier.Middleware())
+ }
+
+ r.Delete(routeNarInfo, s.deleteNarInfo)
+ r.Delete(routeNarCompression, s.deleteNar)
+ r.Delete(routeNar, s.deleteNar)
+ })
- // 2. Register "upload only" routes under /upload
+ // 3. Register "upload only" routes under /upload
s.router.Route("/upload", func(r chi.Router) {
// Middleware to inject the UploadOnly flag
r.Use(func(next http.Handler) http.Handler {
@@ -122,13 +145,19 @@ func (s *Server) createRouter() {
})
})
- // register standard routes
+ // register standard routes (read, no auth)
s.registerRoutes(r)
- // register PUT routes
- r.Put(routeNarInfo, s.putNarInfo)
- r.Put(routeNarCompression, s.putNar)
- r.Put(routeNar, s.putNar)
+ // register PUT routes (optional OIDC gate)
+ r.Group(func(r chi.Router) {
+ if s.oidcVerifier != nil {
+ r.Use(s.oidcVerifier.Middleware())
+ }
+
+ r.Put(routeNarInfo, s.putNarInfo)
+ r.Put(routeNarCompression, s.putNar)
+ r.Put(routeNar, s.putNar)
+ })
})
// Add Prometheus metrics endpoint if gatherer is configured