feat(dex): in-cluster Password CR client for local-password accounts
Writes passwords.dex.coreos.com CRs against the in-cluster Kubernetes API using the pod's service-account token + cluster CA (no kubectl / client-go dependency). NewPasswordClient returns ErrNotInCluster off cluster so the web layer degrades gracefully in dev. Load-bearing: Dex's kubernetes storage types Password.Hash as []byte, which k8s JSON-marshals as base64 — so the `hash` field carries the base64 of the bcrypt string, not the raw string. Storing the raw string makes Dex's base64-decode-on-login produce garbage and every login fail. 409 -> ErrPasswordExists, 401/403 -> ErrForbidden (RBAC missing) so the handler can give precise messages. Tested against an httptest TLS server. bcrypt cost-12 hashing lives in the web handler; golang.org/x/crypto was already a transitive dep (now promoted in go.sum). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -91,6 +91,8 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// Package dex creates Dex local-password accounts by writing
|
||||
// passwords.dex.coreos.com custom resources directly against the in-cluster
|
||||
// Kubernetes API. This is the write side of the invite flow: a recipient sets a
|
||||
// password on /invite/{token}, Tapir bcrypt-hashes it and POSTs a Password CR into
|
||||
// the auth namespace, and Dex (configured with kubernetes storage) then serves
|
||||
// local-password login for that email.
|
||||
//
|
||||
// Why the raw API and not kubectl/client-go: the deployed pod already carries a
|
||||
// service-account token and the cluster CA at the well-known mount paths, so a
|
||||
// single net/http POST needs no extra dependency and no shelling out. Standalone /
|
||||
// dev has no such mount — NewPasswordClient returns ErrNotInCluster and the web
|
||||
// handler degrades gracefully (account creation only works in the deployed env).
|
||||
package dex
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sentinel errors let the web handler turn API outcomes into clear user messages.
|
||||
var (
|
||||
// ErrNotInCluster means the service-account token mount is absent, so there is
|
||||
// no in-cluster API to talk to (local dev / tests). Construction-time only.
|
||||
ErrNotInCluster = errors.New("dex: not running in-cluster (no service-account token)")
|
||||
// ErrPasswordExists maps the API's 409 Conflict — a Password CR for this email
|
||||
// already exists. The handler treats it as a benign "log in instead".
|
||||
ErrPasswordExists = errors.New("dex: password already exists")
|
||||
// ErrForbidden maps 401/403 — the tapir ServiceAccount lacks create/get on
|
||||
// passwords.dex.coreos.com in the auth namespace (RBAC not applied).
|
||||
ErrForbidden = errors.New("dex: forbidden — missing RBAC for passwords.dex.coreos.com")
|
||||
)
|
||||
|
||||
// Well-known in-cluster service-account mount paths (projected by kubelet).
|
||||
const (
|
||||
saTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec // path, not a secret
|
||||
saCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
|
||||
// apiServer is the in-cluster API endpoint; its TLS is validated against the
|
||||
// mounted cluster CA.
|
||||
apiServer = "https://kubernetes.default.svc"
|
||||
// passwordsPath is the Dex Password collection in the auth namespace.
|
||||
passwordsPath = "/apis/dex.coreos.com/v1/namespaces/auth/passwords"
|
||||
)
|
||||
|
||||
// PasswordClient writes Dex Password CRs against the in-cluster API. Construct it
|
||||
// with NewPasswordClient; the zero value is not usable.
|
||||
type PasswordClient struct {
|
||||
server string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewPasswordClient reads the service-account token and cluster CA from the
|
||||
// well-known mount paths and returns a client that authenticates as the pod's
|
||||
// ServiceAccount. It returns ErrNotInCluster when the token mount is absent (dev /
|
||||
// tests / standalone), so callers can detect "no Dex available" and degrade.
|
||||
func NewPasswordClient() (*PasswordClient, error) {
|
||||
token, err := os.ReadFile(saTokenPath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotInCluster
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dex: read service-account token: %w", err)
|
||||
}
|
||||
|
||||
caPEM, err := os.ReadFile(saCAPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dex: read cluster CA: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caPEM) {
|
||||
return nil, errors.New("dex: cluster CA is not valid PEM")
|
||||
}
|
||||
|
||||
hc := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
|
||||
},
|
||||
}
|
||||
return newClient(apiServer, strings.TrimSpace(string(token)), hc), nil
|
||||
}
|
||||
|
||||
// newClient is the injectable constructor shared by NewPasswordClient and tests
|
||||
// (which point server at an httptest.Server and pass its TLS client).
|
||||
func newClient(server, token string, hc *http.Client) *PasswordClient {
|
||||
return &PasswordClient{server: server, token: token, http: hc}
|
||||
}
|
||||
|
||||
// password is the wire form of a Dex Password CR. NOTE: Dex's kubernetes storage
|
||||
// types the hash as []byte, which Kubernetes JSON-marshals as base64. So the
|
||||
// `hash` field must carry the base64 encoding of the bcrypt string, NOT the raw
|
||||
// bcrypt string — store the raw string and Dex's base64-decode on login yields
|
||||
// garbage and every login fails. CreatePassword does that encoding.
|
||||
type password struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Kind string `json:"kind"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
Email string `json:"email"`
|
||||
Hash string `json:"hash"`
|
||||
Username string `json:"username"`
|
||||
UserID string `json:"userID"`
|
||||
}
|
||||
|
||||
// CreatePassword creates a Dex local-password account for email with the given
|
||||
// bcrypt hash and Dex user id. The CR name is derived from the email so it is a
|
||||
// valid, stable, idempotent Kubernetes object name. Returns ErrPasswordExists on
|
||||
// 409 (the account already exists) and ErrForbidden on 401/403 (RBAC missing).
|
||||
func (c *PasswordClient) CreatePassword(ctx context.Context, email, bcryptHash, userID string) error {
|
||||
body, err := json.Marshal(password{
|
||||
APIVersion: "dex.coreos.com/v1",
|
||||
Kind: "Password",
|
||||
Metadata: map[string]string{"name": passwordName(email), "namespace": "auth"},
|
||||
Email: email,
|
||||
// base64 of the bcrypt string — see the password type's NOTE.
|
||||
Hash: base64.StdEncoding.EncodeToString([]byte(bcryptHash)),
|
||||
Username: email,
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dex: marshal password: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.server+passwordsPath, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("dex: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dex: create password: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusCreated, http.StatusOK:
|
||||
return nil
|
||||
case http.StatusConflict:
|
||||
return ErrPasswordExists
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return ErrForbidden
|
||||
default:
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("dex: create password: unexpected status %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet)))
|
||||
}
|
||||
}
|
||||
|
||||
// invalidNameChars matches anything not allowed in an RFC-1123 subdomain segment
|
||||
// after the explicit @/. substitutions, so any stray character becomes '-'.
|
||||
var invalidNameChars = regexp.MustCompile(`[^a-z0-9-]`)
|
||||
|
||||
// passwordName maps an email to a valid, deterministic Kubernetes object name:
|
||||
// lowercase, '@' -> '-at-', '.' -> '-dot-', any remaining invalid char -> '-',
|
||||
// with leading/trailing '-' trimmed. Deterministic so a re-invite targets the
|
||||
// same CR (and so Dex's 409 is meaningful).
|
||||
func passwordName(email string) string {
|
||||
n := strings.ToLower(strings.TrimSpace(email))
|
||||
n = strings.ReplaceAll(n, "@", "-at-")
|
||||
n = strings.ReplaceAll(n, ".", "-dot-")
|
||||
n = invalidNameChars.ReplaceAllString(n, "-")
|
||||
n = strings.Trim(n, "-")
|
||||
if n == "" {
|
||||
n = "user"
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package dex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newTestClient points a PasswordClient at an httptest server, using that
|
||||
// server's TLS client so the in-cluster TLS path is exercised without a real CA.
|
||||
func newTestClient(srv *httptest.Server) *PasswordClient {
|
||||
return newClient(srv.URL, "test-token", srv.Client())
|
||||
}
|
||||
|
||||
func TestCreatePasswordSuccess(t *testing.T) {
|
||||
var gotAuth, gotPath, gotMethod string
|
||||
var gotBody password
|
||||
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth, gotPath, gotMethod = r.Header.Get("Authorization"), r.URL.Path, r.Method
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(b, &gotBody)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"kind":"Password"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := newTestClient(srv).CreatePassword(context.Background(),
|
||||
"New.User@Example.com", "$2a$12$abcdefghijklmnopqrstuv", "user-uuid-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, http.MethodPost, gotMethod)
|
||||
require.Equal(t, passwordsPath, gotPath)
|
||||
require.Equal(t, "Bearer test-token", gotAuth)
|
||||
|
||||
// Email/username carry the raw address; the CR name is sanitised + lowercased.
|
||||
require.Equal(t, "New.User@Example.com", gotBody.Email)
|
||||
require.Equal(t, "New.User@Example.com", gotBody.Username)
|
||||
require.Equal(t, "user-uuid-1", gotBody.UserID)
|
||||
require.Equal(t, "new-dot-user-at-example-dot-com", gotBody.Metadata["name"])
|
||||
require.Equal(t, "auth", gotBody.Metadata["namespace"])
|
||||
|
||||
// The hash is the BASE64 of the bcrypt string (Dex stores hash as []byte).
|
||||
decoded, err := base64.StdEncoding.DecodeString(gotBody.Hash)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "$2a$12$abcdefghijklmnopqrstuv", string(decoded))
|
||||
}
|
||||
|
||||
func TestCreatePasswordConflict(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := newTestClient(srv).CreatePassword(context.Background(), "dup@example.com", "$2a$12$x", "u")
|
||||
require.ErrorIs(t, err, ErrPasswordExists)
|
||||
}
|
||||
|
||||
func TestCreatePasswordForbidden(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := newTestClient(srv).CreatePassword(context.Background(), "x@example.com", "$2a$12$x", "u")
|
||||
require.ErrorIs(t, err, ErrForbidden)
|
||||
}
|
||||
|
||||
func TestCreatePasswordUnexpectedStatus(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("boom"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := newTestClient(srv).CreatePassword(context.Background(), "x@example.com", "$2a$12$x", "u")
|
||||
require.Error(t, err)
|
||||
require.NotErrorIs(t, err, ErrPasswordExists)
|
||||
require.NotErrorIs(t, err, ErrForbidden)
|
||||
require.Contains(t, err.Error(), "500")
|
||||
}
|
||||
|
||||
func TestNewPasswordClientNotInCluster(t *testing.T) {
|
||||
// In the test environment the SA token mount does not exist.
|
||||
_, err := NewPasswordClient()
|
||||
require.ErrorIs(t, err, ErrNotInCluster)
|
||||
}
|
||||
|
||||
func TestPasswordName(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Alice@Example.com": "alice-at-example-dot-com",
|
||||
"a.b+c@gmail.com": "a-dot-b-c-at-gmail-dot-com",
|
||||
"UPPER@DOMAIN.IO": "upper-at-domain-dot-io",
|
||||
}
|
||||
for in, want := range cases {
|
||||
require.Equal(t, want, passwordName(in), in)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user