The original NOTE claimed Dex's kubernetes storage types Hash as []byte, requiring the bcrypt string to be base64-encoded before storage. This was wrong: Dex v2.41 stores and compares the hash field as a plain string. The base64-encoding caused every invite login to fail with 'Invalid credentials' because Dex passed the base64 bytes (starting with 'J' not '$') directly to bcrypt. Static passwords in the configmap always used raw bcrypt strings and worked fine — confirming the dynamic CR encoding was the bug.
180 lines
7.0 KiB
Go
180 lines
7.0 KiB
Go
// 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"
|
|
"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. 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)))
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|