Files
honeyDueAPI/internal/kratos/client.go
T
Trey t 3d3ba84df0
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
fix(auth): delete the Kratos identity on account deletion
Account deletion removed all local data but left the Ory Kratos
identity intact — an orphaned identity that can still authenticate.
Close the gap:

- kratos.Client gains the admin API: NewClient(publicURL, adminURL)
  and DeleteIdentity (DELETE /admin/identities/{id}; a 404 is treated
  as success so a retry after a partial failure is idempotent).
- AuthService.DeleteAccount deletes the Kratos identity FIRST; if that
  call fails it aborts before touching local data, so the operation is
  retryable rather than partially applied.
- KRATOS_ADMIN_URL config (default http://kratos:4434) + router wiring.
- kratos NetworkPolicy split: the api pods may now reach the admin API
  :4434 (Traefik still reaches only the public API :4433).
- kratos CORS: allow_credentials + OPTIONS so the web browser flows
  (ory_kratos_session cookie) work; origins stay an explicit allowlist.
- Regression tests: identity teardown happens, and a Kratos failure
  aborts the deletion instead of orphaning local data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:55:33 -05:00

146 lines
4.5 KiB
Go

// Package kratos is a thin client for the Ory Kratos APIs. honeyDue
// delegates all identity concerns (credentials, sessions, verification,
// recovery, social sign-in) to Kratos; this client validates sessions
// against the public API and deletes identities via the admin API.
package kratos
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// ErrUnauthorized is returned by Whoami when the session is missing, invalid,
// or inactive — the caller should respond 401.
var ErrUnauthorized = errors.New("kratos: session invalid or inactive")
// Client talks to the Ory Kratos public and admin APIs.
type Client struct {
publicURL string
adminURL string
http *http.Client
}
// NewClient builds a Kratos client. publicURL is the public-API base used for
// session validation (e.g. http://kratos:4433 in-cluster); adminURL is the
// admin-API base used for identity management (e.g. http://kratos:4434).
// Either may be empty when the corresponding API is unused.
func NewClient(publicURL, adminURL string) *Client {
return &Client{
publicURL: strings.TrimRight(publicURL, "/"),
adminURL: strings.TrimRight(adminURL, "/"),
http: &http.Client{Timeout: 5 * time.Second},
}
}
// Identity is the subset of a Kratos identity honeyDue consumes. It mirrors
// the identity schema in deploy-k3s/manifests/kratos/configmap.yaml.
type Identity struct {
ID string `json:"id"` // UUID — the stable identity identifier
Traits struct {
Email string `json:"email"`
Name struct {
First string `json:"first"`
Last string `json:"last"`
} `json:"name"`
} `json:"traits"`
VerifiableAddresses []struct {
Value string `json:"value"`
Verified bool `json:"verified"`
} `json:"verifiable_addresses"`
}
// Session is a Kratos session as returned by GET /sessions/whoami.
type Session struct {
ID string `json:"id"`
Active bool `json:"active"`
Identity Identity `json:"identity"`
}
// EmailVerified reports whether any of the identity's email addresses is
// verified — the source of truth for honeyDue's RequireVerified gate.
func (s *Session) EmailVerified() bool {
for _, a := range s.Identity.VerifiableAddresses {
if a.Verified {
return true
}
}
return false
}
// Whoami validates a session against Kratos. Supply the mobile session token
// (sessionToken) OR the browser cookie header (cookie) — whichever is
// non-empty is forwarded to Kratos. Returns ErrUnauthorized for an invalid or
// inactive session.
func (c *Client) Whoami(ctx context.Context, sessionToken, cookie string) (*Session, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.publicURL+"/sessions/whoami", nil)
if err != nil {
return nil, err
}
if sessionToken != "" {
req.Header.Set("X-Session-Token", sessionToken)
}
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("kratos whoami: %w", err)
}
defer resp.Body.Close()
switch {
case resp.StatusCode == http.StatusUnauthorized, resp.StatusCode == http.StatusForbidden:
return nil, ErrUnauthorized
case resp.StatusCode != http.StatusOK:
return nil, fmt.Errorf("kratos whoami: unexpected status %d", resp.StatusCode)
}
var s Session
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
return nil, fmt.Errorf("kratos whoami: decode: %w", err)
}
if !s.Active || s.Identity.ID == "" {
return nil, ErrUnauthorized
}
return &s, nil
}
// DeleteIdentity permanently removes a Kratos identity by its UUID via the
// admin API (DELETE /admin/identities/{id}). A 404 is treated as success —
// the identity is already gone, which is the desired end state, so the call
// is idempotent across retries. Called when a honeyDue account is deleted so
// no orphaned, still-loginable identity is left behind.
func (c *Client) DeleteIdentity(ctx context.Context, identityID string) error {
if c.adminURL == "" {
return errors.New("kratos: admin URL not configured")
}
if identityID == "" {
return errors.New("kratos: empty identity id")
}
endpoint := c.adminURL + "/admin/identities/" + url.PathEscape(identityID)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
return err
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("kratos delete identity: %w", err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusNoContent, http.StatusNotFound:
return nil
default:
return fmt.Errorf("kratos delete identity: unexpected status %d", resp.StatusCode)
}
}