Add admin-create registration + live email-verified flag
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

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>
This commit is contained in:
Trey t
2026-06-03 17:46:30 -05:00
parent 7b87f2e392
commit 81e454d86d
9 changed files with 223 additions and 9 deletions
+100
View File
@@ -5,10 +5,12 @@
package kratos
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
@@ -19,6 +21,16 @@ import (
// 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
@@ -112,6 +124,94 @@ func (c *Client) Whoami(ctx context.Context, sessionToken, cookie string) (*Sess
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