Gate local repo writes behind OIDC tokens
Add ARTIFACTAPI_AUTH_UPLOADS to require a verified token for local uploads, deletes, evictions, /api/v1 publishes and /v2 push. Accept the token as Bearer, Token, Basic password or bare header, and serve a Docker registry token endpoint so pulls stay anonymous.
This commit is contained in:
+62
-9
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -104,14 +105,17 @@ func (b *BearerOIDC) discoverOnce(done chan struct{}) (v *oidc.IDTokenVerifier,
|
||||
// Require gates every request through next on a valid token. A nil receiver
|
||||
// passes everything through.
|
||||
func (b *BearerOIDC) Require(next http.Handler) http.Handler {
|
||||
return b.require(next, "Bearer")
|
||||
}
|
||||
|
||||
func (b *BearerOIDC) require(next http.Handler, challenge string) http.Handler {
|
||||
if b == nil {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
scheme, raw, _ := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ")
|
||||
raw = strings.TrimSpace(raw)
|
||||
if !strings.EqualFold(scheme, "Bearer") || raw == "" {
|
||||
unauthorized(w, "missing bearer token")
|
||||
raw := token(r)
|
||||
if raw == "" {
|
||||
unauthorized(w, challenge, "missing token")
|
||||
return
|
||||
}
|
||||
v, err := b.getVerifier(r.Context())
|
||||
@@ -124,12 +128,12 @@ func (b *BearerOIDC) Require(next http.Handler) http.Handler {
|
||||
tok, err := v.Verify(r.Context(), raw)
|
||||
if err != nil {
|
||||
slog.Info("bearer token rejected", "error", err)
|
||||
unauthorized(w, "invalid token")
|
||||
unauthorized(w, challenge, "invalid token")
|
||||
return
|
||||
}
|
||||
if len(b.audiences) > 0 && !slices.ContainsFunc(tok.Audience, func(a string) bool { return slices.Contains(b.audiences, a) }) {
|
||||
slog.Info("bearer token rejected", "error", "audience not allowed", "aud", tok.Audience)
|
||||
unauthorized(w, "invalid token")
|
||||
unauthorized(w, challenge, "invalid token")
|
||||
return
|
||||
}
|
||||
slog.Info("bearer token accepted", "sub", tok.Subject, "method", r.Method, "path", r.URL.Path)
|
||||
@@ -137,9 +141,58 @@ func (b *BearerOIDC) Require(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// token reads the JWT from `Bearer`/`Token` credentials, the password of
|
||||
// `Basic` credentials (any username), or a bare header value as `gem push`
|
||||
// sends its API key.
|
||||
func token(r *http.Request) string {
|
||||
if _, pass, ok := r.BasicAuth(); ok {
|
||||
return pass
|
||||
}
|
||||
scheme, raw, found := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ")
|
||||
if !found {
|
||||
return scheme
|
||||
}
|
||||
if strings.EqualFold(scheme, "Bearer") || strings.EqualFold(scheme, "Token") {
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RequireWrites gates only mutating methods.
|
||||
func (b *BearerOIDC) RequireWrites(next http.Handler) http.Handler {
|
||||
gated := b.Require(next)
|
||||
return writesOnly(next, b.Require(next))
|
||||
}
|
||||
|
||||
// RequireRegistryWrites gates Docker Registry V2 writes behind the token flow
|
||||
// Docker clients need: an unauthenticated ping gets a Bearer challenge naming
|
||||
// realm, which serves the Basic password back as the token once verified, or
|
||||
// an anonymous token so pulls stay open.
|
||||
func (b *BearerOIDC) RequireRegistryWrites(next http.Handler, realm func(*http.Request) string) http.Handler {
|
||||
if b == nil {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
challenge := `Bearer realm="` + realm(r) + `",service="artifactapi"`
|
||||
hasAuth := r.Header.Get("Authorization") != ""
|
||||
switch {
|
||||
case r.URL.Path == "/v2/token" && !hasAuth:
|
||||
writeToken(w, "anonymous")
|
||||
case r.URL.Path == "/v2/token":
|
||||
b.require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeToken(w, token(r)) }), challenge).ServeHTTP(w, r)
|
||||
case r.URL.Path == "/v2/" && !hasAuth:
|
||||
unauthorized(w, challenge, "authentication required")
|
||||
default:
|
||||
writesOnly(next, b.require(next, challenge)).ServeHTTP(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func writeToken(w http.ResponseWriter, tok string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"token": tok, "access_token": tok})
|
||||
}
|
||||
|
||||
func writesOnly(next, gated http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
@@ -150,9 +203,9 @@ func (b *BearerOIDC) RequireWrites(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func unauthorized(w http.ResponseWriter, msg string) {
|
||||
func unauthorized(w http.ResponseWriter, challenge, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
w.Header().Set("WWW-Authenticate", challenge)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"` + msg + `"}`))
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -253,3 +254,87 @@ func TestRequireWritesLeavesReadsOpen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func basic(user, pass string) string {
|
||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass))
|
||||
}
|
||||
|
||||
func TestTokenSchemes(t *testing.T) {
|
||||
iss := testsupport.NewOIDCIssuer()
|
||||
defer iss.Close()
|
||||
h := NewBearerOIDC(iss.URL, []string{"artifactapi"}).RequireWrites(ok)
|
||||
valid := iss.Token(nil, "artifactapi", "ci", time.Hour)
|
||||
cases := []struct {
|
||||
name string
|
||||
authz string
|
||||
want int
|
||||
}{
|
||||
{"bearer", "Bearer " + valid, http.StatusNoContent},
|
||||
{"token scheme", "Token " + valid, http.StatusNoContent},
|
||||
{"basic password", basic("anyone", valid), http.StatusNoContent},
|
||||
{"basic empty user", basic("", valid), http.StatusNoContent},
|
||||
{"bare jwt", valid, http.StatusNoContent},
|
||||
{"basic wrong aud", basic("ci", iss.Token(nil, "nope", "ci", time.Hour)), http.StatusUnauthorized},
|
||||
{"basic token as user", basic(valid, "x"), http.StatusUnauthorized},
|
||||
{"bare garbage", "garbage", http.StatusUnauthorized},
|
||||
{"unknown scheme", "Digest " + valid, http.StatusUnauthorized},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := serveAuth(h, http.MethodPut, c.authz); got != c.want {
|
||||
t.Errorf("%s: status %d, want %d", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireRegistryWrites(t *testing.T) {
|
||||
iss := testsupport.NewOIDCIssuer()
|
||||
defer iss.Close()
|
||||
h := NewBearerOIDC(iss.URL, []string{"artifactapi"}).RequireRegistryWrites(ok, func(*http.Request) string { return "https://reg.example/v2/token" })
|
||||
valid := iss.Token(nil, "artifactapi", "ci", time.Hour)
|
||||
do := func(method, path, authz string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(method, path, nil)
|
||||
if authz != "" {
|
||||
r.Header.Set("Authorization", authz)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
challenge := `Bearer realm="https://reg.example/v2/token",service="artifactapi"`
|
||||
|
||||
w := do(http.MethodGet, "/v2/", "")
|
||||
if w.Code != http.StatusUnauthorized || w.Header().Get("WWW-Authenticate") != challenge {
|
||||
t.Errorf("anonymous ping: %d %q", w.Code, w.Header().Get("WWW-Authenticate"))
|
||||
}
|
||||
if w := do(http.MethodGet, "/v2/", "Bearer anonymous"); w.Code != http.StatusNoContent {
|
||||
t.Errorf("ping with token: %d", w.Code)
|
||||
}
|
||||
if w := do(http.MethodGet, "/v2/token", ""); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"token":"anonymous"`) {
|
||||
t.Errorf("anonymous token: %d %s", w.Code, w.Body)
|
||||
}
|
||||
if w := do(http.MethodGet, "/v2/token", basic("ci", valid)); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"token":"`+valid+`"`) {
|
||||
t.Errorf("token exchange: %d %s", w.Code, w.Body)
|
||||
}
|
||||
if w := do(http.MethodGet, "/v2/token", basic("ci", "bogus")); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("bad credentials: %d", w.Code)
|
||||
}
|
||||
for _, m := range []string{http.MethodGet, http.MethodHead} {
|
||||
if w := do(m, "/v2/repo/img/manifests/t", ""); w.Code != http.StatusNoContent {
|
||||
t.Errorf("anonymous %s: %d", m, w.Code)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{http.MethodPost, http.MethodPatch, http.MethodPut, http.MethodDelete} {
|
||||
w := do(m, "/v2/repo/img/blobs/uploads/", "Bearer anonymous")
|
||||
if w.Code != http.StatusUnauthorized || w.Header().Get("WWW-Authenticate") != challenge {
|
||||
t.Errorf("anonymous %s: %d %q", m, w.Code, w.Header().Get("WWW-Authenticate"))
|
||||
}
|
||||
for _, authz := range []string{"Bearer " + valid, basic("ci", valid)} {
|
||||
if w := do(m, "/v2/repo/img/blobs/uploads/", authz); w.Code != http.StatusNoContent {
|
||||
t.Errorf("authenticated %s: %d", m, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
if got := serve(NewBearerOIDC("", nil).RequireRegistryWrites(ok, nil), http.MethodPut, ""); got != http.StatusNoContent {
|
||||
t.Errorf("disabled: status %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user