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.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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
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 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
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=
|
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