Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Overview

`registry-auth` is a Go service that provides authentication and authorization for the [Docker Registry](https://github.com/distribution/distribution). It implements two of the Registry's auth flows:

- **Token auth**: Registry-auth runs alongside the Registry. The client first hits the Registry, gets a 401 with the `WWW-Authenticate` URL, then exchanges Basic credentials at `/auth/token` for a JWT that the Registry verifies against the shared certificate.
- **Proxy auth**: Registry-auth sits in front of the Registry. It accepts Basic auth on `/v2/*`, mints a token internally, swaps it onto the request's `Authorization` header, and reverse-proxies to the Registry backend.

Both flows share the same JWT signer; the Docker Registry must be configured with the same `rootcertbundle` and `issuer` as registry-auth's `--auth-public-cert-file` and `--auth-issuer`.

## Common commands

```bash
make build # build _output/<os>/<arch>/registry-auth (CGO_ENABLED=0)
make test # go test -v -cover ./...
make fmt vet # gofmt + go vet
make image # docker build -> ghcr.io/alauda/registry-auth:<version>
make all # fmt + vet + test + build + strip

# Single test (no shortcut in Makefile)
go test -v ./pkg/server -run TestHandleAuth

# End-to-end integration test — pulls a real registry binary + skopeo, runs
# registry-auth, push/pull image. Used by the Dockerfile's RUN_TEST stage.
# Requires curl, openssl, go, and root-ish perms to write /usr/bin. Run from repo root.
bash scripts/simple-tests.sh
```

The version string baked into the binary comes from `git describe`; building from a detached or dirty tree marks `GitTreeState=dirty`.

## Architecture

### Layered entrypoint

`cmd/registry-auth/main.go` → `cmd/registry-auth/app/app.go` wires together option groups and calls `pkg/app.NewApp`. `pkg/app` is a small cobra+viper+pflag harness shared across Alauda apps:

- `Options` (`pkg/server/options/interface.go`) aggregates a list of `Optioner`s; each option group owns its flags, viper bindings, and an `ApplyToServer` hook.
- `app.runCommand` calls `ApplyFlags` (CLI → struct), then `ApplyToServer` (struct → `*server.Server`), then `server.Start`.
- To add a new flag, add it to an existing options file under `pkg/server/options/` (or create a new `Optioner` and register it in `cmd/registry-auth/app/app.go`).

### Server

`pkg/server/server.go` holds the long-lived `Server`. `ApplyToServer` wires routes onto a `go-restful` container:

- `GET /auth/token` → `HandleAuth` (token mode)
- `/v2/*` → `HandleProxy` (proxy mode; reverse-proxies to `--registry-backend`)
- `/health` → static OK

Both modes funnel through `Server.signToken` (`pkg/server/handlers.go`), which calls `AuthProcessor.Authenticate` → `Authorize` → `Sign`.

### AuthProcessor (`pkg/server/auth.go`)

The single source of auth truth. It holds two parallel sets of user/auth maps:

- `StaticUsers` / `StaticAuths` — populated from the YAML/JSON file (`--auth-config-file`).
- `SecretUsers` / `SecretAuths` — populated from Kubernetes Secrets (`--auth-config-namespace` + `--auth-config-selector`).

**Lookup order** in both `Authenticate` and `Authorize`: Secret first, then Static, then third-party (`pkg/server/thirdparty.go`). If a request's user has no matching authorization, `signToken` retries authorization as the special `_anonymous` user — that's how unauthenticated pulls are configured.

Password verification supports three formats in this priority: bcrypt (`$...`), reserved `PBKDF2:` prefix (not implemented), and plaintext fallback. Use `htpasswd -nbB user pass` to generate bcrypt hashes.

`DecodeScope` parses the `scope=` query parameter (token mode). `DecodeScopeFromUrl` infers `type:name:actions` from the URL/method (proxy mode); HTTP methods map to actions as GET/HEAD→`pull`, PUT/PATCH/POST→`push`, DELETE→`delete`. `/v2/_catalog` uses `registry:catalog:*`.

In proxy mode `IsScopeActionMatch` short-circuits: if the request's actions are already a subset of what was granted, `signToken` returns `ErrNotHandleAuthHeader` and the handler leaves the existing `Authorization` header alone (avoids re-signing on every blob request).

### Config sources (all watched live)

- `file.go` — `fsnotify` watcher on `--auth-config-file`. Calls `LoadFromFile` on Create/Write events.
- `secret.go` — client-go informer over Secrets in `--auth-config-namespace` matching `--auth-config-selector` (default `registry-auth-config=true`). The Secret's `config` key contains the same YAML schema as the file. Add/Update/Delete events are reflected into `SecretUsers`/`SecretAuths` via `LoadFromSecret`.
- `thirdparty.go` — `--auth-thirdparty-server` URL. Registry-auth POSTs Basic-auth'd login requests there; the response is a list of `Authorization` entries cached per user. Only consulted when the user is in neither map.

### JWT signing

`NewAuthProcessor` parses the public cert (`loadCert`) and constructs a `jose.Signer` with RS256, emitting the signing cert as a JWS `x5c` header (`encoding/base64` StdEncoding of `cert.Raw`). The private key must be RSA. Claims include `iss`, `sub`, `aud` (from `?service=` or `--service`), `exp`, `nbf`, `iat`, `jti`, and the `access` array.

Why `x5c` and not `kid`: distribution v2 keys its `trustedKeys` map by `libtrust.KeyID()` (libtrust is archived), while distribution v3 keys it by RFC 7638 JWK thumbprint — the two formats don't agree. Both versions, however, first verify the JWT's `x5c` cert chain against the `rootcertbundle`, so emitting `x5c` makes tokens accepted by both v2.x and v3.x without depending on libtrust.

## Configuration schema

`auth-config-file` and the `config` key of watched Secrets share this YAML schema:

```yaml
users:
user1: plaintext-password
user2: $2y$05$...bcrypt-hash
auths:
user1:
- target: repo/path # literal match
actions: [pull, push]
- target: team/.* # regex match
useRegexp: true
actions: [pull]
_anonymous: # fallthrough for unauthenticated or unmatched users
- target: .*
useRegexp: true
actions: [pull]
```

`Authorization.Type` defaults to `repository` if omitted; set it to `registry` for catalog-scoped grants.

## Notes / gotchas

- `BasicOptions.ApplyToServer` (`pkg/server/options/basic.go:130`) assigns `o.AuthConfigNamespace` into `server.BasicConfig.AuthConfigLabelSelector` — looks like a copy-paste bug. Selector configured via the flag will not reach the server. If you touch this code, fix to use `o.AuthConfigLabelSelector`.
- `AuthProcessor.LoadFromSecret` (`pkg/server/auth.go:295`) reads `dataOld[SecretKey]` when populating new entries (should be `dataNew`). Updates to Secrets may not behave as expected — verify before relying on dynamic Secret updates.
- The `go.mod` pins `k8s.io/client-go` to `v0.24.4` via `replace`; other `k8s.io/*` deps must stay compatible with that version.
- `pkg/server/auth.go` still uses `io/ioutil`; that's fine, just don't "modernize" mid-task without reason.
- Tokens are verified via `x5c` (not `kid`). Don't reintroduce `libtrust` or a `kid` header without also handling v2/v3 thumbprint differences — see the JWT signing section above.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ data:
The Docker Registry need configure to work with Registry-Auth.
You can configure it by config file or environment variables.

You can add the following configs in /etc/docker/registry/config.yml:
Registry-Auth works with both `distribution/distribution` v2.x and v3.x. The token configuration keys (`realm`, `service`, `issuer`, `rootcertbundle`, `autoredirect`) and `REGISTRY_AUTH_TOKEN_*` environment variables are identical across both versions. Note that v3 changed the default config path from `/etc/docker/registry/config.yml` to `/etc/distribution/config.yml`.

You can add the following configs in /etc/docker/registry/config.yml (v2) or /etc/distribution/config.yml (v3):

``` yaml
auth:
Expand Down
4 changes: 3 additions & 1 deletion README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,9 @@ data:
当使用 Registry-Auth 时,Docker Registry 也要做必要的设置。
你可以选择通过 **配置文件** 或 **环境变量** 的方式配置 Docker Registry。

你可以在 /etc/docker/registry/config.yml 中配置如下字段:
Registry-Auth 同时兼容 `distribution/distribution` v2.x 与 v3.x,token 段的配置项(`realm`、`service`、`issuer`、`rootcertbundle`、`autoredirect`)与 `REGISTRY_AUTH_TOKEN_*` 环境变量两版完全一致。注意 v3 的默认配置路径由 `/etc/docker/registry/config.yml` 改为 `/etc/distribution/config.yml`。

你可以在 /etc/docker/registry/config.yml(v2)或 /etc/distribution/config.yml(v3)中配置如下字段:

``` yaml
auth:
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ toolchain go1.24.11
replace k8s.io/client-go => k8s.io/client-go v0.24.4

require (
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7
github.com/emicklei/go-restful v2.16.0+incompatible
github.com/fatih/color v1.13.0
github.com/google/uuid v1.1.2
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
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=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4=
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc=
github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
Expand Down
28 changes: 12 additions & 16 deletions pkg/server/auth.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package server

import (
"crypto"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
Expand All @@ -19,7 +18,6 @@ import (

"github.com/thoas/go-funk"

"github.com/docker/libtrust"
"github.com/google/uuid"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
Expand Down Expand Up @@ -63,9 +61,9 @@ type UserAuthorization struct {
}

type ClaimAccess struct {
Type string
Name string
Actions []string
Type string `json:"type"`
Name string `json:"name"`
Actions []string `json:"actions"`
}

type AccessScope []ClaimAccess
Expand All @@ -81,7 +79,7 @@ type AuthProcessor struct {
ThirdpartyAuth ThirdpartyAuth

signer jose.Signer
kid string
cert *x509.Certificate
}

type ConfigFile struct {
Expand Down Expand Up @@ -195,7 +193,7 @@ func NewAuthProcessor(privateKeyFile, publicCertFile, issuer string, tokenDurati
Issuer: issuer,
TokenDuration: time.Second * time.Duration(tokenDuration),
}
if err := a.getKid(publicCertFile); err != nil {
if err := a.loadCert(publicCertFile); err != nil {
return nil, err
}

Expand All @@ -206,7 +204,7 @@ func NewAuthProcessor(privateKeyFile, publicCertFile, issuer string, tokenDurati
return a, nil
}

func (a *AuthProcessor) getKid(publicCertFile string) error {
func (a *AuthProcessor) loadCert(publicCertFile string) error {
data, err := ioutil.ReadFile(publicCertFile)
if err != nil {
return err
Expand All @@ -221,12 +219,7 @@ func (a *AuthProcessor) getKid(publicCertFile string) error {
return fmt.Errorf("parse certificate '%s' error: %v ", publicCertFile, err)
}

pubKey, err := libtrust.FromCryptoPublicKey(crypto.PublicKey(cert.PublicKey))
if err != nil {
return fmt.Errorf("parse public key '%s' error: %v ", publicCertFile, err)
}

a.kid = pubKey.KeyID()
a.cert = cert
return nil
}

Expand All @@ -245,12 +238,15 @@ func (a *AuthProcessor) generateSigner(privateKeyFile string) error {
return fmt.Errorf("the private key is not in RSA format")
}

op := &jose.SignerOptions{}
op := (&jose.SignerOptions{}).WithHeader(
jose.HeaderKey("x5c"),
[]string{base64.StdEncoding.EncodeToString(a.cert.Raw)},
)

a.signer, err = jose.NewSigner(jose.SigningKey{
Algorithm: jose.RS256,
Key: rsaPrivKey,
}, op.WithHeader(jose.HeaderKey("kid"), a.kid))
}, op)
return err
}

Expand Down
2 changes: 1 addition & 1 deletion pkg/server/options/basic.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ type BasicOptions struct {
config.BasicConfig
}

//// NewConsoleOptions creates a ConsoleOptions object with default parameters.
// // NewConsoleOptions creates a ConsoleOptions object with default parameters.
func NewBasicOptions() *BasicOptions {
return &BasicOptions{}
}
Expand Down
Loading
Loading