// 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) } }