Files
tapir/internal/adapters/dex/dex.go
T
mathias 9cd3f7e934
CI / Lint / Test / Vet (push) Failing after 12s
CI / Build & Import (push) Has been skipped
fix(dex): passwordName must match Dex's internal passwordID() — maps non-[a-z0-9-] to '-'
Tapir used human-readable substitutions ('@' -> '-at-', '.' -> '-dot-') when
deriving the Password CR name from an email. Dex's internal passwordID() maps
every non-[a-z0-9-] character to plain '-'. This caused a name mismatch:
Tapir wrote the CR as 'mathias-at-d-ma-dot-be', Dex looked it up as
'mathias-d-ma-be', got not-found, and returned 'Invalid credentials' on every
invite login — while static configmap passwords (a different code path) worked
fine. Diagnosed by adding the email to staticPasswords and confirming login
succeeded, proving the kubernetes CR lookup was the failure point.
2026-06-07 11:37:24 +02:00

182 lines
7.1 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"
"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
}