feat: support time_zone for user registration context

This commit is contained in:
Ilia Denisov
2026-04-09 09:00:06 +02:00
parent e6b73a8f55
commit 7043af4cb3
40 changed files with 3452 additions and 164 deletions
+10 -5
View File
@@ -68,7 +68,7 @@ The gateway already distinguishes:
The public auth contract is:
* `send-email-code(email) -> challenge_id`
* `confirm-email-code(challenge_id, code, client_public_key) -> device_session_id`
* `confirm-email-code(challenge_id, code, client_public_key, time_zone) -> device_session_id`
The authenticated request contract is based on:
@@ -217,6 +217,11 @@ Important architectural rules:
* active-session limits are configuration-driven;
* `send-email-code` stays success-shaped for existing, new, blocked, and throttled email flows.
When `confirm-email-code` reaches first successful completion for an e-mail
address that does not yet belong to a user, auth may pass create-only
registration context to `User Service` during the synchronous ensure/create
step.
Direct integrations:
* synchronous to `User Service` for user resolution/create/block decision;
@@ -619,10 +624,10 @@ sequenceDiagram
Auth-->>Gateway: challenge_id
Gateway-->>Client: challenge_id
Client->>Gateway: POST confirm-email-code
Gateway->>Auth: confirm-email-code
Auth->>Auth: validate challenge/code/public key
Auth->>User: resolve/create/block
Client->>Gateway: POST confirm-email-code(time_zone)
Gateway->>Auth: confirm-email-code(time_zone)
Auth->>Auth: validate challenge/code/public key/time_zone
Auth->>User: resolve/create/block with create-only registration context when needed
User-->>Auth: user_id or deny
Auth->>Auth: create device_session
Auth->>Redis: write gateway session projection
+7 -1
View File
@@ -138,10 +138,14 @@ The effective DTO contract is:
| Operation | Request | Success response |
| --- | --- | --- |
| `POST /api/v1/public/auth/send-email-code` | `{ "email": string }` | `{ "challenge_id": string }` |
| `POST /api/v1/public/auth/confirm-email-code` | `{ "challenge_id": string, "code": string, "client_public_key": string }` | `{ "device_session_id": string }` |
| `POST /api/v1/public/auth/confirm-email-code` | `{ "challenge_id": string, "code": string, "client_public_key": string, "time_zone": string }` | `{ "device_session_id": string }` |
`client_public_key` is the standard base64-encoded raw 32-byte Ed25519 public
key registered for the created device session.
`time_zone` is the client-selected IANA time zone name. During the current
rollout phase, successful confirms forward create-only user registration
context to `User Service` as `preferred_language="en"` and the supplied
`time_zone` until gateway geoip-based language derivation is deployed.
Public boundary rules:
@@ -151,6 +155,8 @@ Public boundary rules:
`400 invalid_request`
- surrounding ASCII and Unicode whitespace is trimmed from input string fields
before validation
- `confirm-email-code` requires a non-empty `time_zone` and validates it as an
IANA time zone name
- `send-email-code` remains success-shaped for existing, new, blocked, and
throttled e-mail paths
- `confirm-email-code` returns a ready `device_session_id` synchronously on
+13 -3
View File
@@ -71,8 +71,8 @@ paths:
description: |
Completes a previously issued `challenge_id`, validates the submitted
verification code, registers the standard base64-encoded raw 32-byte
Ed25519 `client_public_key`, and returns the created
`device_session_id`.
Ed25519 `client_public_key`, validates the submitted IANA
`time_zone`, and returns the created `device_session_id`.
security: []
requestBody:
required: true
@@ -86,6 +86,7 @@ paths:
challenge_id: challenge-123
code: "123456"
client_public_key: 11qYAYdk8v3K6Yw8QK6ZlQ2nP4Wm8Cq5g1H0K8vT9no=
time_zone: Europe/Kaliningrad
responses:
"200":
description: The device session was created and is ready for use.
@@ -137,6 +138,7 @@ components:
- challenge_id
- code
- client_public_key
- time_zone
properties:
challenge_id:
type: string
@@ -147,6 +149,9 @@ components:
client_public_key:
type: string
description: Standard base64-encoded raw 32-byte Ed25519 public key registered for the new device session.
time_zone:
type: string
description: Client-selected IANA time zone name forwarded as create-only registration context.
ConfirmEmailCodeResponse:
type: object
additionalProperties: false
@@ -201,7 +206,7 @@ components:
description: |
Request body or field values are invalid. This includes malformed
request payloads, invalid confirmation codes, and malformed
`client_public_key` values.
`client_public_key` or `time_zone` values.
content:
application/json:
schema:
@@ -222,6 +227,11 @@ components:
error:
code: invalid_client_public_key
message: client_public_key is not a valid base64-encoded raw 32-byte Ed25519 public key
invalidTimeZone:
value:
error:
code: invalid_request
message: time_zone must be a valid IANA time zone name
ChallengeNotFoundError:
description: The referenced challenge does not exist.
content:
+6
View File
@@ -118,6 +118,12 @@ func TestPublicOpenAPISpecErrorExamplesMatchStablePublicErrors(t *testing.T) {
exampleName: "invalidClientPublicKey",
projection: shared.ProjectPublicError(shared.InvalidClientPublicKey()),
},
{
name: "confirm invalid time zone",
responseName: "ConfirmEmailCodeBadRequestError",
exampleName: "invalidTimeZone",
projection: shared.ProjectPublicError(shared.InvalidRequest("time_zone must be a valid IANA time zone name")),
},
{
name: "challenge not found",
responseName: "ChallengeNotFoundError",
+2 -1
View File
@@ -60,7 +60,8 @@ curl -X POST http://127.0.0.1:8080/api/v1/public/auth/confirm-email-code \
-d '{
"challenge_id": "challenge-123",
"code": "123456",
"client_public_key": "11qYAYdk8v3K6Yw8QK6ZlQ2nP4Wm8Cq5g1H0K8vT9no="
"client_public_key": "11qYAYdk8v3K6Yw8QK6ZlQ2nP4Wm8Cq5g1H0K8vT9no=",
"time_zone": "Europe/Kaliningrad"
}'
```
+1 -1
View File
@@ -40,7 +40,7 @@ sequenceDiagram
Client->>Gateway: POST /api/v1/public/auth/confirm-email-code
Gateway->>Auth: POST /api/v1/public/auth/confirm-email-code
Auth->>Challenge: load and validate challenge
Auth->>User: EnsureUserByEmail(email)
Auth->>User: EnsureUserByEmail(email, registration_context)
User-->>Auth: existing / created / blocked
Auth->>Config: LoadSessionLimit()
Auth->>Session: CountActiveByUserID(user_id)
+29 -23
View File
@@ -51,12 +51,22 @@ const (
gatewayCompatibilitySessionEventsStream = "gateway:session_events"
gatewayCompatibilityStreamMaxLen int64 = 128
gatewayCompatibilityEmail = "pilot@example.com"
gatewayCompatibilityCode = "123456"
gatewayCompatibilityEmail = "pilot@example.com"
gatewayCompatibilityCode = "123456"
gatewayCompatibilityTimeZone = "Europe/Kaliningrad"
)
var gatewayCompatibilityClientPublicKey = mustGatewayCompatibilityClientPublicKeyBase64()
func gatewayCompatibilityConfirmRequest(challengeID string, code string, clientPublicKey string) map[string]string {
return map[string]string{
"challenge_id": challengeID,
"code": code,
"client_public_key": clientPublicKey,
"time_zone": gatewayCompatibilityTimeZone,
}
}
func TestGatewayCompatibilityConfirmReturnsGatewayReadableSessionProjection(t *testing.T) {
t.Parallel()
@@ -74,11 +84,11 @@ func TestGatewayCompatibilityConfirmReturnsGatewayReadableSessionProjection(t *t
attempts := app.mailSender.RecordedAttempts()
require.Len(t, attempts, 1)
confirmResponse := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": sendBody.ChallengeID,
"code": attempts[0].Input.Code,
"client_public_key": gatewayCompatibilityClientPublicKey,
})
confirmResponse := gatewayCompatibilityPostJSONValue(
t,
app.publicBaseURL+"/api/v1/public/auth/confirm-email-code",
gatewayCompatibilityConfirmRequest(sendBody.ChallengeID, attempts[0].Input.Code, gatewayCompatibilityClientPublicKey),
)
assert.Equal(t, http.StatusOK, confirmResponse.StatusCode)
var confirmBody struct {
@@ -159,11 +169,7 @@ func TestGatewayCompatibilityRepeatedConfirmReturnsSameSessionID(t *testing.T) {
attempts := app.mailSender.RecordedAttempts()
require.Len(t, attempts, 1)
requestBody := map[string]string{
"challenge_id": sendBody.ChallengeID,
"code": attempts[0].Input.Code,
"client_public_key": gatewayCompatibilityClientPublicKey,
}
requestBody := gatewayCompatibilityConfirmRequest(sendBody.ChallengeID, attempts[0].Input.Code, gatewayCompatibilityClientPublicKey)
first := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", requestBody)
second := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", requestBody)
@@ -228,11 +234,11 @@ func TestGatewayCompatibilitySessionLimitExceededReturnsStableClientError(t *tes
attempts := app.mailSender.RecordedAttempts()
require.Len(t, attempts, 1)
confirmResponse := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": "challenge-1",
"code": attempts[0].Input.Code,
"client_public_key": gatewayCompatibilityClientPublicKey,
})
confirmResponse := gatewayCompatibilityPostJSONValue(
t,
app.publicBaseURL+"/api/v1/public/auth/confirm-email-code",
gatewayCompatibilityConfirmRequest("challenge-1", attempts[0].Input.Code, gatewayCompatibilityClientPublicKey),
)
assert.Equal(t, http.StatusConflict, confirmResponse.StatusCode)
assert.JSONEq(t, `{"error":{"code":"session_limit_exceeded","message":"active session limit would be exceeded"}}`, confirmResponse.Body)
}
@@ -245,7 +251,7 @@ func TestGatewayCompatibilityMalformedClientPublicKeyReturnsStableError(t *testi
response := gatewayCompatibilityPostJSON(
t,
app.publicBaseURL+"/api/v1/public/auth/confirm-email-code",
`{"challenge_id":"challenge-123","code":"123456","client_public_key":"invalid"}`,
`{"challenge_id":"challenge-123","code":"123456","client_public_key":"invalid","time_zone":"`+gatewayCompatibilityTimeZone+`"}`,
)
assert.Equal(t, http.StatusBadRequest, response.StatusCode)
assert.JSONEq(t, `{"error":{"code":"invalid_client_public_key","message":"client_public_key is not a valid base64-encoded raw 32-byte Ed25519 public key"}}`, response.Body)
@@ -437,11 +443,11 @@ func (h gatewayCompatibilityHarness) createSessionThroughPublicFlow(t *testing.T
attempts := h.mailSender.RecordedAttempts()
require.Len(t, attempts, 1)
confirmResponse := gatewayCompatibilityPostJSONValue(t, h.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": sendBody.ChallengeID,
"code": attempts[0].Input.Code,
"client_public_key": gatewayCompatibilityClientPublicKey,
})
confirmResponse := gatewayCompatibilityPostJSONValue(
t,
h.publicBaseURL+"/api/v1/public/auth/confirm-email-code",
gatewayCompatibilityConfirmRequest(sendBody.ChallengeID, attempts[0].Input.Code, gatewayCompatibilityClientPublicKey),
)
assert.Equal(t, http.StatusOK, confirmResponse.StatusCode)
var confirmBody struct {
@@ -149,25 +149,42 @@ func (c *RESTClient) ExistsByUserID(ctx context.Context, userID common.UserID) (
return response.Exists, nil
}
// EnsureUserByEmail returns an existing user for email, creates a new user
// when registration is allowed, or reports a blocked outcome.
func (c *RESTClient) EnsureUserByEmail(ctx context.Context, email common.Email) (ports.EnsureUserResult, error) {
// EnsureUserByEmail returns an existing user for input.Email, creates a new
// user when registration is allowed, or reports a blocked outcome.
func (c *RESTClient) EnsureUserByEmail(ctx context.Context, input ports.EnsureUserInput) (ports.EnsureUserResult, error) {
if err := validateContext(ctx, "ensure user by email"); err != nil {
return ports.EnsureUserResult{}, err
}
if err := email.Validate(); err != nil {
if err := input.Validate(); err != nil {
return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err)
}
payload := struct {
Email string `json:"email"`
RegistrationContext *struct {
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
} `json:"registration_context,omitempty"`
}{
Email: input.Email.String(),
}
if input.RegistrationContext != nil {
payload.RegistrationContext = &struct {
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
}{
PreferredLanguage: input.RegistrationContext.PreferredLanguage,
TimeZone: input.RegistrationContext.TimeZone,
}
}
var response struct {
Outcome ports.EnsureUserOutcome `json:"outcome"`
UserID string `json:"user_id,omitempty"`
BlockReasonCode userresolution.BlockReasonCode `json:"block_reason_code,omitempty"`
}
if err := c.doJSON(ctx, "ensure user by email", http.MethodPost, ensureByEmailPath, map[string]string{
"email": email.String(),
}, &response, false); err != nil {
if err := c.doJSON(ctx, "ensure user by email", http.MethodPost, ensureByEmailPath, payload, &response, false); err != nil {
return ports.EnsureUserResult{}, err
}
@@ -20,6 +20,8 @@ import (
"github.com/stretchr/testify/require"
)
const restClientEnsureTimeZone = "Europe/Kaliningrad"
func TestNewRESTClient(t *testing.T) {
t.Parallel()
@@ -107,7 +109,13 @@ func TestRESTClientEndpointSuccessCases(t *testing.T) {
{
name: "ensure user by email",
run: func(t *testing.T, client *RESTClient) {
result, err := client.EnsureUserByEmail(context.Background(), common.Email("created@example.com"))
result, err := client.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email("created@example.com"),
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "en",
TimeZone: restClientEnsureTimeZone,
},
})
require.NoError(t, err)
assert.Equal(t, ports.EnsureUserResult{
Outcome: ports.EnsureUserOutcomeCreated,
@@ -212,7 +220,7 @@ func TestRESTClientEndpointSuccessCases(t *testing.T) {
Method: http.MethodPost,
Path: ensureByEmailPath,
ContentType: "application/json",
Body: `{"email":"created@example.com"}`,
Body: `{"email":"created@example.com","registration_context":{"preferred_language":"en","time_zone":"Europe/Kaliningrad"}}`,
}, requests[0])
case "block by user id":
assert.Equal(t, capturedRequest{
@@ -331,7 +339,13 @@ func TestRESTClientMutationMethodsDoNotRetry(t *testing.T) {
{
name: "ensure user by email",
run: func(client *RESTClient) error {
_, err := client.EnsureUserByEmail(context.Background(), common.Email("pilot@example.com"))
_, err := client.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email("pilot@example.com"),
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "en",
TimeZone: restClientEnsureTimeZone,
},
})
return err
},
},
@@ -405,7 +419,13 @@ func TestRESTClientStrictDecodingAndUnexpectedStatuses(t *testing.T) {
body: `{"outcome":"mystery"}`,
wantErrText: "unsupported",
run: func(client *RESTClient) error {
_, err := client.EnsureUserByEmail(context.Background(), common.Email("pilot@example.com"))
_, err := client.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email("pilot@example.com"),
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "en",
TimeZone: restClientEnsureTimeZone,
},
})
return err
},
},
@@ -415,7 +435,13 @@ func TestRESTClientStrictDecodingAndUnexpectedStatuses(t *testing.T) {
body: `{"outcome":"created"}`,
wantErrText: "user id",
run: func(client *RESTClient) error {
_, err := client.EnsureUserByEmail(context.Background(), common.Email("pilot@example.com"))
_, err := client.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email("pilot@example.com"),
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "en",
TimeZone: restClientEnsureTimeZone,
},
})
return err
},
},
@@ -516,7 +542,22 @@ func TestRESTClientContextAndValidation(t *testing.T) {
{
name: "invalid email",
run: func() error {
_, err := client.EnsureUserByEmail(context.Background(), common.Email(" bad@example.com "))
_, err := client.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email(" bad@example.com "),
})
return err
},
},
{
name: "invalid registration context",
run: func() error {
_, err := client.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email("pilot@example.com"),
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: " en ",
TimeZone: restClientEnsureTimeZone,
},
})
return err
},
},
@@ -69,13 +69,13 @@ func (d *StubDirectory) ExistsByUserID(ctx context.Context, userID common.UserID
return ok, nil
}
// EnsureUserByEmail returns an existing user for email, creates a new user
// when registration is allowed, or reports a blocked outcome.
func (d *StubDirectory) EnsureUserByEmail(ctx context.Context, email common.Email) (ports.EnsureUserResult, error) {
// EnsureUserByEmail returns an existing user for input.Email, creates a new
// user when registration is allowed, or reports a blocked outcome.
func (d *StubDirectory) EnsureUserByEmail(ctx context.Context, input ports.EnsureUserInput) (ports.EnsureUserResult, error) {
if err := validateContext(ctx, "ensure user by email"); err != nil {
return ports.EnsureUserResult{}, err
}
if err := email.Validate(); err != nil {
if err := input.Validate(); err != nil {
return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err)
}
@@ -84,7 +84,7 @@ func (d *StubDirectory) EnsureUserByEmail(ctx context.Context, email common.Emai
d.ensureMapsLocked()
stored, ok := d.byEmail[email]
stored, ok := d.byEmail[input.Email]
if ok {
if !stored.blockReasonCode.IsZero() {
result := ports.EnsureUserResult{
@@ -113,8 +113,8 @@ func (d *StubDirectory) EnsureUserByEmail(ctx context.Context, email common.Emai
if err != nil {
return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err)
}
d.byEmail[email] = entry{userID: userID}
d.emailByUserID[userID] = email
d.byEmail[input.Email] = entry{userID: userID}
d.emailByUserID[userID] = input.Email
result := ports.EnsureUserResult{
Outcome: ports.EnsureUserOutcomeCreated,
@@ -70,7 +70,13 @@ func TestStubDirectoryEnsureUserByEmail(t *testing.T) {
directory := &StubDirectory{}
require.NoError(t, directory.SeedExisting(common.Email("existing@example.com"), common.UserID("user-existing")))
result, err := directory.EnsureUserByEmail(context.Background(), common.Email("existing@example.com"))
result, err := directory.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email("existing@example.com"),
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "en",
TimeZone: "Europe/Kaliningrad",
},
})
require.NoError(t, err)
assert.Equal(t, ports.EnsureUserOutcomeExisting, result.Outcome)
assert.Equal(t, common.UserID("user-existing"), result.UserID)
@@ -82,7 +88,13 @@ func TestStubDirectoryEnsureUserByEmail(t *testing.T) {
directory := &StubDirectory{}
require.NoError(t, directory.SeedBlockedEmail(common.Email("blocked@example.com"), userresolution.BlockReasonCode("policy_block")))
result, err := directory.EnsureUserByEmail(context.Background(), common.Email("blocked@example.com"))
result, err := directory.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email("blocked@example.com"),
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "en",
TimeZone: "Europe/Kaliningrad",
},
})
require.NoError(t, err)
assert.Equal(t, ports.EnsureUserOutcomeBlocked, result.Outcome)
assert.Equal(t, userresolution.BlockReasonCode("policy_block"), result.BlockReasonCode)
@@ -94,12 +106,24 @@ func TestStubDirectoryEnsureUserByEmail(t *testing.T) {
directory := &StubDirectory{}
require.NoError(t, directory.QueueCreatedUserIDs(common.UserID("user-created")))
first, err := directory.EnsureUserByEmail(context.Background(), common.Email("created@example.com"))
first, err := directory.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email("created@example.com"),
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "en",
TimeZone: "Europe/Kaliningrad",
},
})
require.NoError(t, err)
assert.Equal(t, ports.EnsureUserOutcomeCreated, first.Outcome)
assert.Equal(t, common.UserID("user-created"), first.UserID)
second, err := directory.EnsureUserByEmail(context.Background(), common.Email("created@example.com"))
second, err := directory.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email("created@example.com"),
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "fr",
TimeZone: "Europe/Paris",
},
})
require.NoError(t, err)
assert.Equal(t, ports.EnsureUserOutcomeExisting, second.Outcome)
assert.Equal(t, common.UserID("user-created"), second.UserID)
@@ -110,7 +134,13 @@ func TestStubDirectoryEnsureUserByEmail(t *testing.T) {
directory := &StubDirectory{}
result, err := directory.EnsureUserByEmail(context.Background(), common.Email("fallback@example.com"))
result, err := directory.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email("fallback@example.com"),
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "en",
TimeZone: "Europe/Kaliningrad",
},
})
require.NoError(t, err)
assert.Equal(t, ports.EnsureUserOutcomeCreated, result.Outcome)
assert.Equal(t, common.UserID("user-1"), result.UserID)
@@ -240,7 +270,9 @@ func TestStubDirectoryContextAndValidation(t *testing.T) {
{
name: "ensure cancelled context",
run: func() error {
_, err := directory.EnsureUserByEmail(cancelledCtx, common.Email("pilot@example.com"))
_, err := directory.EnsureUserByEmail(cancelledCtx, ports.EnsureUserInput{
Email: common.Email("pilot@example.com"),
})
return err
},
want: context.Canceled.Error(),
@@ -46,6 +46,7 @@ func TestPublicHTTPEndToEndSendThenConfirm(t *testing.T) {
"challenge_id": "challenge-1",
"code": attempts[0].Input.Code,
"client_public_key": validClientPublicKey,
"time_zone": publicConfirmTimeZone,
}
confirmResponse := postJSONValue(t, server.URL+"/api/v1/public/auth/confirm-email-code", confirmBody)
@@ -104,13 +105,36 @@ func TestPublicHTTPEndToEndInvalidClientPublicKey(t *testing.T) {
response := postJSON(
t,
server.URL+"/api/v1/public/auth/confirm-email-code",
`{"challenge_id":"challenge-123","code":"123456","client_public_key":"invalid"}`,
`{"challenge_id":"challenge-123","code":"123456","client_public_key":"invalid","time_zone":"`+publicConfirmTimeZone+`"}`,
)
assert.Equal(t, http.StatusBadRequest, response.StatusCode)
assert.JSONEq(t, `{"error":{"code":"invalid_client_public_key","message":"client_public_key is not a valid base64-encoded raw 32-byte Ed25519 public key"}}`, response.Body)
}
func TestPublicHTTPEndToEndInvalidTimeZone(t *testing.T) {
t.Parallel()
app := newEndToEndApp(t, endToEndOptions{
SeedChallenge: seedChallengeOptions{
ID: "challenge-123",
Code: "123456",
Status: challenge.StatusSent,
},
})
server := httptest.NewServer(app.handler)
defer server.Close()
response := postJSON(
t,
server.URL+"/api/v1/public/auth/confirm-email-code",
`{"challenge_id":"challenge-123","code":"123456","client_public_key":"`+validClientPublicKey+`","time_zone":"Mars/Olympus"}`,
)
assert.Equal(t, http.StatusBadRequest, response.StatusCode)
assert.JSONEq(t, `{"error":{"code":"invalid_request","message":"time_zone must be a valid IANA time zone name"}}`, response.Body)
}
func TestPublicHTTPEndToEndChallengeNotFound(t *testing.T) {
t.Parallel()
@@ -122,6 +146,7 @@ func TestPublicHTTPEndToEndChallengeNotFound(t *testing.T) {
"challenge_id": "missing",
"code": "123456",
"client_public_key": validClientPublicKey,
"time_zone": publicConfirmTimeZone,
})
assert.Equal(t, http.StatusNotFound, response.StatusCode)
@@ -146,6 +171,7 @@ func TestPublicHTTPEndToEndChallengeExpired(t *testing.T) {
"challenge_id": "challenge-123",
"code": "123456",
"client_public_key": validClientPublicKey,
"time_zone": publicConfirmTimeZone,
})
assert.Equal(t, http.StatusGone, response.StatusCode)
@@ -169,6 +195,7 @@ func TestPublicHTTPEndToEndInvalidCode(t *testing.T) {
"challenge_id": "challenge-123",
"code": "654321",
"client_public_key": validClientPublicKey,
"time_zone": publicConfirmTimeZone,
})
assert.Equal(t, http.StatusBadRequest, response.StatusCode)
@@ -192,6 +219,7 @@ func TestPublicHTTPEndToEndThrottledChallengeConfirmReturnsInvalidCode(t *testin
"challenge_id": "challenge-123",
"code": "123456",
"client_public_key": validClientPublicKey,
"time_zone": publicConfirmTimeZone,
})
assert.Equal(t, http.StatusBadRequest, response.StatusCode)
@@ -226,6 +254,7 @@ func TestPublicHTTPEndToEndSessionLimitExceeded(t *testing.T) {
"challenge_id": "challenge-1",
"code": attempts[0].Input.Code,
"client_public_key": validClientPublicKey,
"time_zone": publicConfirmTimeZone,
})
assert.Equal(t, http.StatusConflict, confirmResponse.StatusCode)
@@ -35,6 +35,7 @@ type confirmEmailCodeRequest struct {
ChallengeID string `json:"challenge_id"`
Code string `json:"code"`
ClientPublicKey string `json:"client_public_key"`
TimeZone string `json:"time_zone"`
}
type confirmEmailCodeResponse struct {
@@ -142,6 +143,7 @@ func handleConfirmEmailCode(useCase ConfirmEmailCodeUseCase, timeout time.Durati
ChallengeID: request.ChallengeID,
Code: request.Code,
ClientPublicKey: request.ClientPublicKey,
TimeZone: request.TimeZone,
})
if err != nil {
abortWithProjection(c, projectConfirmEmailCodeError(err))
@@ -195,6 +197,11 @@ func validateConfirmEmailCodeRequest(request *confirmEmailCodeRequest) error {
return errors.New("client_public_key must not be empty")
}
request.TimeZone = strings.TrimSpace(request.TimeZone)
if request.TimeZone == "" {
return errors.New("time_zone must not be empty")
}
return nil
}
@@ -19,6 +19,8 @@ import (
"go.uber.org/zap/zapcore"
)
const publicConfirmTimeZone = "Europe/Kaliningrad"
func TestSendEmailCodeHandlerSuccess(t *testing.T) {
t.Parallel()
@@ -58,6 +60,7 @@ func TestConfirmEmailCodeHandlerSuccess(t *testing.T) {
ChallengeID: "challenge-123",
Code: "123456",
ClientPublicKey: "public-key-material",
TimeZone: publicConfirmTimeZone,
}, input)
return confirmemailcode.Result{DeviceSessionID: "device-session-123"}, nil
}),
@@ -67,7 +70,7 @@ func TestConfirmEmailCodeHandlerSuccess(t *testing.T) {
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/confirm-email-code",
bytes.NewBufferString(`{"challenge_id":" challenge-123 ","code":" 123456 ","client_public_key":" public-key-material "}`),
bytes.NewBufferString(`{"challenge_id":" challenge-123 ","code":" 123456 ","client_public_key":" public-key-material ","time_zone":" `+publicConfirmTimeZone+` "}`),
)
request.Header.Set("Content-Type", "application/json")
@@ -133,10 +136,17 @@ func TestPublicAuthHandlersRejectInvalidRequests(t *testing.T) {
{
name: "empty code",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":" ","client_public_key":"public-key-material"}`,
body: `{"challenge_id":"challenge-123","code":" ","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"code must not be empty"}}`,
},
{
name: "empty time zone",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":" "}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"time_zone must not be empty"}}`,
},
}
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
@@ -198,7 +208,7 @@ func TestPublicAuthHandlersMapServiceErrors(t *testing.T) {
{
name: "confirm invalid client public key",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
@@ -213,7 +223,7 @@ func TestPublicAuthHandlersMapServiceErrors(t *testing.T) {
{
name: "confirm challenge not found",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
@@ -228,7 +238,7 @@ func TestPublicAuthHandlersMapServiceErrors(t *testing.T) {
{
name: "confirm challenge expired",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
@@ -243,7 +253,7 @@ func TestPublicAuthHandlersMapServiceErrors(t *testing.T) {
{
name: "confirm blocked by policy",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
@@ -258,7 +268,7 @@ func TestPublicAuthHandlersMapServiceErrors(t *testing.T) {
{
name: "confirm session limit exceeded",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
@@ -273,7 +283,7 @@ func TestPublicAuthHandlersMapServiceErrors(t *testing.T) {
{
name: "confirm hides internal error",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
@@ -363,7 +373,7 @@ func TestPublicAuthHandlersRejectInvalidSuccessPayloads(t *testing.T) {
{
name: "confirm blank device session id",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
@@ -413,7 +423,7 @@ func TestPublicAuthLogsDoNotContainSensitiveFields(t *testing.T) {
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/confirm-email-code",
bytes.NewBufferString(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`),
bytes.NewBufferString(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"`+publicConfirmTimeZone+`"}`),
)
request.Header.Set("Content-Type", "application/json")
+61 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"galaxy/authsession/internal/domain/common"
"galaxy/authsession/internal/domain/userresolution"
@@ -23,7 +24,7 @@ type UserDirectory interface {
// EnsureUserByEmail returns an existing user for email, creates a new user
// when registration is allowed, or reports a blocked outcome when the
// address may not continue through confirm flow.
EnsureUserByEmail(ctx context.Context, email common.Email) (EnsureUserResult, error)
EnsureUserByEmail(ctx context.Context, input EnsureUserInput) (EnsureUserResult, error)
// BlockByUserID applies a block state to the user identified by
// input.UserID. Implementations must wrap ErrNotFound when input.UserID does
@@ -35,6 +36,65 @@ type UserDirectory interface {
BlockByEmail(ctx context.Context, input BlockUserByEmailInput) (BlockUserResult, error)
}
// EnsureUserInput describes one user-directory ensure request keyed by the
// normalized e-mail address.
type EnsureUserInput struct {
// Email identifies the normalized e-mail address that should resolve to an
// existing user, a newly created user, or a blocked outcome.
Email common.Email
// RegistrationContext carries create-only user initialization fields. The
// user directory must ignore this context for existing users.
RegistrationContext *RegistrationContext
}
// Validate reports whether EnsureUserInput contains a complete request.
func (i EnsureUserInput) Validate() error {
if err := i.Email.Validate(); err != nil {
return fmt.Errorf("ensure user input email: %w", err)
}
if i.RegistrationContext != nil {
if err := i.RegistrationContext.Validate(); err != nil {
return fmt.Errorf("ensure user input registration context: %w", err)
}
}
return nil
}
// RegistrationContext describes create-only user initialization fields
// forwarded from the public confirm-email-code flow.
type RegistrationContext struct {
// PreferredLanguage stores the BCP 47 language tag that should initialize a
// newly created user. During the current rollout phase Auth / Session
// Service sends a temporary `"en"` default until gateway geoip derivation is
// deployed.
PreferredLanguage string
// TimeZone stores the client-selected IANA time zone name that should
// initialize a newly created user.
TimeZone string
}
// Validate reports whether RegistrationContext contains complete create-only
// initialization metadata.
func (c RegistrationContext) Validate() error {
if strings.TrimSpace(c.PreferredLanguage) == "" {
return errors.New("preferred language must not be empty")
}
if strings.TrimSpace(c.PreferredLanguage) != c.PreferredLanguage {
return errors.New("preferred language must not contain surrounding whitespace")
}
if strings.TrimSpace(c.TimeZone) == "" {
return errors.New("time zone must not be empty")
}
if strings.TrimSpace(c.TimeZone) != c.TimeZone {
return errors.New("time zone must not contain surrounding whitespace")
}
return nil
}
// EnsureUserOutcome identifies the coarse outcome of ensuring a user record
// for one normalized e-mail address.
type EnsureUserOutcome string
@@ -17,6 +17,7 @@ import (
)
const blockFlowPublicKey = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
const blockFlowTimeZone = "Europe/Kaliningrad"
func TestBlockUserAffectsLaterSendAndConfirmFlows(t *testing.T) {
t.Parallel()
@@ -81,6 +82,7 @@ func TestBlockUserAffectsLaterSendAndConfirmFlows(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: blockFlowPublicKey,
TimeZone: blockFlowTimeZone,
})
require.Error(t, err)
assert.Equal(t, shared.ErrorCodeBlockedByPolicy, shared.CodeOf(err))
@@ -28,6 +28,7 @@ func TestExecuteReturnsInvalidCodeForThrottledChallengeWithoutConsumingAttempts(
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
require.Error(t, err)
assert.Equal(t, shared.ErrorCodeInvalidCode, shared.CodeOf(err))
@@ -31,6 +31,7 @@ func TestExecuteConfirmsChallengeAfterTransientProjectionPublishFailures(t *test
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
require.NoError(t, err)
assert.Equal(t, "device-session-1", result.DeviceSessionID)
@@ -57,6 +58,7 @@ func TestExecuteConfirmedRetryRepublishesAfterTransientProjectionPublishFailures
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
require.NoError(t, err)
assert.Equal(t, "device-session-1", result.DeviceSessionID)
@@ -79,6 +81,7 @@ func TestExecuteRepairsProjectionOnIdenticalRetryAfterExhaustedPublishRetries(t
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
require.Error(t, err)
assert.Equal(t, shared.ErrorCodeServiceUnavailable, shared.CodeOf(err))
@@ -99,6 +102,7 @@ func TestExecuteRepairsProjectionOnIdenticalRetryAfterExhaustedPublishRetries(t
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
require.NoError(t, err)
assert.Equal(t, "device-session-1", result.DeviceSessionID)
@@ -19,9 +19,10 @@ import (
)
const (
revokeReasonConfirmRace common.RevokeReasonCode = "confirm_race_repair"
revokeActorTypeService common.RevokeActorType = "service"
revokeActorIDService = "confirmemailcode"
revokeReasonConfirmRace common.RevokeReasonCode = "confirm_race_repair"
revokeActorTypeService common.RevokeActorType = "service"
revokeActorIDService = "confirmemailcode"
defaultPreferredLanguage = "en"
)
// Input describes one public confirm-email-code request.
@@ -35,6 +36,11 @@ type Input struct {
// ClientPublicKey is the base64-encoded raw 32-byte Ed25519 public key that
// should be registered for the created device session.
ClientPublicKey string
// TimeZone is the client-selected IANA time zone name that should be
// forwarded as create-only registration context when the user does not yet
// exist.
TimeZone string
}
// Result describes one public confirm-email-code response.
@@ -192,6 +198,10 @@ func (s *Service) Execute(ctx context.Context, input Input) (result Result, err
if err != nil {
return Result{}, err
}
timeZone, err := shared.ParseTimeZone(input.TimeZone)
if err != nil {
return Result{}, err
}
for attempt := 0; attempt < shared.MaxCompareAndSwapRetries; attempt++ {
current, err := s.challengeStore.Get(ctx, challengeID)
@@ -236,7 +246,13 @@ func (s *Service) Execute(ctx context.Context, input Input) (result Result, err
return Result{}, shared.InvalidCode()
}
ensureUserResult, err := s.userDirectory.EnsureUserByEmail(ctx, current.Email)
ensureUserResult, err := s.userDirectory.EnsureUserByEmail(ctx, ports.EnsureUserInput{
Email: current.Email,
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: defaultPreferredLanguage,
TimeZone: timeZone,
},
})
if err != nil {
return Result{}, shared.ServiceUnavailable(err)
}
@@ -11,10 +11,13 @@ import (
"galaxy/authsession/internal/domain/common"
"galaxy/authsession/internal/domain/devicesession"
"galaxy/authsession/internal/domain/userresolution"
"galaxy/authsession/internal/ports"
"galaxy/authsession/internal/service/shared"
"galaxy/authsession/internal/testkit"
)
const confirmEmailCodeTimeZone = "Europe/Kaliningrad"
func TestExecuteConfirmsChallengeForExistingUser(t *testing.T) {
t.Parallel()
@@ -31,6 +34,7 @@ func TestExecuteConfirmsChallengeForExistingUser(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if err != nil {
require.Failf(t, "test failed", "Execute() returned error: %v", err)
@@ -75,6 +79,7 @@ func TestExecuteConfirmsChallengeByCreatingUser(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if err != nil {
require.Failf(t, "test failed", "Execute() returned error: %v", err)
@@ -114,6 +119,7 @@ func TestExecuteConfirmsSuppressedChallenge(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if err != nil {
require.Failf(t, "test failed", "Execute() returned error: %v", err)
@@ -132,6 +138,7 @@ func TestExecuteReturnsChallengeNotFound(t *testing.T) {
ChallengeID: "missing",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeChallengeNotFound {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeChallengeNotFound)
@@ -152,6 +159,7 @@ func TestExecuteReturnsChallengeExpiredAndMarksExpired(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeChallengeExpired {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeChallengeExpired)
@@ -194,6 +202,7 @@ func TestExecuteReturnsChallengeExpiredForConfirmedChallengeAfterRetentionWindow
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeChallengeExpired {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeChallengeExpired)
@@ -220,12 +229,32 @@ func TestExecuteReturnsInvalidClientPublicKey(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: "invalid",
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeInvalidClientPublicKey {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidClientPublicKey)
}
}
func TestExecuteReturnsInvalidRequestForInvalidTimeZone(t *testing.T) {
t.Parallel()
service := mustNewConfirmService(t, newConfirmDeps(t))
_, err := service.Execute(context.Background(), Input{
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: "Mars/Olympus",
})
if shared.CodeOf(err) != shared.ErrorCodeInvalidRequest {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidRequest)
}
if err == nil || err.Error() != "time_zone must be a valid IANA time zone name" {
require.Failf(t, "test failed", "Execute() error = %v, want invalid time_zone detail", err)
}
}
func TestExecuteInvalidCodeIncrementsAttempts(t *testing.T) {
t.Parallel()
@@ -239,6 +268,7 @@ func TestExecuteInvalidCodeIncrementsAttempts(t *testing.T) {
ChallengeID: "challenge-1",
Code: "000000",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeInvalidCode {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidCode)
@@ -268,6 +298,7 @@ func TestExecuteFifthInvalidAttemptMarksChallengeFailed(t *testing.T) {
ChallengeID: "challenge-1",
Code: "000000",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeInvalidCode {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidCode)
@@ -304,6 +335,7 @@ func TestExecuteDoesNotCreateSessionAfterTooManyAttempts(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeInvalidCode {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidCode)
@@ -337,6 +369,7 @@ func TestExecuteReturnsSameSessionIDForIdempotentRetryAndRepublishes(t *testing.
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if err != nil {
require.Failf(t, "test failed", "Execute() returned error: %v", err)
@@ -370,6 +403,7 @@ func TestExecuteReturnsInvalidCodeForDifferentKeyDuringIdempotentRetry(t *testin
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: alternatePublicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeInvalidCode {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidCode)
@@ -425,6 +459,7 @@ func TestExecuteReturnsInvalidCodeForNonConfirmableStates(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeInvalidCode {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidCode)
@@ -457,6 +492,7 @@ func TestExecuteMarksChallengeFailedAndReturnsBlockedByPolicy(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeBlockedByPolicy {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeBlockedByPolicy)
@@ -492,6 +528,7 @@ func TestExecuteReturnsSessionLimitExceededWithoutConsumingChallenge(t *testing.
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeSessionLimitExceeded {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeSessionLimitExceeded)
@@ -509,6 +546,57 @@ func TestExecuteReturnsSessionLimitExceededWithoutConsumingChallenge(t *testing.
}
}
func TestExecutePassesRegistrationContextToUserDirectory(t *testing.T) {
t.Parallel()
deps := newConfirmDeps(t)
recordingDirectory := &recordingEnsureUserDirectory{delegate: deps.userDirectory}
deps.userDirectory = nil
if err := recordingDirectory.delegate.QueueCreatedUserIDs(common.UserID("user-created")); err != nil {
require.Failf(t, "test failed", "QueueCreatedUserIDs() returned error: %v", err)
}
if err := deps.challengeStore.Create(context.Background(), sentChallengeFixture(t, deps.hasher, "challenge-1", "new@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))); err != nil {
require.Failf(t, "test failed", "Create() returned error: %v", err)
}
service, err := New(
deps.challengeStore,
deps.sessionStore,
recordingDirectory,
deps.configProvider,
deps.publisher,
deps.idGenerator,
deps.hasher,
testkit.FixedClock{Time: deps.now},
)
if err != nil {
require.Failf(t, "test failed", "New() returned error: %v", err)
}
_, err = service.Execute(context.Background(), Input{
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if err != nil {
require.Failf(t, "test failed", "Execute() returned error: %v", err)
}
if recordingDirectory.lastEnsureInput.Email != common.Email("new@example.com") {
require.Failf(t, "test failed", "last ensure email = %q, want %q", recordingDirectory.lastEnsureInput.Email, common.Email("new@example.com"))
}
if recordingDirectory.lastEnsureInput.RegistrationContext == nil {
require.FailNow(t, "last ensure registration context = nil, want value")
}
if recordingDirectory.lastEnsureInput.RegistrationContext.PreferredLanguage != "en" {
require.Failf(t, "test failed", "preferred language = %q, want %q", recordingDirectory.lastEnsureInput.RegistrationContext.PreferredLanguage, "en")
}
if recordingDirectory.lastEnsureInput.RegistrationContext.TimeZone != confirmEmailCodeTimeZone {
require.Failf(t, "test failed", "time zone = %q, want %q", recordingDirectory.lastEnsureInput.RegistrationContext.TimeZone, confirmEmailCodeTimeZone)
}
}
func TestExecuteReturnsServiceUnavailableThenSucceedsIdempotentlyAfterPublishFailure(t *testing.T) {
t.Parallel()
@@ -526,6 +614,7 @@ func TestExecuteReturnsServiceUnavailableThenSucceedsIdempotentlyAfterPublishFai
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeServiceUnavailable {
require.Failf(t, "test failed", "first Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeServiceUnavailable)
@@ -536,6 +625,7 @@ func TestExecuteReturnsServiceUnavailableThenSucceedsIdempotentlyAfterPublishFai
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if err != nil {
require.Failf(t, "test failed", "second Execute() returned error: %v", err)
@@ -680,3 +770,33 @@ func publicKeyString() string {
func alternatePublicKeyString() string {
return "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
}
// recordingEnsureUserDirectory records the last ensure input while delegating
// behavior to the in-memory testkit directory.
type recordingEnsureUserDirectory struct {
delegate *testkit.InMemoryUserDirectory
lastEnsureInput ports.EnsureUserInput
}
func (d *recordingEnsureUserDirectory) ResolveByEmail(ctx context.Context, email common.Email) (userresolution.Result, error) {
return d.delegate.ResolveByEmail(ctx, email)
}
func (d *recordingEnsureUserDirectory) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) {
return d.delegate.ExistsByUserID(ctx, userID)
}
func (d *recordingEnsureUserDirectory) EnsureUserByEmail(ctx context.Context, input ports.EnsureUserInput) (ports.EnsureUserResult, error) {
d.lastEnsureInput = input
return d.delegate.EnsureUserByEmail(ctx, input)
}
func (d *recordingEnsureUserDirectory) BlockByUserID(ctx context.Context, input ports.BlockUserByIDInput) (ports.BlockUserResult, error) {
return d.delegate.BlockByUserID(ctx, input)
}
func (d *recordingEnsureUserDirectory) BlockByEmail(ctx context.Context, input ports.BlockUserByEmailInput) (ports.BlockUserResult, error) {
return d.delegate.BlockByEmail(ctx, input)
}
var _ ports.UserDirectory = (*recordingEnsureUserDirectory)(nil)
@@ -51,6 +51,7 @@ func TestExecuteWithRuntimeStubUserDirectory(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
require.NoError(t, err)
assert.Equal(t, "device-session-1", result.DeviceSessionID)
@@ -92,6 +93,7 @@ func TestExecuteWithRuntimeStubUserDirectory(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
require.Error(t, err)
assert.Equal(t, shared.ErrorCodeBlockedByPolicy, shared.CodeOf(err))
@@ -44,6 +44,7 @@ func TestExecuteRecordsInvalidCodeMetricForThrottledChallenge(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
require.Error(t, err)
@@ -154,7 +154,7 @@ func (d *countingUserDirectory) ExistsByUserID(context.Context, common.UserID) (
return false, nil
}
func (d *countingUserDirectory) EnsureUserByEmail(context.Context, common.Email) (ports.EnsureUserResult, error) {
func (d *countingUserDirectory) EnsureUserByEmail(context.Context, ports.EnsureUserInput) (ports.EnsureUserResult, error) {
return ports.EnsureUserResult{}, nil
}
@@ -91,6 +91,20 @@ func ParseClientPublicKey(value string) (common.ClientPublicKey, error) {
return key, nil
}
// ParseTimeZone trims value and validates it as an IANA time zone name.
func ParseTimeZone(value string) (string, error) {
timeZone := NormalizeString(value)
if timeZone == "" {
return "", InvalidRequest("time_zone must not be empty")
}
if _, err := time.LoadLocation(timeZone); err != nil {
return "", InvalidRequest("time_zone must be a valid IANA time zone name")
}
return timeZone, nil
}
// ParseRevokeReasonCode trims value and validates it as one machine-readable
// revoke reason code.
func ParseRevokeReasonCode(value string) (common.RevokeReasonCode, error) {
@@ -34,6 +34,19 @@ func TestParseClientPublicKey(t *testing.T) {
assert.Equal(t, ErrorCodeInvalidClientPublicKey, CodeOf(err))
}
func TestParseTimeZone(t *testing.T) {
t.Parallel()
timeZone, err := ParseTimeZone(" Europe/Kaliningrad ")
require.NoError(t, err)
assert.Equal(t, "Europe/Kaliningrad", timeZone)
_, err = ParseTimeZone("Mars/Olympus")
require.Error(t, err)
assert.Equal(t, ErrorCodeInvalidRequest, CodeOf(err))
assert.Equal(t, "time_zone must be a valid IANA time zone name", err.Error())
}
func TestToSession(t *testing.T) {
t.Parallel()
@@ -63,13 +63,13 @@ func (d *InMemoryUserDirectory) ExistsByUserID(ctx context.Context, userID commo
return ok, nil
}
// EnsureUserByEmail returns an existing user for email, creates a new user
// when registration is allowed, or reports a blocked outcome.
func (d *InMemoryUserDirectory) EnsureUserByEmail(ctx context.Context, email common.Email) (ports.EnsureUserResult, error) {
// EnsureUserByEmail returns an existing user for input.Email, creates a new
// user when registration is allowed, or reports a blocked outcome.
func (d *InMemoryUserDirectory) EnsureUserByEmail(ctx context.Context, input ports.EnsureUserInput) (ports.EnsureUserResult, error) {
if err := ctx.Err(); err != nil {
return ports.EnsureUserResult{}, err
}
if err := email.Validate(); err != nil {
if err := input.Validate(); err != nil {
return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err)
}
@@ -83,7 +83,7 @@ func (d *InMemoryUserDirectory) EnsureUserByEmail(ctx context.Context, email com
d.emailByUserID = make(map[common.UserID]common.Email)
}
entry, ok := d.byEmail[email]
entry, ok := d.byEmail[input.Email]
if ok {
if !entry.BlockReasonCode.IsZero() {
result := ports.EnsureUserResult{
@@ -104,8 +104,8 @@ func (d *InMemoryUserDirectory) EnsureUserByEmail(ctx context.Context, email com
if err != nil {
return ports.EnsureUserResult{}, err
}
d.byEmail[email] = userDirectoryEntry{UserID: userID}
d.emailByUserID[userID] = email
d.byEmail[input.Email] = userDirectoryEntry{UserID: userID}
d.emailByUserID[userID] = input.Email
result := ports.EnsureUserResult{
Outcome: ports.EnsureUserOutcomeCreated,
@@ -94,7 +94,13 @@ func TestInMemoryUserDirectoryEnsureUserExistingCreatedAndBlocked(t *testing.T)
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := directory.EnsureUserByEmail(context.Background(), tt.email)
got, err := directory.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: tt.email,
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "en",
TimeZone: "Europe/Kaliningrad",
},
})
if err != nil {
require.Failf(t, "test failed", "EnsureUserByEmail() returned error: %v", err)
}
@@ -138,11 +138,7 @@ func TestProductionHardeningConcurrentIdenticalConfirmsConvergeToOneActiveSessio
})
challengeID, code := app.SendChallenge(t, gatewayCompatibilityEmail)
requestBody := map[string]string{
"challenge_id": challengeID,
"code": code,
"client_public_key": gatewayCompatibilityClientPublicKey,
}
requestBody := gatewayCompatibilityConfirmRequest(challengeID, code, gatewayCompatibilityClientPublicKey)
responses := make([]gatewayCompatibilityHTTPResponse, 2)
start := make(chan struct{})
@@ -220,11 +216,11 @@ func TestProductionHardeningConcurrentConfirmAndRevokeAllKeepProjectionConsisten
challengeID, code := app.SendChallenge(t, gatewayCompatibilityEmail)
confirmResponseCh := make(chan gatewayCompatibilityHTTPResponse, 1)
go func() {
confirmResponseCh <- gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": challengeID,
"code": code,
"client_public_key": gatewayCompatibilityClientPublicKey,
})
confirmResponseCh <- gatewayCompatibilityPostJSONValue(
t,
app.publicBaseURL+"/api/v1/public/auth/confirm-email-code",
gatewayCompatibilityConfirmRequest(challengeID, code, gatewayCompatibilityClientPublicKey),
)
}()
createdIDs := gate.WaitForCreates(t, 1)
@@ -279,11 +275,11 @@ func TestProductionHardeningConcurrentBlockUserAndConfirmDoNotLeakActiveSession(
confirmResponseCh := make(chan gatewayCompatibilityHTTPResponse, 1)
go func() {
confirmResponseCh <- gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": challengeID,
"code": code,
"client_public_key": gatewayCompatibilityClientPublicKey,
})
confirmResponseCh <- gatewayCompatibilityPostJSONValue(
t,
app.publicBaseURL+"/api/v1/public/auth/confirm-email-code",
gatewayCompatibilityConfirmRequest(challengeID, code, gatewayCompatibilityClientPublicKey),
)
}()
createdIDs := gate.WaitForCreates(t, 1)
@@ -324,11 +320,11 @@ func TestProductionHardeningConcurrentBlockUserAndConfirmDoNotLeakActiveSession(
assert.NotEmpty(t, sendBody.ChallengeID)
assert.Len(t, app.mailSender.RecordedAttempts(), 1)
followupConfirm := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": sendBody.ChallengeID,
"code": gatewayCompatibilityCode,
"client_public_key": gatewayCompatibilityClientPublicKey,
})
followupConfirm := gatewayCompatibilityPostJSONValue(
t,
app.publicBaseURL+"/api/v1/public/auth/confirm-email-code",
gatewayCompatibilityConfirmRequest(sendBody.ChallengeID, gatewayCompatibilityCode, gatewayCompatibilityClientPublicKey),
)
assert.Equal(t, http.StatusForbidden, followupConfirm.StatusCode)
assert.JSONEq(t, `{"error":{"code":"blocked_by_policy","message":"authentication is blocked by policy"}}`, followupConfirm.Body)
}
+25 -25
View File
@@ -426,11 +426,11 @@ func (a *hardeningApp) CreateSessionThroughPublicFlow(t *testing.T) string {
t.Helper()
challengeID, code := a.SendChallenge(t, gatewayCompatibilityEmail)
response := gatewayCompatibilityPostJSONValue(t, a.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": challengeID,
"code": code,
"client_public_key": gatewayCompatibilityClientPublicKey,
})
response := gatewayCompatibilityPostJSONValue(
t,
a.publicBaseURL+"/api/v1/public/auth/confirm-email-code",
gatewayCompatibilityConfirmRequest(challengeID, code, gatewayCompatibilityClientPublicKey),
)
assert.Equal(t, http.StatusOK, response.StatusCode)
var body struct {
@@ -557,22 +557,22 @@ func TestProductionHardeningConfirmRetryRepairsProjectionAfterProcessRestart(t *
})
challengeID, code := failingApp.SendChallenge(t, gatewayCompatibilityEmail)
firstConfirm := gatewayCompatibilityPostJSONValue(t, failingApp.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": challengeID,
"code": code,
"client_public_key": gatewayCompatibilityClientPublicKey,
})
firstConfirm := gatewayCompatibilityPostJSONValue(
t,
failingApp.publicBaseURL+"/api/v1/public/auth/confirm-email-code",
gatewayCompatibilityConfirmRequest(challengeID, code, gatewayCompatibilityClientPublicKey),
)
assert.Equal(t, http.StatusServiceUnavailable, firstConfirm.StatusCode)
assert.False(t, env.GatewayCacheExists(context.Background(), "device-session-1"))
failingApp.Close()
healthyApp := newHardeningApp(t, env, hardeningAppOptions{})
secondConfirm := gatewayCompatibilityPostJSONValue(t, healthyApp.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": challengeID,
"code": code,
"client_public_key": gatewayCompatibilityClientPublicKey,
})
secondConfirm := gatewayCompatibilityPostJSONValue(
t,
healthyApp.publicBaseURL+"/api/v1/public/auth/confirm-email-code",
gatewayCompatibilityConfirmRequest(challengeID, code, gatewayCompatibilityClientPublicKey),
)
assert.Equal(t, http.StatusOK, secondConfirm.StatusCode)
var body struct {
@@ -743,21 +743,21 @@ func TestProductionHardeningExpiredChallengeReturnsExpiredDuringGraceAndNotFound
require.NoError(t, record.Validate())
require.NoError(t, app.challengeStore.Create(context.Background(), record))
firstConfirm := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": "challenge-expired",
"code": gatewayCompatibilityCode,
"client_public_key": gatewayCompatibilityClientPublicKey,
})
firstConfirm := gatewayCompatibilityPostJSONValue(
t,
app.publicBaseURL+"/api/v1/public/auth/confirm-email-code",
gatewayCompatibilityConfirmRequest("challenge-expired", gatewayCompatibilityCode, gatewayCompatibilityClientPublicKey),
)
assert.Equal(t, http.StatusGone, firstConfirm.StatusCode)
assert.JSONEq(t, `{"error":{"code":"challenge_expired","message":"challenge expired"}}`, firstConfirm.Body)
env.FastForward(t, 5*time.Minute+time.Second)
secondConfirm := gatewayCompatibilityPostJSONValue(t, app.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": "challenge-expired",
"code": gatewayCompatibilityCode,
"client_public_key": gatewayCompatibilityClientPublicKey,
})
secondConfirm := gatewayCompatibilityPostJSONValue(
t,
app.publicBaseURL+"/api/v1/public/auth/confirm-email-code",
gatewayCompatibilityConfirmRequest("challenge-expired", gatewayCompatibilityCode, gatewayCompatibilityClientPublicKey),
)
assert.Equal(t, http.StatusNotFound, secondConfirm.StatusCode)
assert.JSONEq(t, `{"error":{"code":"challenge_not_found","message":"challenge not found"}}`, secondConfirm.Body)
}
@@ -72,21 +72,21 @@ func TestUserServiceRESTCompatibilityPublicConfirmUsesEnsureOutcomes(t *testing.
createdChallengeID := harness.sendChallengeID(t, "created@example.com")
blockedChallengeID := harness.sendChallengeID(t, "blocked@example.com")
existing := gatewayCompatibilityPostJSONValue(t, harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": existingChallengeID,
"code": userServiceRESTCompatibilityCode,
"client_public_key": gatewayCompatibilityClientPublicKey,
})
created := gatewayCompatibilityPostJSONValue(t, harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": createdChallengeID,
"code": userServiceRESTCompatibilityCode,
"client_public_key": gatewayCompatibilityClientPublicKey,
})
blocked := gatewayCompatibilityPostJSONValue(t, harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": blockedChallengeID,
"code": userServiceRESTCompatibilityCode,
"client_public_key": gatewayCompatibilityClientPublicKey,
})
existing := gatewayCompatibilityPostJSONValue(
t,
harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code",
gatewayCompatibilityConfirmRequest(existingChallengeID, userServiceRESTCompatibilityCode, gatewayCompatibilityClientPublicKey),
)
created := gatewayCompatibilityPostJSONValue(
t,
harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code",
gatewayCompatibilityConfirmRequest(createdChallengeID, userServiceRESTCompatibilityCode, gatewayCompatibilityClientPublicKey),
)
blocked := gatewayCompatibilityPostJSONValue(
t,
harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code",
gatewayCompatibilityConfirmRequest(blockedChallengeID, userServiceRESTCompatibilityCode, gatewayCompatibilityClientPublicKey),
)
assert.Equal(t, http.StatusOK, existing.StatusCode)
assert.JSONEq(t, `{"device_session_id":"device-session-1"}`, existing.Body)
@@ -326,13 +326,39 @@ func newUserServiceStubHandler(directory *userservice.StubDirectory) http.Handle
writeUserServiceStubJSON(writer, http.StatusOK, map[string]bool{"exists": exists})
case request.Method == http.MethodPost && request.URL.Path == "/api/v1/internal/users/ensure-by-email":
var input struct {
Email string `json:"email"`
Email string `json:"email"`
RegistrationContext *struct {
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
} `json:"registration_context"`
}
if !decodeUserServiceStubRequest(writer, request, &input) {
return
}
result, err := directory.EnsureUserByEmail(request.Context(), common.Email(input.Email))
ensureInput := ports.EnsureUserInput{
Email: common.Email(input.Email),
}
if input.RegistrationContext != nil {
ensureInput.RegistrationContext = &ports.RegistrationContext{
PreferredLanguage: input.RegistrationContext.PreferredLanguage,
TimeZone: input.RegistrationContext.TimeZone,
}
}
if ensureInput.RegistrationContext == nil {
writeUserServiceStubError(writer, http.StatusBadRequest, errors.New("registration_context must be present"))
return
}
if ensureInput.RegistrationContext.PreferredLanguage != "en" {
writeUserServiceStubError(writer, http.StatusBadRequest, errors.New("registration_context.preferred_language must equal en during rollout"))
return
}
if ensureInput.RegistrationContext.TimeZone != gatewayCompatibilityTimeZone {
writeUserServiceStubError(writer, http.StatusBadRequest, errors.New("registration_context.time_zone must match public confirm time_zone"))
return
}
result, err := directory.EnsureUserByEmail(request.Context(), ensureInput)
if err != nil {
writeUserServiceStubError(writer, http.StatusInternalServerError, err)
return
+6 -3
View File
@@ -109,11 +109,14 @@ The public REST listener read budgets are configured by:
The public auth JSON contract uses a challenge-token flow:
- `send-email-code` accepts `email` and returns `challenge_id`;
- `confirm-email-code` accepts `challenge_id`, `code`, and
`client_public_key`, then returns `device_session_id`.
- `confirm-email-code` accepts `challenge_id`, `code`,
`client_public_key`, and `time_zone`, then returns
`device_session_id`.
`client_public_key` is the standard base64-encoded raw 32-byte Ed25519 public
key for the device session being created.
`time_zone` is the client-selected IANA time zone name forwarded unchanged to
`Auth / Session Service`.
These routes remain unauthenticated and delegate only through an injected
`AuthServiceClient`.
@@ -950,7 +953,7 @@ Auth / Session Service.
The gateway contract is:
- `SendEmailCode(email) -> challenge_id`
- `ConfirmEmailCode(challenge_id, code, client_public_key) -> device_session_id`
- `ConfirmEmailCode(challenge_id, code, client_public_key, time_zone) -> device_session_id`
When no concrete implementation is wired, the gateway keeps the public routes
available and returns a stable `503 service_unavailable` response instead of
+2 -1
View File
@@ -39,7 +39,8 @@ curl -X POST http://127.0.0.1:8080/api/v1/public/auth/confirm-email-code \
-d '{
"challenge_id": "challenge-123",
"code": "123456",
"client_public_key": "11qYAYdk8v3K6Yw8QK6ZlQ2nP4Wm8Cq5g1H0K8vT9no="
"client_public_key": "11qYAYdk8v3K6Yw8QK6ZlQ2nP4Wm8Cq5g1H0K8vT9no=",
"time_zone": "Europe/Kaliningrad"
}'
```
+1 -1
View File
@@ -19,7 +19,7 @@ sequenceDiagram
Client->>Gateway: POST /api/v1/public/auth/confirm-email-code
Gateway->>Limiter: classify + rate-limit + body checks
Limiter-->>Gateway: allowed
Gateway->>Auth: ConfirmEmailCode(challenge_id, code, client_public_key)
Gateway->>Auth: ConfirmEmailCode(challenge_id, code, client_public_key, time_zone)
Auth-->>Gateway: device_session_id
Gateway-->>Client: 200 {device_session_id}
```
@@ -18,7 +18,7 @@ func TestPublicAntiAbuseRejectsOversizedBodies(t *testing.T) {
t.Parallel()
oversizedJSONBody := `{"email":"` + strings.Repeat("a", 8200) + `@example.com"}`
oversizedConfirmJSONBody := `{"challenge_id":"` + strings.Repeat("c", 8300) + `","code":"123456","client_public_key":"key"}`
oversizedConfirmJSONBody := `{"challenge_id":"` + strings.Repeat("c", 8300) + `","code":"123456","client_public_key":"key","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`
tests := []struct {
name string
@@ -282,9 +282,9 @@ func TestPublicAntiAbuseConfirmEmailIdentityThrottle(t *testing.T) {
}
handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AuthService: authService})
first := confirmEmailCodeRequest(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`)
second := confirmEmailCodeRequest(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`)
third := confirmEmailCodeRequest(`{"challenge_id":"challenge-456","code":"123456","client_public_key":"public-key-material"}`)
first := confirmEmailCodeRequest(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`)
second := confirmEmailCodeRequest(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`)
third := confirmEmailCodeRequest(`{"challenge_id":"challenge-456","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`)
firstResp := httptest.NewRecorder()
handler.ServeHTTP(firstResp, first)
+10
View File
@@ -79,6 +79,11 @@ type ConfirmEmailCodeInput struct {
// ClientPublicKey is the standard base64-encoded raw 32-byte Ed25519 public
// key that should be registered for the created device session.
ClientPublicKey string `json:"client_public_key"`
// TimeZone is the client-selected IANA time zone name forwarded to the
// Auth / Session Service as registration context for first-time user
// creation.
TimeZone string `json:"time_zone"`
}
// ConfirmEmailCodeResult describes the public REST and adapter payload
@@ -391,6 +396,11 @@ func validateConfirmEmailCodeInput(input *ConfirmEmailCodeInput) error {
return errors.New("client_public_key must not be empty")
}
input.TimeZone = strings.TrimSpace(input.TimeZone)
if input.TimeZone == "" {
return errors.New("time_zone must not be empty")
}
return nil
}
+18 -6
View File
@@ -16,6 +16,8 @@ import (
"github.com/stretchr/testify/require"
)
const confirmEmailCodeTestTimeZone = "Europe/Kaliningrad"
func TestSendEmailCodeHandlerSuccess(t *testing.T) {
t.Parallel()
@@ -59,7 +61,7 @@ func TestConfirmEmailCodeHandlerSuccess(t *testing.T) {
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/confirm-email-code",
strings.NewReader(`{"challenge_id":" challenge-123 ","code":" 123456 ","client_public_key":" public-key-material "}`),
strings.NewReader(`{"challenge_id":" challenge-123 ","code":" 123456 ","client_public_key":" public-key-material ","time_zone":" `+confirmEmailCodeTestTimeZone+` "}`),
)
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
@@ -75,6 +77,7 @@ func TestConfirmEmailCodeHandlerSuccess(t *testing.T) {
ChallengeID: "challenge-123",
Code: "123456",
ClientPublicKey: "public-key-material",
TimeZone: confirmEmailCodeTestTimeZone,
}, authService.confirmEmailCodeInput)
assert.True(t, authService.confirmEmailCodeRouteClassOK)
assert.Equal(t, PublicRouteClassPublicAuth, authService.confirmEmailCodeRouteClass)
@@ -113,12 +116,21 @@ func TestPublicAuthHandlersRejectInvalidRequests(t *testing.T) {
{
name: "confirm email empty code",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":" ","client_public_key":"public-key-material"}`,
body: `{"challenge_id":"challenge-123","code":" ","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"code must not be empty"}}`,
wantSendCalls: 0,
wantConfirmCalls: 0,
},
{
name: "confirm email empty time zone",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":" "}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"time_zone must not be empty"}}`,
wantSendCalls: 0,
wantConfirmCalls: 0,
},
}
for _, tt := range tests {
@@ -159,7 +171,7 @@ func TestPublicAuthHandlersMapAdapterErrors(t *testing.T) {
{
name: "auth service projected bad request",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`,
authClient: &recordingAuthServiceClient{
confirmEmailCodeErr: &AuthServiceError{
StatusCode: http.StatusBadRequest,
@@ -187,7 +199,7 @@ func TestPublicAuthHandlersMapAdapterErrors(t *testing.T) {
{
name: "auth service projected gateway normalizes blank gateway error fields",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`,
authClient: &recordingAuthServiceClient{
confirmEmailCodeErr: &AuthServiceError{
StatusCode: http.StatusBadGateway,
@@ -253,7 +265,7 @@ func TestDefaultAuthServiceReturnsServiceUnavailable(t *testing.T) {
name: "confirm email code",
method: http.MethodPost,
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`,
wantStatus: http.StatusServiceUnavailable,
wantBody: `{"error":{"code":"service_unavailable","message":"auth service is unavailable"}}`,
},
@@ -325,7 +337,7 @@ func TestPublicAuthLogsDoNotContainSensitiveFields(t *testing.T) {
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/confirm-email-code",
strings.NewReader(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`),
strings.NewReader(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"`+confirmEmailCodeTestTimeZone+`"}`),
)
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
+12 -2
View File
@@ -192,8 +192,10 @@ paths:
description: |
Completes a previously issued `challenge_id`, sends the verification
`code`, and registers the standard base64-encoded raw 32-byte Ed25519
`client_public_key` for the new device session. The response returns
the created `device_session_id`.
`client_public_key` for the new device session. The caller must also
supply the client-selected IANA `time_zone`, which the gateway forwards
unchanged to the Auth / Session Service. The response returns the
created `device_session_id`.
This route is unauthenticated and classified as `public_auth`.
Public REST anti-abuse applies a per-IP bucket derived from
@@ -221,6 +223,7 @@ paths:
challenge_id: challenge-123
code: "123456"
client_public_key: base64-encoded-raw-ed25519-public-key
time_zone: Europe/Kaliningrad
responses:
"200":
description: The device session was created by the Auth / Session Service.
@@ -296,6 +299,7 @@ components:
- challenge_id
- code
- client_public_key
- time_zone
properties:
challenge_id:
type: string
@@ -306,6 +310,12 @@ components:
client_public_key:
type: string
description: Standard base64-encoded raw 32-byte Ed25519 public key registered for the new device session.
time_zone:
type: string
description: |
Client-selected IANA time zone name forwarded to the Auth /
Session Service as registration context for first-time user
creation.
ConfirmEmailCodeResponse:
type: object
additionalProperties: false
+644
View File
@@ -0,0 +1,644 @@
# User Service Implementation Plan
## Planning Principles
This plan is aligned with the current repository architecture and is written
for an experienced middle-level Go developer implementing an internal trusted
microservice.
Execution priorities:
- preserve the frozen auth and geo ownership boundaries
- keep user state authoritative in one service
- prefer explicit command behavior over generic patch behavior
- keep synchronous read paths simple for auth and lobby
- separate current effective state from append-only or historical records where
fast reads matter
- keep the first version storage-agnostic at the domain boundary even if Redis
is the initial backend
## Stage 01 — Freeze Vocabulary, Contracts, and Cross-Service Ownership
### Goal
Remove naming ambiguity and freeze the service boundary before implementation.
### Tasks
- Freeze the regular-user-only scope of `User Service`.
- Freeze that system-admin identity is out of scope and belongs to later
`Admin Service`.
- Freeze the self-service vocabulary:
- `GetMyAccount`
- `UpdateMyProfile`
- `UpdateMySettings`
- Freeze that `race_name` replaces `display_name`.
- Freeze the current ownership split for `declared_country`:
- current value in `User Service`
- workflow and history in `Geo Profile Service`
- Freeze the auth-facing internal REST endpoints already reserved by
`Auth / Session Service`.
- Freeze the need for create-only registration context on
`EnsureUserByEmail`.
### Deliverables
- service README with stable terminology
- short internal ADR or equivalent note for `declared_country` ownership split
- short internal ADR or equivalent note for regular-user versus admin identity
split
### Exit Criteria
- no unresolved naming conflict remains around `race_name`,
`declared_country`, entitlement, sanction, or limit semantics
- no service boundary question remains open for auth, lobby, or geo
### Targeted Tests
- none yet beyond documentation review
## Stage 02 — Define Domain Entities and Redis-Backed Logical State
### Goal
Describe the persistent state clearly enough that storage adapters can be built
without revisiting core semantics.
### Tasks
- Define logical entities for:
- user account
- race-name reservation
- blocked e-mail subject
- entitlement period record
- current entitlement snapshot
- sanction record
- limit record
- Freeze required fields, timestamps, and identifiers for each entity.
- Decide Redis logical key layout and lookup indexes without leaking them into
the domain layer.
- Freeze deterministic pagination keys for admin listing.
- Define how active/effective evaluation works for sanctions and limits.
### Deliverables
- domain entity definitions
- storage design notes for Redis keys and secondary indexes
- active/effective evaluation rules
### Exit Criteria
- every required read and mutation can map to a clear logical entity set
- Redis adapters can be implemented directly from the frozen logical model
### Targeted Tests
- domain validation tests for required fields
- tests for effective-state evaluation of active versus expired records
## Stage 03 — Implement Auth-Facing Resolution, Ensure, Existence, and E-Mail Blocking
### Goal
Provide the minimum trusted API needed by `Auth / Session Service`.
### Tasks
- Implement:
- resolve by e-mail
- ensure by e-mail
- exists by user id
- block by user id
- block by e-mail
- Preserve exact route shapes already reserved by the auth REST client.
- Implement the separate blocked-email-subject model.
- Make `BlockByEmail` idempotent for both existing-user and no-user cases.
- Ensure `ResolveByEmail` and `EnsureUserByEmail` both respect blocked-email
subjects.
### Deliverables
- trusted internal REST handlers for auth-facing endpoints
- domain services for resolution and block behavior
- Redis-backed storage for user existence and blocked-email subjects
### Exit Criteria
- auth can distinguish `existing`, `creatable`, and `blocked`
- blocked e-mail subjects prevent user creation before a user exists
- `BlockByUserID` and `BlockByEmail` are idempotent
### Targeted Tests
- resolve existing/creatable/blocked by e-mail
- ensure existing/created/blocked outcomes
- blocked e-mail subject prevents creation before user record exists
- block by user id on unknown user returns not found
- repeated block calls stay idempotent
## Stage 04 — Add New-User Creation Context from Auth
### Goal
Support first-login user creation with initial settings captured at confirm
time.
### Tasks
- Extend `EnsureUserByEmail` contract with create-only registration context:
- `preferred_language`
- `time_zone`
- Validate `preferred_language` as BCP 47.
- Validate `time_zone` as IANA TZ name.
- Generate initial `race_name` in `player-<shortid>` form during creation.
- Initialize the newly created user with:
- free entitlement
- no active sanctions
- no custom limits
- Ignore registration context for existing users.
- Document required follow-up changes in `gateway` and `authsession`.
### Deliverables
- extended ensure-by-email request model
- create-user domain service
- generated-race-name helper
### Exit Criteria
- first successful ensure-create path can fully initialize a new user
- existing-user ensure does not overwrite language or time zone
### Targeted Tests
- new user created with generated `race_name`, derived `preferred_language`,
and required client `time_zone`
- existing user ensure ignores create-only registration context
- invalid BCP 47 or IANA inputs are rejected on create path
## Stage 05 — Implement Self-Service Account Read and Split Profile/Settings Mutations
### Goal
Expose the minimal authenticated account surface routed by `Edge Gateway`.
### Tasks
- Implement `GetMyAccount`.
- Implement `UpdateMyProfile` for `race_name` only.
- Implement `UpdateMySettings` for:
- `preferred_language`
- `time_zone`
- Ensure `GetMyAccount` returns:
- account identity fields
- current entitlement snapshot
- active sanctions
- active effective limits
- read-only `declared_country`
- Reject attempts to mutate `email` or `declared_country` through self-service
flows.
- Enforce `profile_update_block` sanction on both self-service mutations.
### Deliverables
- authenticated application services for account read and updates
- gateway-facing handler or adapter contracts for future routing
- DTOs for account aggregate and mutation requests
### Exit Criteria
- authenticated users can read current account state in one aggregate
- profile and settings changes are clearly separated
- self-service updates cannot mutate forbidden fields
### Targeted Tests
- `GetMyAccount` returns current entitlement, active sanctions, active limits,
and read-only `declared_country`
- `UpdateMyProfile` cannot change email or `declared_country`
- `UpdateMySettings` validates BCP 47 and IANA values
- active `profile_update_block` denies both update flows
## Stage 06 — Implement race_name Uniqueness Policy Behind a Dedicated Interface
### Goal
Keep `race_name` uniqueness strict and replaceable.
### Tasks
- Introduce a dedicated race-name policy interface.
- Implement canonicalization for uniqueness checks:
- case-insensitive folding
- confusable anti-fraud normalization
- Add Redis-backed reservation storage for canonicalized keys.
- Preserve original casing for stored and returned `race_name`.
- Ensure rename flow handles reservation swap safely.
- Keep the interface narrow so a future shared name-catalog service can replace
the local implementation.
### Deliverables
- race-name policy interface
- local normalization implementation
- reservation adapter and conflict handling
### Exit Criteria
- no two users can hold conflicting `race_name` values under the frozen policy
- self-service rename is atomic with respect to uniqueness reservation
### Targeted Tests
- uniqueness rejects case-insensitive collisions
- uniqueness rejects common anti-fraud-confusable collisions
- rename releases the old reservation only after the new one is secured
- failed reservation backend causes mutation to fail closed
## Stage 07 — Implement Entitlement History Plus Materialized Current Snapshot
### Goal
Support both auditability and fast synchronous entitlement reads.
### Tasks
- Implement period-based entitlement history records.
- Implement a materialized current entitlement snapshot.
- Define the v1 plan catalog:
- `free`
- `paid_monthly`
- `paid_yearly`
- `paid_lifetime`
- Implement explicit trusted entitlement commands:
- grant paid access
- extend paid access
- revoke paid access
- Update current snapshot transactionally with each successful entitlement
mutation.
- Ensure the default new-user path creates the correct free snapshot.
### Deliverables
- entitlement domain model
- history store
- current snapshot store
- trusted entitlement command handlers
### Exit Criteria
- current effective entitlement is always readable without replaying history
- history and snapshot stay consistent across supported mutation paths
### Targeted Tests
- entitlement period mutations update the materialized current snapshot
correctly
- free default is created for new users
- extending or revoking access preserves deterministic current-state behavior
## Stage 08 — Implement Sanctions and Limit Records with Active/Effective Evaluation
### Goal
Support negative policy and quota overrides without scattering policy logic into
consumers.
### Tasks
- Implement sanction records with optional expiry.
- Implement limit records with numeric values and optional expiry.
- Freeze v1 sanction catalog:
- `login_block`
- `private_game_create_block`
- `private_game_manage_block`
- `game_join_block`
- `profile_update_block`
- Freeze v1 limit catalog:
- `max_owned_private_games`
- `max_active_private_games`
- `max_pending_public_applications`
- `max_pending_private_join_requests`
- `max_pending_private_invites_sent`
- `max_active_game_memberships`
- Implement active/effective evaluation with current time.
- Implement trusted explicit commands to apply/remove sanctions and set/remove
limits.
### Deliverables
- sanction model and store
- limit model and store
- effective-state evaluator
- trusted mutation handlers
### Exit Criteria
- active sanctions and active limits can be read consistently from one user
account view
- expired or removed records are not treated as active
### Targeted Tests
- active sanctions appear in account reads
- expired sanctions and limits stop affecting effective state
- applying and removing sanctions/limits is idempotent where appropriate
## Stage 09 — Implement Lobby Eligibility Snapshot API
### Goal
Give `Game Lobby` one synchronous read that contains everything it needs for
user-level access decisions.
### Tasks
- Design and implement one trusted query by `user_id`.
- Return:
- existence
- current entitlement snapshot
- active lobby-relevant sanctions
- effective lobby-relevant limits
- derived booleans for lobby decisions
- Keep the response read-optimized so lobby does not need multiple dependent
calls back into `User Service`.
- Define deterministic not-found behavior.
### Deliverables
- lobby eligibility query endpoint
- response DTO
- mapping from entitlement/sanction/limit state to derived eligibility fields
### Exit Criteria
- `Game Lobby` can decide create/join/manage eligibility from one read
- no extra fan-out to other user sub-queries is required
### Targeted Tests
- lobby eligibility snapshot reflects paid status, sanctions, and limits
- unknown user returns stable not-found behavior
- derived booleans remain consistent with raw effective state
## Stage 10 — Implement Geo declared_country Sync Command
### Goal
Support the current-country denormalization path owned by `Geo Profile Service`.
### Tasks
- Implement one explicit trusted command to sync current `declared_country`.
- Validate ISO alpha-2 input.
- Ensure the command updates only the current value on the user account.
- Do not add country history behavior to `User Service`.
- Preserve explicit not-found behavior for unknown `user_id`.
- Emit the corresponding auxiliary declared-country change event after a
successful commit.
### Deliverables
- geo-facing sync endpoint
- application service for country sync
- event publication on successful mutation
### Exit Criteria
- geo can synchronize current `declared_country` without introducing hidden
history in `User Service`
- unknown users are rejected deterministically
### Targeted Tests
- geo country sync changes only current `declared_country`
- invalid country codes are rejected
- country sync emits the correct auxiliary event after commit
## Stage 11 — Implement Admin Lookup, Filtered Listing, and Explicit Trusted Mutations
### Goal
Provide the operational surface required by future `Admin Service` and manual
operations.
### Tasks
- Implement exact reads by:
- `user_id`
- normalized `email`
- exact `race_name`
- Implement paginated listing with richer filters:
- paid/free state
- paid expiry
- current `declared_country`
- sanction code
- limit code
- eligibility markers
- Freeze deterministic ordering for the listing.
- Implement the explicit trusted command surface for:
- entitlement grant/extend/revoke
- sanction apply/remove
- limit set/remove
- declared-country sync
- Preserve audit metadata on every trusted mutation.
### Deliverables
- admin/internal read endpoints
- filtered listing endpoint
- explicit trusted mutation endpoints
### Exit Criteria
- future `Admin Service` can operate fully through this trusted API without
needing direct storage access
- list filtering and pagination are deterministic
### Targeted Tests
- admin listing filters behave deterministically
- exact lookups by `user_id`, email, and `race_name` resolve the correct user
- every trusted mutation preserves actor and reason metadata
## Stage 12 — Add Per-Domain-Area Async Events and Observability
### Goal
Make production behavior observable without treating events as the source of
truth.
### Tasks
- Publish per-domain-area events for:
- profile changes
- settings changes
- entitlement changes
- sanction changes
- limit changes
- declared-country changes
- Add structured logs for trusted mutations and critical failures.
- Add metrics for:
- auth-facing resolution outcomes
- user creation outcomes
- race-name reservation conflicts
- entitlement mutation outcomes
- sanction and limit mutation outcomes
- event publication failures
- Add tracing spans on synchronous internal request paths where useful.
### Deliverables
- event publisher integration
- structured logging hooks
- metrics and tracing instrumentation
### Exit Criteria
- mutation flows are observable in production without ad hoc logging
- event publication failure does not compromise source-of-truth persistence
### Targeted Tests
- async event publication failure does not lose source-of-truth state
- event payloads include minimum required metadata
- observability hooks do not change business behavior
## Stage 13 — Add Contract Tests Against Auth, Lobby, and Geo Expectations
### Goal
Verify the service not only in isolation, but against the internal contracts it
must satisfy for other services.
### Tasks
- Add compatibility tests against the frozen auth-facing REST contract.
- Add compatibility tests for the future ensure-by-email registration context.
- Add lobby eligibility snapshot contract tests.
- Add geo country-sync contract tests.
- Add account aggregate tests matching gateway-routed user expectations.
- Add tests for deterministic admin listing filters and ordering.
### Deliverables
- cross-service contract test suite
- test fixtures for auth/lobby/geo integration expectations
### Exit Criteria
- no ambiguity remains about service behavior expected by auth, lobby, or geo
- regressions in reserved internal contract shapes are caught automatically
### Targeted Tests
- new user created on first successful confirm with generated `race_name`,
derived `preferred_language`, and required client `time_zone`
- existing user confirm ignores create-only registration context
- blocked e-mail subject prevents user creation before a user record exists
- `GetMyAccount` returns current entitlement, active sanctions, active limits,
and read-only `declared_country`
- lobby eligibility snapshot reflects paid status, sanctions, and limits
- geo country sync changes only current `declared_country`
## Stage 14 — Add Rollout Notes for Gateway/Auth/OpenAPI Updates and Shared geoip
### Goal
Prepare the surrounding platform changes required for the service to work in
its intended end-to-end form.
### Tasks
- Document the required `gateway` public `confirm-email-code` addition of
`time_zone`.
- Document the required `authsession` public OpenAPI mirror change.
- Document the required `authsession -> user` ensure contract extension for
create-only registration context.
- Document the required shared `pkg/geoip` package for gateway and geo.
- Document README follow-up updates needed in `gateway` and `geoprofile`.
- Define rollout order so the cross-service contract changes do not land in an
unsafe sequence.
### Deliverables
- rollout checklist
- dependency order notes
- cross-repo or cross-module follow-up ticket list
### Exit Criteria
- the implementation can be integrated into surrounding services without
rediscovering hidden dependencies
- no required upstream or downstream change is left implicit
### Targeted Tests
- documentation review only
## Recommended First Working Slice
The smallest useful end-to-end slice is:
1. Stage 01
2. Stage 02
3. Stage 03
4. Stage 04
This slice makes it possible to support auth-driven user creation and blocking
before the rest of the service surface exists.
## Recommended Second Slice
The next highest-value slice is:
1. Stage 05
2. Stage 06
3. Stage 07
4. Stage 08
5. Stage 09
This slice gives the platform usable account reads, self-service profile and
settings updates, and the lobby eligibility integration.
## Final Acceptance Criteria
The first production-capable v1 of `User Service` should satisfy all of the
following:
- new users can be created through auth with generated `race_name`, derived
`preferred_language`, and required client `time_zone`
- existing-user auth confirm ignores create-only registration context
- blocked e-mail subjects prevent new-user creation before a user record exists
- `race_name` uniqueness rejects case-insensitive and anti-fraud-confusable
collisions
- `GetMyAccount` returns current entitlement, active sanctions, active limits,
and read-only `declared_country`
- `UpdateMyProfile` cannot change email or `declared_country`
- `UpdateMySettings` validates BCP 47 and IANA values
- entitlement period mutations update the materialized current snapshot
correctly
- lobby eligibility snapshot reflects paid status, sanctions, and limits
- geo `declared_country` sync changes only current account state
- admin listing filters and ordering are deterministic
- async event publication failure does not lose source-of-truth state
## Implementation Order Summary
Recommended implementation order:
1. freeze vocabulary and ownership
2. define domain entities and logical storage
3. build auth-facing resolution and blocking
4. add new-user creation context
5. build self-service account read and updates
6. add race-name uniqueness policy
7. build entitlement history and current snapshot
8. build sanctions and limits
9. add lobby eligibility snapshot
10. add geo country sync
11. add admin reads, listing, and mutations
12. add events and observability
13. add cross-service contract tests
14. document and sequence rollout dependencies
+801
View File
@@ -0,0 +1,801 @@
# User Service
## Context and Purpose
`User Service` is the internal source of truth for regular Galaxy Plus platform
users.
The service exists to solve six closely related problems:
- Own the durable platform user account identified by `user_id`.
- Store the current editable self-service profile and settings of a user.
- Materialize the current effective entitlement state used by the rest of the
platform.
- Store user-specific sanctions and limit overrides that affect access
decisions.
- Expose synchronous trusted APIs needed by `Auth / Session Service`,
`Game Lobby`, `Geo Profile Service`, and future administrative tooling.
- Publish auxiliary user-domain change events without turning events into the
source of truth.
The service is intentionally the owner of regular user identity only.
System-administrator identity is outside this service and belongs to the later
`Admin Service` architecture.
## Explicit Non-Goals
The following are intentionally out of scope for this service:
- Authentication challenges, device sessions, or request-signing state.
- System-administrator identity or administrator role management.
- Ownership of game membership, invites, roster, or per-game moderation.
- Automatic billing computation or payment-provider integration in v1.
- History of `declared_country` changes.
- Geo-IP lookup or country-review workflow logic.
- Direct public unauthenticated exposure.
- Using async events as the authoritative representation of user state.
## Place in the Existing Microservice System
`User Service` operates inside the trusted internal platform and integrates
with:
- `Edge Gateway`
- `Auth / Session Service`
- `Game Lobby`
- `Geo Profile Service`
- `Admin Service` later
- `Billing Service` later
- internal event bus
`Edge Gateway` routes authenticated user-facing account operations to this
service.
`Auth / Session Service` uses this service to resolve, create, and block users
during the public e-mail-code login flow.
`Game Lobby` uses this service for synchronous eligibility checks that depend
on current entitlement, sanctions, and limit state.
`Geo Profile Service` remains the owner of country-change workflow and history,
but synchronizes the latest effective `declared_country` into this service.
`Admin Service` will later use the trusted internal read and mutation APIs
defined here. Administrator accounts themselves still do not belong to
`User Service`.
`Billing Service` is future-only in v1 and will later feed entitlement
outcomes through the trusted entitlement mutation path defined here.
The event bus is an auxiliary propagation channel and not the source of truth.
## Responsibility Boundaries
`User Service` owns:
- regular platform `user_id`
- login/contact e-mail stored on the user account
- `race_name`
- `preferred_language`
- `time_zone`
- current effective `declared_country`
- current effective entitlement snapshot
- entitlement history records
- active and historical user sanctions
- active and historical user-specific limit overrides
- blocked e-mail subjects that may exist before any user record exists
- synchronous trusted reads used for auth, lobby, geo, and admin workflows
- auxiliary user-domain events
`User Service` does not own:
- system-admin accounts
- `device_session` or revoke state
- full payment history
- game ownership, game membership, or game-level bans
- `declared_country` history or approval workflow
- per-request country observations
## High-Level Architecture
```mermaid
flowchart LR
Gateway[Edge Gateway]
Auth[Auth / Session Service]
Lobby[Game Lobby]
Geo[Geo Profile Service]
Admin[Admin Service]
Billing[Billing Service]
User[User Service]
Redis[Redis]
Bus[Event Bus]
Gateway --> User
Auth --> User
Lobby --> User
Geo --> User
Admin --> User
Billing --> User
User --> Redis
User --> Bus
```
## Semantic Model
The service works with several core concepts.
### User Account
The user account is the canonical regular-user aggregate.
Required logical fields:
- `user_id`
- normalized `email`
- `race_name`
- `preferred_language`
- `time_zone`
- current effective `declared_country`
- creation timestamp
- last update timestamp
Important rules:
- `email` is the primary login/contact identifier for end users.
- `email` is not directly editable through self-service profile updates.
- future e-mail change is a separate confirm-based workflow and is not part of
v1 `User Service` mutations.
- `declared_country` is readable in account views but writable only through the
trusted geo sync path.
### race_name
`race_name` is the user-facing account name.
Properties:
- It is globally unique.
- It is not an identity key. Internal identity remains `user_id`, and end-user
login identity remains `email`.
- It is stored and returned in the original user-provided casing.
- Uniqueness is enforced through a dedicated policy boundary rather than by
naive string equality.
The uniqueness policy must at minimum:
- compare case-insensitively
- reject common confusable substitutions used for impersonation, such as
`I` versus `1`, `O` versus `0`, and `B` versus `8`
- remain replaceable behind a dedicated interface because a future shared name
catalog service is expected
### preferred_language and time_zone
`preferred_language` and `time_zone` are explicit user settings, not inferred
runtime facts.
Properties:
- `preferred_language` uses BCP 47 language tags.
- `time_zone` uses IANA time zone names.
- both values exist on every created user in v1
- both values are editable later through self-service settings mutation
Initial creation rules:
- `preferred_language` is supplied to `User Service` through auth create-only
registration context
- the value is derived by `Edge Gateway` from local geoip country plus local
country-to-language mapping
- when that lookup cannot determine a language, gateway falls back to `en`
- `time_zone` is supplied by the client in public `confirm-email-code`
`User Service` does not perform its own geo lookup for this purpose.
### declared_country
`declared_country` is the latest effective user-declared country.
Properties:
- It uses ISO 3166-1 alpha-2.
- It is the read-optimized current value only.
- It is owned for storage by `User Service`.
- It is owned for mutation workflow and history by `Geo Profile Service`.
This split is intentional:
- reads of current account state go to `User Service`
- reads of review workflow and country history go to `Geo Profile Service`
### Entitlement
Entitlement describes the paid/free access state of a user account.
The plan catalog fixed for v1 is:
- `free`
- `paid_monthly`
- `paid_yearly`
- `paid_lifetime`
The service stores both:
- immutable or append-only entitlement period history records
- a materialized current effective entitlement snapshot for synchronous reads
The current effective snapshot is not computed on every request. It is updated
when trusted entitlement mutations succeed.
Period history records store:
- `user_id`
- `plan_code`
- `source`
- actor identity or actor type
- `reason_code`
- `starts_at`
- optional `ends_at`
- creation timestamp
Current effective snapshot stores at minimum:
- `user_id`
- current `plan_code`
- effective paid/free state
- effective period bounds when applicable
- source metadata needed by operations and admin reads
- last recomputation timestamp
In v1, entitlement mutations come from explicit trusted admin/internal
commands. Later, `Billing Service` uses the same mutation path.
### Sanctions
Sanctions are negative policy records that may deny or restrict access
regardless of entitlement state.
The initial sanction set for v1 is:
- `login_block`
- `private_game_create_block`
- `private_game_manage_block`
- `game_join_block`
- `profile_update_block`
Each sanction record stores:
- `user_id`
- `sanction_code`
- scope
- `reason_code`
- actor identity or actor type
- `applied_at`
- optional `expires_at`
- optional removal metadata if later removed
Sanctions are typed records rather than inline booleans so the service can keep
auditability and deterministic active-state evaluation.
### User-Specific Limits
User-specific limits are count-based override records that shape access and
eligibility decisions.
The initial limit set for v1 is:
- `max_owned_private_games`
- `max_active_private_games`
- `max_pending_public_applications`
- `max_pending_private_join_requests`
- `max_pending_private_invites_sent`
- `max_active_game_memberships`
Each limit record stores:
- `user_id`
- `limit_code`
- numeric value
- `reason_code`
- actor identity or actor type
- `applied_at`
- optional `expires_at`
- optional removal metadata if later removed
Limit rules:
- limits are count-based only in v1
- limits are user-specific overrides, not the global default catalog itself
- effective eligibility combines entitlement-derived defaults with active
user-specific overrides
### Blocked E-Mail Subject
`User Service` must support blocking an e-mail subject before any user account
exists.
This requires a separate blocked-email-subject model.
Required logical fields:
- normalized `email`
- `reason_code`
- block timestamp
- optional actor metadata when available
- optional expiry or removal metadata if policy later requires it
- optional resolved `user_id` when the e-mail already belongs to an existing
user
This model exists to support `Auth / Session Service` flows such as
`BlockByEmail` and `ResolveByEmail` before user creation.
## Data Ownership Rules
The ownership split is intentional and must remain stable.
- `User Service` owns regular user identity and current effective account
state.
- `Auth / Session Service` owns login challenge and session lifecycle state.
- `Game Lobby` owns game membership and game-specific moderation.
- `Geo Profile Service` owns geo workflow and `declared_country` history.
- `Admin Service` later owns administrator identity and UI orchestration.
In particular:
- no service other than `Geo Profile Service` should mutate
`declared_country`
- no service other than `User Service` should create or edit regular user
profile/settings records
- no other service should maintain its own source of truth for current
entitlements
## User-Facing Interface Model
User-facing traffic reaches `User Service` only through authenticated gateway
routing.
The v1 aggregate query is:
- `GetMyAccount`
The v1 self-service mutations are:
- `UpdateMyProfile`
- `UpdateMySettings`
### GetMyAccount
`GetMyAccount` returns one read-optimized account aggregate for the currently
authenticated regular user.
The aggregate should include at minimum:
- `user_id`
- `email`
- `race_name`
- `preferred_language`
- `time_zone`
- current `declared_country`
- current entitlement snapshot
- active sanctions
- active effective limits
- account timestamps needed by clients
`declared_country` is read-only in this aggregate.
### UpdateMyProfile
`UpdateMyProfile` updates self-service profile fields only.
Editable fields in v1:
- `race_name`
Rules:
- e-mail cannot be changed here
- `declared_country` cannot be changed here
- `race_name` must pass global uniqueness policy before commit
- active `profile_update_block` sanction rejects the mutation
### UpdateMySettings
`UpdateMySettings` updates self-service settings only.
Editable fields in v1:
- `preferred_language`
- `time_zone`
Rules:
- values are validated as BCP 47 and IANA formats
- active `profile_update_block` sanction rejects the mutation
## Trusted Internal API Model
All service-to-service integration in v1 is documented as trusted JSON REST.
### Auth-Facing Contract
The auth-facing contract is already reserved by `Auth / Session Service` and
must remain stable.
Frozen endpoints:
- `POST /api/v1/internal/user-resolutions/by-email`
- `GET /api/v1/internal/users/{user_id}/exists`
- `POST /api/v1/internal/users/ensure-by-email`
- `POST /api/v1/internal/users/{user_id}/block`
- `POST /api/v1/internal/user-blocks/by-email`
Auth-facing behavior:
- resolve by e-mail returns `existing`, `creatable`, or `blocked`
- ensure by e-mail returns `existing`, `created`, or `blocked`
- block by user id and block by e-mail are idempotent
- blocked e-mail subjects are respected even when no user exists yet
`EnsureUserByEmail` is extended for v1 user creation context.
Recommended request shape:
```json
{
"email": "pilot@example.com",
"registration_context": {
"preferred_language": "en",
"time_zone": "Europe/Berlin"
}
}
```
Rules for `registration_context`:
- it is used only when the user is created
- it is ignored for an existing user
- it must not overwrite settings of an existing user
- it is required by the future auth contract because first successful confirm
may create the user
### Lobby-Facing Eligibility Snapshot
`Game Lobby` needs one synchronous query by `user_id`.
Purpose:
- determine whether the user currently may create or join game flows
- obtain effective quotas relevant to lobby decisions
The response should include at minimum:
- whether the user exists
- current entitlement snapshot
- active sanctions relevant to lobby actions
- effective limit values relevant to lobby actions
- derived booleans such as whether private-game creation is currently allowed
This query is intentionally one read-optimized snapshot rather than multiple
smaller cross-service round trips.
### Geo-Facing Declared Country Sync
`Geo Profile Service` needs one explicit trusted command to synchronize the
current effective `declared_country`.
Required behavior:
- update only the current `declared_country` value in `User Service`
- not create or manage country history here
- fail explicitly on unknown `user_id`
- remain synchronous so geo workflow can decide whether its own version record
becomes effective
### Admin/Internal Reads
The trusted admin/internal read surface must support:
- exact lookup by `user_id`
- exact lookup by normalized `email`
- exact lookup by exact `race_name`
- paginated listing with filters
The v1 listing filters must support at minimum:
- paid/free state
- paid expiry window
- current `declared_country`
- active sanction code
- active limit code
- relevant eligibility markers
Listing must use deterministic pagination and stable ordering.
Recommended default ordering is newest first by `created_at`, with `user_id`
used as the deterministic tiebreaker.
### Admin/Internal Mutations
Trusted mutations remain explicit-command based.
The minimum command vocabulary in v1 is:
- grant paid access
- extend paid access
- revoke paid access
- apply sanction
- remove sanction
- set limit
- remove limit
- sync declared country
These are intentionally explicit commands rather than one generic patch API.
The service should preserve reason and actor metadata on every trusted
administrative mutation.
## New User Creation Flow
```mermaid
sequenceDiagram
participant Client
participant Gateway
participant Auth as Auth / Session Service
participant User as User Service
Client->>Gateway: confirm-email-code(code, client_public_key, time_zone)
Gateway->>Gateway: local geoip lookup and country-to-language mapping
Gateway->>Auth: confirm-email-code(..., time_zone)
Auth->>User: ensure-by-email(email, registration_context)
alt user already exists
User-->>Auth: existing user_id
else new user
User->>User: create user with generated race_name
User->>User: initialize language, time zone, free entitlement
User-->>Auth: created user_id
end
Auth-->>Gateway: device_session_id
Gateway-->>Client: device_session_id
```
New-user defaults:
- generated `race_name` in `player-<shortid>` form
- `preferred_language` from gateway-derived registration context
- `time_zone` from client-provided registration context
- `free` entitlement
- no active sanctions
- no custom limit overrides
## Interface Between Entitlement, Sanction, and Limit Evaluation
The service must keep these three layers separate.
- entitlement provides the base paid/free access state
- sanctions can deny actions regardless of entitlement
- user-specific limits can narrow or override numeric quotas
This means:
- a paid user may still be denied private-game creation by sanction
- a non-blocked user may still be quota-limited by effective count limits
- lobby checks should consume one effective snapshot rather than reimplementing
this evaluation itself
## Events
Events are auxiliary notifications only. They are not the source of truth.
The service should emit per-domain-area events for:
- profile changes
- settings changes
- entitlement changes
- sanction changes
- limit changes
- declared-country changes
Recommended event classes:
- `user.profile.changed`
- `user.settings.changed`
- `user.entitlement.changed`
- `user.sanction.changed`
- `user.limit.changed`
- `user.declared_country.changed`
Each event should include at minimum:
- `user_id`
- event timestamp
- mutation source
- correlation or request metadata when available
- enough event-specific detail to identify the changed domain area
Loss of an event must not lose the authoritative business state.
## Data Entities
This section defines the core logical entities. These are domain entities, not
mandatory final physical Redis key names.
### User Account Record
Required logical fields:
- `user_id`
- normalized `email`
- `race_name`
- `preferred_language`
- `time_zone`
- current `declared_country`
- `created_at`
- `updated_at`
### race_name Reservation
Required logical fields:
- canonical uniqueness key produced by the race-name policy
- referenced `user_id`
- original stored `race_name`
- reservation timestamp
This entity exists so uniqueness policy stays replaceable and explicit.
### Blocked E-Mail Subject Entity
Required logical fields:
- normalized `email`
- `reason_code`
- block status
- `blocked_at`
- optional resolved `user_id`
### Entitlement Period Record
Required logical fields:
- `user_id`
- `plan_code`
- `source`
- actor metadata
- `reason_code`
- `starts_at`
- optional `ends_at`
- record creation timestamp
### Current Entitlement Snapshot
Required logical fields:
- `user_id`
- effective `plan_code`
- paid/free state
- effective period bounds
- source metadata
- snapshot update timestamp
### Sanction Record
Required logical fields:
- `user_id`
- `sanction_code`
- scope
- `reason_code`
- actor metadata
- `applied_at`
- optional `expires_at`
- current status metadata
### Limit Record
Required logical fields:
- `user_id`
- `limit_code`
- numeric value
- `reason_code`
- actor metadata
- `applied_at`
- optional `expires_at`
- current status metadata
## Failure and Degradation Model
The service is synchronous for critical reads and mutations.
### Auth Dependency Failure
If `User Service` is unavailable during auth flows:
- `Auth / Session Service` must fail the affected operation explicitly
- no user should be created partially without source-of-truth persistence
### Event Publication Failure
If event publication fails:
- the source-of-truth mutation still remains committed
- failure is logged and metered
- downstream consumers recover from direct reads if needed
### race_name Uniqueness Backend Failure
If the dedicated race-name uniqueness policy backend fails:
- self-service profile update must fail closed
- new-user creation must fail explicitly rather than create ambiguous names
### Geo Sync Failure
If `Geo Profile Service` cannot synchronize `declared_country` into
`User Service`:
- geo must treat the change as not yet effective
- `User Service` must not create hidden partial country history
## Minimal Initial API Surface
The minimum useful v1 API surface is:
- gateway-routed authenticated:
- `GetMyAccount`
- `UpdateMyProfile`
- `UpdateMySettings`
- trusted internal auth:
- resolve by e-mail
- ensure by e-mail
- exists by user id
- block by user id
- block by e-mail
- trusted internal lobby:
- get user eligibility snapshot
- trusted internal geo:
- sync current `declared_country`
- trusted internal admin:
- exact user reads
- filtered user listing
- entitlement mutations
- sanction mutations
- limit mutations
## Cross-Service Follow-Up Dependencies
The service design here depends on later follow-up work in other modules.
Required follow-up items:
- `Edge Gateway` public `confirm-email-code` contract must add required
`time_zone`.
- `Auth / Session Service` public OpenAPI must mirror the same `time_zone`
addition.
- `Auth / Session Service -> User Service` ensure-by-email contract must add
create-only registration context with `preferred_language` and `time_zone`.
- a shared `pkg/geoip` package must be introduced for `Edge Gateway` and
`Geo Profile Service`
- `gateway/README.md` should later document the local geoip dependency used for
initial language derivation
- `geoprofile/README.md` should later document the shared `pkg/geoip`
dependency explicitly alongside its own local geo lookup
## Design Trade-Offs Accepted by This Architecture
- Current entitlement is materialized for fast reads instead of computed from
history on each request.
- User-specific limits are count-based only in v1 to keep evaluation simple.
- `race_name` uniqueness is stricter than plain case-insensitive comparison to
reduce impersonation risk.
- `User Service` stores only the latest effective `declared_country` while geo
owns the workflow and version history.
- Explicit trusted commands are preferred over generic patch semantics so
administrative changes remain auditable and predictable.
## Implementation Readiness Statement
This service specification is intended to be implementation-ready for a first
production-capable internal version.
The main remaining work is not product ambiguity inside `User Service`, but
follow-up cross-service contract changes in `gateway`, `authsession`, and the
future shared `pkg/geoip` package.
+1376
View File
File diff suppressed because it is too large Load Diff