Add admin-create registration + live email-verified flag
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user