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
+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