81e454d86d
Registration now goes through POST /api/auth/register, which admin-creates the Kratos identity (unverified email, NO auto-sent code). Kratos self-service registration never returns the verification flow id, so the client could never submit the user's code to the right flow; admin creation lets the client own a single verification flow instead. Also surface the live Kratos verified flag and fix Apple audience + team IDs. - kratos.Client.CreateIdentity via admin API; ErrIdentityExists / ErrInvalidCredentials - AuthService.Register + AuthHandler.Register + public POST /api/auth/register/ - CurrentUser overrides stale user_profile.verified with the live Kratos flag; UserRepository.MarkVerified mirrors it back - configmap: additional_id_token_audiences allows the .dev bundle id_token - fix Apple/APNs team id V3PF3M6B6U -> X86BR9WTLD in .env.example + dev init Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
246 lines
8.2 KiB
Go
246 lines
8.2 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 (
|
|
"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)
|
|
}
|
|
}
|