// 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/json" "errors" "fmt" "io" "net/http" "os" "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. The hash field is a plain // bcrypt string (e.g. "$2a$12$..."). Dex v2.41+ stores and compares it as-is — // it does NOT base64-decode the field. Earlier code base64-encoded the hash // based on a misread of Dex's internal []byte type; that caused every dynamic // invite login to fail with "Invalid credentials" while static passwords (set as // plain strings in the configmap) worked fine. 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, Hash: bcryptHash, // raw bcrypt string — Dex compares it directly 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))) } } // passwordName maps an email to the Kubernetes object name Dex uses internally // when looking up a Password CR by email (Dex storage/kubernetes passwordID()). // Dex maps every character that is not [a-z0-9-] to '-' — it does NOT use // human-readable substitutions like '-at-' or '-dot-'. Using a different scheme // creates a name mismatch: Tapir writes the CR under one name, Dex looks it up // under another, and every login returns "Invalid credentials". func passwordName(email string) string { n := strings.ToLower(strings.TrimSpace(email)) var b strings.Builder for _, r := range n { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { b.WriteRune(r) } else { b.WriteRune('-') } } result := strings.Trim(b.String(), "-") if result == "" { return "user" } return result }