diff --git a/go.sum b/go.sum index 029cf1b..7c52e63 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/adapters/dex/dex.go b/internal/adapters/dex/dex.go new file mode 100644 index 0000000..479e4f5 --- /dev/null +++ b/internal/adapters/dex/dex.go @@ -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 +} diff --git a/internal/adapters/dex/dex_test.go b/internal/adapters/dex/dex_test.go new file mode 100644 index 0000000..8235535 --- /dev/null +++ b/internal/adapters/dex/dex_test.go @@ -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) + } +}