// 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 ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "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") // ErrIdentityExists is returned by CreateIdentity when an identity with the // same credential identifier (email) already exists — caller should respond 409. var ErrIdentityExists = errors.New("kratos: identity already exists") // ErrInvalidCredentials is returned by CreateIdentity when Kratos rejects the // password against its policy (too short, breached, etc.) — caller responds 400. type ErrInvalidCredentials struct{ Reason string } func (e *ErrInvalidCredentials) Error() string { return "kratos: " + e.Reason } // 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 } // CreateIdentity admin-creates a Kratos identity with a password credential and // an UNVERIFIED email, returning the created identity (with its UUID). // // Why admin-create instead of the self-service registration flow: self-service // registration always auto-emails a verification code to a flow whose id Kratos // never returns to the client (verified 2026-06-03 — true with and without the // session after-hook), so the client can never submit the user's code to the // right flow. Admin creation runs no registration hooks and sends no email, so // honeyDue drives verification explicitly afterward: the client starts its own // verification flow (the single code) and the user enters it. The new identity // is fully usable immediately — the client logs in right after to get a session // (Kratos permits login for unverified identities; app access is gated on the // verified flag, not on Kratos login). // // password is sent in cleartext over the in-cluster admin API (never exposed // publicly); Kratos hashes it with the configured bcrypt cost and applies the // password policy, returning 400 on a weak/breached password and 409 on a // duplicate email. func (c *Client) CreateIdentity(ctx context.Context, email, firstName, lastName, password string) (*Identity, error) { if c.adminURL == "" { return nil, errors.New("kratos: admin URL not configured") } payload := map[string]any{ "schema_id": "honeydue", "traits": map[string]any{ "email": email, "name": map[string]any{"first": firstName, "last": lastName}, }, "credentials": map[string]any{ "password": map[string]any{ "config": map[string]any{"password": password}, }, }, } buf, err := json.Marshal(payload) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.adminURL+"/admin/identities", bytes.NewReader(buf)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("kratos create identity: %w", err) } defer resp.Body.Close() switch resp.StatusCode { case http.StatusCreated, http.StatusOK: var id Identity if err := json.NewDecoder(resp.Body).Decode(&id); err != nil { return nil, fmt.Errorf("kratos create identity: decode: %w", err) } return &id, nil case http.StatusConflict: return nil, ErrIdentityExists case http.StatusBadRequest: body, _ := io.ReadAll(resp.Body) return nil, &ErrInvalidCredentials{Reason: kratosReason(body)} default: body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("kratos create identity: unexpected status %d: %s", resp.StatusCode, string(body)) } } // kratosReason pulls a human-readable reason out of a Kratos error body // ({"error":{"reason":"...","message":"..."}}), falling back to the raw body. func kratosReason(body []byte) string { var env struct { Error struct { Reason string `json:"reason"` Message string `json:"message"` } `json:"error"` } if json.Unmarshal(body, &env) == nil { if env.Error.Reason != "" { return env.Error.Reason } if env.Error.Message != "" { return env.Error.Message } } return strings.TrimSpace(string(body)) } // 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) } }