refactor: remove Dex local-password invite provisioning (ADR-019)
Authentik owns invites now (infra ADR-0001). Delete adapters/dex, the /invite set-password UI, the tapir invite CLI, the InvitationStore/ DexPasswordCreator ports + App wiring, the invite Templ pages, and the invite Taskfile target. New users are invited via Authentik, log in via OIDC, and hit the existing /register gate. invitations table (mig 009) left in place (append-only; harmless). task check green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,181 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package dex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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-user-example-com", gotBody.Metadata["name"])
|
||||
require.Equal(t, "auth", gotBody.Metadata["namespace"])
|
||||
|
||||
// Hash is stored as the raw bcrypt string — Dex compares it directly.
|
||||
require.Equal(t, "$2a$12$abcdefghijklmnopqrstuv", gotBody.Hash)
|
||||
}
|
||||
|
||||
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) {
|
||||
// Must match Dex's internal passwordID() — maps every non-[a-z0-9-] to '-'.
|
||||
// Using a different scheme (e.g. '-at-', '-dot-') causes a name mismatch:
|
||||
// Tapir writes the CR under one name, Dex looks it up under another.
|
||||
cases := map[string]string{
|
||||
"Alice@Example.com": "alice-example-com",
|
||||
"a.b+c@gmail.com": "a-b-c-gmail-com",
|
||||
"UPPER@DOMAIN.IO": "upper-domain-io",
|
||||
"mathias@d-ma.be": "mathias-d-ma-be",
|
||||
}
|
||||
for in, want := range cases {
|
||||
require.Equal(t, want, passwordName(in), in)
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Invitations are NOT routed through withUser: an invitation exists before its
|
||||
// user does, so there is no user_id to scope by and no authenticated context when
|
||||
// one is minted (host CLI) or claimed (the public /invite handler). The token is
|
||||
// the capability — single-use, time-boxed, crypto-random. The invitations table
|
||||
// is deliberately outside RLS for the same reason (see migration 009).
|
||||
|
||||
// CreateInvitation mints a single-use invite for email, valid for ttl, and
|
||||
// returns its token. The token is 32 bytes of crypto-random entropy, hex-encoded;
|
||||
// it is the only secret a recipient needs to claim the invite.
|
||||
func (s *Store) CreateInvitation(ctx context.Context, email string, ttl time.Duration) (string, error) {
|
||||
token, err := newInviteToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO invitations (email, token, expires_at)
|
||||
VALUES ($1, $2, NOW() + $3::interval)`,
|
||||
email, token, ttl.String()); err != nil {
|
||||
return "", fmt.Errorf("store: create invitation: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// PeekInvitation returns the invited email for a token that is real, unexpired,
|
||||
// and unused WITHOUT consuming it — the read the /invite form does to validate the
|
||||
// link before showing the password fields. Returns ErrNotFound when the token is
|
||||
// missing, expired, or already used. Use ClaimInvitation to consume.
|
||||
func (s *Store) PeekInvitation(ctx context.Context, token string) (string, error) {
|
||||
var email string
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT email FROM invitations
|
||||
WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()`,
|
||||
token).Scan(&email)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("store: peek invitation: %w", err)
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
// ClaimInvitation atomically consumes a valid invite and returns its email. The
|
||||
// UPDATE ... WHERE used_at IS NULL AND expires_at > NOW() guarded by RETURNING
|
||||
// makes the claim a single round-trip race-free check-and-set: two concurrent
|
||||
// claims of the same token, only one updates a row, the other gets no rows and so
|
||||
// ErrNotFound. Same ErrNotFound for missing/expired/already-used tokens.
|
||||
func (s *Store) ClaimInvitation(ctx context.Context, token string) (string, error) {
|
||||
var email string
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`UPDATE invitations
|
||||
SET used_at = NOW()
|
||||
WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()
|
||||
RETURNING email`,
|
||||
token).Scan(&email)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("store: claim invitation: %w", err)
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
// newInviteToken returns 32 bytes of crypto-random entropy, hex-encoded (64
|
||||
// chars). Hex keeps the token URL-safe with no escaping in /invite/{token}.
|
||||
func newInviteToken() (string, error) {
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("store: invite token: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||
)
|
||||
|
||||
// resetInvitations clears the invitations table between cases. It is not in the
|
||||
// shared resetDB TRUNCATE list (invitations is not user-owned and has no FK to
|
||||
// users), so the invite tests wipe it themselves.
|
||||
func resetInvitations(t *testing.T, p *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
_, err := p.Exec(context.Background(), `TRUNCATE invitations`)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCreateInvitationReturnsUsableToken(t *testing.T) {
|
||||
s, p := newStore(t), rawPool(t)
|
||||
resetInvitations(t, p)
|
||||
ctx := context.Background()
|
||||
|
||||
token, err := s.CreateInvitation(ctx, "new@example.com", time.Hour)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, token, 64, "32 random bytes hex-encoded")
|
||||
|
||||
// Peek does not consume: the same token previews twice.
|
||||
email, err := s.PeekInvitation(ctx, token)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "new@example.com", email)
|
||||
email, err = s.PeekInvitation(ctx, token)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "new@example.com", email)
|
||||
}
|
||||
|
||||
func TestCreateInvitationTokensAreUnique(t *testing.T) {
|
||||
s, p := newStore(t), rawPool(t)
|
||||
resetInvitations(t, p)
|
||||
ctx := context.Background()
|
||||
|
||||
t1, err := s.CreateInvitation(ctx, "a@example.com", time.Hour)
|
||||
require.NoError(t, err)
|
||||
t2, err := s.CreateInvitation(ctx, "b@example.com", time.Hour)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, t1, t2)
|
||||
}
|
||||
|
||||
func TestClaimInvitationHappyPath(t *testing.T) {
|
||||
s, p := newStore(t), rawPool(t)
|
||||
resetInvitations(t, p)
|
||||
ctx := context.Background()
|
||||
|
||||
token, err := s.CreateInvitation(ctx, "claim@example.com", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
email, err := s.ClaimInvitation(ctx, token)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "claim@example.com", email)
|
||||
}
|
||||
|
||||
func TestClaimInvitationIsSingleUse(t *testing.T) {
|
||||
s, p := newStore(t), rawPool(t)
|
||||
resetInvitations(t, p)
|
||||
ctx := context.Background()
|
||||
|
||||
token, err := s.CreateInvitation(ctx, "once@example.com", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s.ClaimInvitation(ctx, token)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Second claim fails — already used.
|
||||
_, err = s.ClaimInvitation(ctx, token)
|
||||
require.ErrorIs(t, err, store.ErrNotFound)
|
||||
|
||||
// And a used token no longer previews.
|
||||
_, err = s.PeekInvitation(ctx, token)
|
||||
require.ErrorIs(t, err, store.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestClaimInvitationExpired(t *testing.T) {
|
||||
s, p := newStore(t), rawPool(t)
|
||||
resetInvitations(t, p)
|
||||
ctx := context.Background()
|
||||
|
||||
// Negative ttl => already expired.
|
||||
token, err := s.CreateInvitation(ctx, "old@example.com", -time.Minute)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s.PeekInvitation(ctx, token)
|
||||
require.ErrorIs(t, err, store.ErrNotFound)
|
||||
_, err = s.ClaimInvitation(ctx, token)
|
||||
require.ErrorIs(t, err, store.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestClaimInvitationNotFound(t *testing.T) {
|
||||
s, p := newStore(t), rawPool(t)
|
||||
resetInvitations(t, p)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := s.ClaimInvitation(ctx, "does-not-exist")
|
||||
require.ErrorIs(t, err, store.ErrNotFound)
|
||||
_, err = s.PeekInvitation(ctx, "does-not-exist")
|
||||
require.ErrorIs(t, err, store.ErrNotFound)
|
||||
}
|
||||
Reference in New Issue
Block a user