feat: authsession service

This commit is contained in:
Ilia Denisov
2026-04-08 16:23:07 +02:00
committed by GitHub
parent 28f04916af
commit 86a68ed9d0
174 changed files with 31732 additions and 112 deletions
@@ -0,0 +1,3 @@
// Package publichttp exposes the public HTTP transport expected by the
// gateway-facing authentication flow.
package publichttp
@@ -0,0 +1,391 @@
package publichttp
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"galaxy/authsession/internal/adapters/mail"
"galaxy/authsession/internal/adapters/userservice"
"galaxy/authsession/internal/domain/challenge"
"galaxy/authsession/internal/domain/common"
"galaxy/authsession/internal/domain/devicesession"
"galaxy/authsession/internal/domain/userresolution"
"galaxy/authsession/internal/ports"
"galaxy/authsession/internal/service/confirmemailcode"
"galaxy/authsession/internal/service/sendemailcode"
"galaxy/authsession/internal/testkit"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPublicHTTPEndToEndSendThenConfirm(t *testing.T) {
t.Parallel()
app := newEndToEndApp(t, endToEndOptions{})
server := httptest.NewServer(app.handler)
defer server.Close()
sendResponse := postJSON(t, server.URL+"/api/v1/public/auth/send-email-code", `{"email":"pilot@example.com"}`)
assert.Equal(t, http.StatusOK, sendResponse.StatusCode)
assert.JSONEq(t, `{"challenge_id":"challenge-1"}`, sendResponse.Body)
attempts := app.mailSender.RecordedAttempts()
require.Len(t, attempts, 1)
confirmBody := map[string]string{
"challenge_id": "challenge-1",
"code": attempts[0].Input.Code,
"client_public_key": validClientPublicKey,
}
confirmResponse := postJSONValue(t, server.URL+"/api/v1/public/auth/confirm-email-code", confirmBody)
assert.Equal(t, http.StatusOK, confirmResponse.StatusCode)
assert.JSONEq(t, `{"device_session_id":"device-session-1"}`, confirmResponse.Body)
}
func TestPublicHTTPEndToEndBlockedSendReturnsChallengeID(t *testing.T) {
t.Parallel()
app := newEndToEndApp(t, endToEndOptions{
SeedBlockedEmail: true,
})
server := httptest.NewServer(app.handler)
defer server.Close()
response := postJSON(t, server.URL+"/api/v1/public/auth/send-email-code", `{"email":"pilot@example.com"}`)
assert.Equal(t, http.StatusOK, response.StatusCode)
assert.JSONEq(t, `{"challenge_id":"challenge-1"}`, response.Body)
assert.Empty(t, app.mailSender.RecordedAttempts())
}
func TestPublicHTTPEndToEndThrottledSendStillReturnsChallengeID(t *testing.T) {
t.Parallel()
app := newEndToEndApp(t, endToEndOptions{
AbuseProtector: &testkit.InMemorySendEmailCodeAbuseProtector{},
})
server := httptest.NewServer(app.handler)
defer server.Close()
first := postJSON(t, server.URL+"/api/v1/public/auth/send-email-code", `{"email":"pilot@example.com"}`)
assert.Equal(t, http.StatusOK, first.StatusCode)
assert.JSONEq(t, `{"challenge_id":"challenge-1"}`, first.Body)
second := postJSON(t, server.URL+"/api/v1/public/auth/send-email-code", `{"email":"pilot@example.com"}`)
assert.Equal(t, http.StatusOK, second.StatusCode)
assert.JSONEq(t, `{"challenge_id":"challenge-2"}`, second.Body)
assert.Len(t, app.mailSender.RecordedAttempts(), 1)
}
func TestPublicHTTPEndToEndInvalidClientPublicKey(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":"invalid"}`,
)
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 TestPublicHTTPEndToEndChallengeNotFound(t *testing.T) {
t.Parallel()
app := newEndToEndApp(t, endToEndOptions{})
server := httptest.NewServer(app.handler)
defer server.Close()
response := postJSONValue(t, server.URL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": "missing",
"code": "123456",
"client_public_key": validClientPublicKey,
})
assert.Equal(t, http.StatusNotFound, response.StatusCode)
assert.JSONEq(t, `{"error":{"code":"challenge_not_found","message":"challenge not found"}}`, response.Body)
}
func TestPublicHTTPEndToEndChallengeExpired(t *testing.T) {
t.Parallel()
app := newEndToEndApp(t, endToEndOptions{
SeedChallenge: seedChallengeOptions{
ID: "challenge-123",
Code: "123456",
Status: challenge.StatusSent,
ExpiresAt: time.Date(2026, 4, 5, 11, 59, 0, 0, time.UTC),
},
})
server := httptest.NewServer(app.handler)
defer server.Close()
response := postJSONValue(t, server.URL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": "challenge-123",
"code": "123456",
"client_public_key": validClientPublicKey,
})
assert.Equal(t, http.StatusGone, response.StatusCode)
assert.JSONEq(t, `{"error":{"code":"challenge_expired","message":"challenge expired"}}`, response.Body)
}
func TestPublicHTTPEndToEndInvalidCode(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 := postJSONValue(t, server.URL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": "challenge-123",
"code": "654321",
"client_public_key": validClientPublicKey,
})
assert.Equal(t, http.StatusBadRequest, response.StatusCode)
assert.JSONEq(t, `{"error":{"code":"invalid_code","message":"confirmation code is invalid"}}`, response.Body)
}
func TestPublicHTTPEndToEndThrottledChallengeConfirmReturnsInvalidCode(t *testing.T) {
t.Parallel()
app := newEndToEndApp(t, endToEndOptions{
SeedChallenge: seedChallengeOptions{
ID: "challenge-123",
Code: "123456",
Status: challenge.StatusDeliveryThrottled,
},
})
server := httptest.NewServer(app.handler)
defer server.Close()
response := postJSONValue(t, server.URL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": "challenge-123",
"code": "123456",
"client_public_key": validClientPublicKey,
})
assert.Equal(t, http.StatusBadRequest, response.StatusCode)
assert.JSONEq(t, `{"error":{"code":"invalid_code","message":"confirmation code is invalid"}}`, response.Body)
}
func TestPublicHTTPEndToEndSessionLimitExceeded(t *testing.T) {
t.Parallel()
limit := 1
app := newEndToEndApp(t, endToEndOptions{
Config: ports.SessionLimitConfig{ActiveSessionLimit: &limit},
SeedExistingUser: true,
SeedActiveSession: &devicesession.Session{
ID: common.DeviceSessionID("device-session-existing"),
UserID: common.UserID("user-1"),
ClientPublicKey: mustClientPublicKey(t, secondValidClientPublicKey),
Status: devicesession.StatusActive,
CreatedAt: time.Date(2026, 4, 5, 11, 58, 0, 0, time.UTC),
},
})
server := httptest.NewServer(app.handler)
defer server.Close()
sendResponse := postJSON(t, server.URL+"/api/v1/public/auth/send-email-code", `{"email":"pilot@example.com"}`)
assert.Equal(t, http.StatusOK, sendResponse.StatusCode)
attempts := app.mailSender.RecordedAttempts()
require.Len(t, attempts, 1)
confirmResponse := postJSONValue(t, server.URL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": "challenge-1",
"code": attempts[0].Input.Code,
"client_public_key": validClientPublicKey,
})
assert.Equal(t, http.StatusConflict, confirmResponse.StatusCode)
assert.JSONEq(t, `{"error":{"code":"session_limit_exceeded","message":"active session limit would be exceeded"}}`, confirmResponse.Body)
}
type endToEndOptions struct {
Config ports.SessionLimitConfig
AbuseProtector ports.SendEmailCodeAbuseProtector
SeedBlockedEmail bool
SeedExistingUser bool
SeedChallenge seedChallengeOptions
SeedActiveSession *devicesession.Session
}
type seedChallengeOptions struct {
ID string
Code string
Status challenge.Status
ExpiresAt time.Time
}
type endToEndApp struct {
handler http.Handler
mailSender *mail.StubSender
}
func newEndToEndApp(t *testing.T, options endToEndOptions) endToEndApp {
t.Helper()
now := time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC)
challengeStore := &testkit.InMemoryChallengeStore{}
sessionStore := &testkit.InMemorySessionStore{}
userDirectory := &userservice.StubDirectory{}
mailSender := &mail.StubSender{}
idGenerator := &testkit.SequenceIDGenerator{}
codeGenerator := testkit.FixedCodeGenerator{Code: "123456"}
codeHasher := testkit.DeterministicCodeHasher{}
clock := testkit.FixedClock{Time: now}
publisher := &testkit.RecordingProjectionPublisher{}
if options.SeedBlockedEmail {
require.NoError(t, userDirectory.SeedBlockedEmail(common.Email("pilot@example.com"), userresolution.BlockReasonCode("policy_blocked")))
}
if options.SeedExistingUser {
require.NoError(t, userDirectory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1")))
}
if options.SeedActiveSession != nil {
require.NoError(t, sessionStore.Create(context.Background(), *options.SeedActiveSession))
}
if options.SeedChallenge.ID != "" {
expiresAt := options.SeedChallenge.ExpiresAt
if expiresAt.IsZero() {
expiresAt = now.Add(challenge.InitialTTL)
}
record := challenge.Challenge{
ID: common.ChallengeID(options.SeedChallenge.ID),
Email: common.Email("pilot@example.com"),
CodeHash: mustHashCode(t, options.SeedChallenge.Code),
Status: options.SeedChallenge.Status,
DeliveryState: deliveryStateForSeedChallenge(options.SeedChallenge.Status),
CreatedAt: now.Add(-time.Minute),
ExpiresAt: expiresAt,
}
require.NoError(t, challengeStore.Create(context.Background(), record))
}
sendService, err := sendemailcode.NewWithRuntime(
challengeStore,
userDirectory,
idGenerator,
codeGenerator,
codeHasher,
mailSender,
options.AbuseProtector,
clock,
nil,
)
require.NoError(t, err)
confirmService, err := confirmemailcode.New(
challengeStore,
sessionStore,
userDirectory,
testkit.StaticConfigProvider{Config: options.Config},
publisher,
idGenerator,
codeHasher,
clock,
)
require.NoError(t, err)
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
SendEmailCode: sendService,
ConfirmEmailCode: confirmService,
})
return endToEndApp{
handler: handler,
mailSender: mailSender,
}
}
func deliveryStateForSeedChallenge(status challenge.Status) challenge.DeliveryState {
switch status {
case challenge.StatusDeliverySuppressed:
return challenge.DeliverySuppressed
case challenge.StatusDeliveryThrottled:
return challenge.DeliveryThrottled
default:
return challenge.DeliverySent
}
}
type httpResponse struct {
StatusCode int
Body string
}
func postJSON(t *testing.T, url string, body string) httpResponse {
t.Helper()
response, err := http.Post(url, "application/json", bytes.NewBufferString(body))
require.NoError(t, err)
defer response.Body.Close()
payload, err := io.ReadAll(response.Body)
require.NoError(t, err)
return httpResponse{StatusCode: response.StatusCode, Body: string(payload)}
}
func postJSONValue(t *testing.T, url string, value any) httpResponse {
t.Helper()
body, err := json.Marshal(value)
require.NoError(t, err)
return postJSON(t, url, string(body))
}
func mustHashCode(t *testing.T, code string) []byte {
t.Helper()
sum := sha256.Sum256([]byte(code))
return sum[:]
}
func mustClientPublicKey(t *testing.T, encoded string) common.ClientPublicKey {
t.Helper()
decoded, err := base64.StdEncoding.DecodeString(encoded)
require.NoError(t, err)
key, err := common.NewClientPublicKey(ed25519.PublicKey(decoded))
require.NoError(t, err)
return key
}
const (
validClientPublicKey = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
secondValidClientPublicKey = "ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8="
)
@@ -0,0 +1,242 @@
package publichttp
import (
"context"
"errors"
"fmt"
"net/http"
"net/mail"
"strings"
"sync"
"time"
"galaxy/authsession/internal/service/confirmemailcode"
"galaxy/authsession/internal/service/sendemailcode"
"galaxy/authsession/internal/service/shared"
"galaxy/authsession/internal/telemetry"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
)
const jsonContentType = "application/json; charset=utf-8"
const publicHTTPServiceName = "galaxy-authsession-public"
type sendEmailCodeRequest struct {
Email string `json:"email"`
}
type sendEmailCodeResponse struct {
ChallengeID string `json:"challenge_id"`
}
type confirmEmailCodeRequest struct {
ChallengeID string `json:"challenge_id"`
Code string `json:"code"`
ClientPublicKey string `json:"client_public_key"`
}
type confirmEmailCodeResponse struct {
DeviceSessionID string `json:"device_session_id"`
}
type errorResponse struct {
Error errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
var configureGinModeOnce sync.Once
func newHandlerWithConfig(cfg Config, deps Dependencies) (http.Handler, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
normalizedDeps, err := normalizeDependencies(deps)
if err != nil {
return nil, err
}
configureGinModeOnce.Do(func() {
gin.SetMode(gin.ReleaseMode)
})
engine := gin.New()
engine.Use(newOTelMiddleware(normalizedDeps.Telemetry))
engine.Use(withPublicObservability(normalizedDeps.Logger, normalizedDeps.Telemetry))
engine.POST(
"/api/v1/public/auth/send-email-code",
handleSendEmailCode(normalizedDeps.SendEmailCode, cfg.RequestTimeout),
)
engine.POST(
"/api/v1/public/auth/confirm-email-code",
handleConfirmEmailCode(normalizedDeps.ConfirmEmailCode, cfg.RequestTimeout),
)
return engine, nil
}
func newOTelMiddleware(runtime *telemetry.Runtime) gin.HandlerFunc {
options := []otelgin.Option{}
if runtime != nil {
options = append(
options,
otelgin.WithTracerProvider(runtime.TracerProvider()),
otelgin.WithMeterProvider(runtime.MeterProvider()),
)
}
return otelgin.Middleware(publicHTTPServiceName, options...)
}
func handleSendEmailCode(useCase SendEmailCodeUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request sendEmailCodeRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, projectSendEmailCodeError(shared.InvalidRequest(err.Error())))
return
}
if err := validateSendEmailCodeRequest(&request); err != nil {
abortWithProjection(c, projectSendEmailCodeError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, sendemailcode.Input{Email: request.Email})
if err != nil {
abortWithProjection(c, projectSendEmailCodeError(err))
return
}
if err := validateSendEmailCodeResult(&result); err != nil {
abortWithProjection(c, unavailableProjection(fmt.Errorf("send email code response: %w", err)))
return
}
c.JSON(http.StatusOK, sendEmailCodeResponse{ChallengeID: result.ChallengeID})
}
}
func handleConfirmEmailCode(useCase ConfirmEmailCodeUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request confirmEmailCodeRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, projectConfirmEmailCodeError(shared.InvalidRequest(err.Error())))
return
}
if err := validateConfirmEmailCodeRequest(&request); err != nil {
abortWithProjection(c, projectConfirmEmailCodeError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, confirmemailcode.Input{
ChallengeID: request.ChallengeID,
Code: request.Code,
ClientPublicKey: request.ClientPublicKey,
})
if err != nil {
abortWithProjection(c, projectConfirmEmailCodeError(err))
return
}
if err := validateConfirmEmailCodeResult(&result); err != nil {
abortWithProjection(c, unavailableProjection(fmt.Errorf("confirm email code response: %w", err)))
return
}
c.JSON(http.StatusOK, confirmEmailCodeResponse{DeviceSessionID: result.DeviceSessionID})
}
}
func validateSendEmailCodeRequest(request *sendEmailCodeRequest) error {
request.Email = strings.TrimSpace(request.Email)
if request.Email == "" {
return errors.New("email must not be empty")
}
parsedAddress, err := mail.ParseAddress(request.Email)
if err != nil || parsedAddress.Name != "" || parsedAddress.Address != request.Email {
return errors.New("email must be a single valid email address")
}
return nil
}
func validateSendEmailCodeResult(result *sendemailcode.Result) error {
result.ChallengeID = strings.TrimSpace(result.ChallengeID)
if result.ChallengeID == "" {
return errors.New("challenge_id must not be empty")
}
return nil
}
func validateConfirmEmailCodeRequest(request *confirmEmailCodeRequest) error {
request.ChallengeID = strings.TrimSpace(request.ChallengeID)
if request.ChallengeID == "" {
return errors.New("challenge_id must not be empty")
}
request.Code = strings.TrimSpace(request.Code)
if request.Code == "" {
return errors.New("code must not be empty")
}
request.ClientPublicKey = strings.TrimSpace(request.ClientPublicKey)
if request.ClientPublicKey == "" {
return errors.New("client_public_key must not be empty")
}
return nil
}
func validateConfirmEmailCodeResult(result *confirmemailcode.Result) error {
result.DeviceSessionID = strings.TrimSpace(result.DeviceSessionID)
if result.DeviceSessionID == "" {
return errors.New("device_session_id must not be empty")
}
return nil
}
func projectSendEmailCodeError(err error) shared.PublicErrorProjection {
if isTimeoutOrCanceled(err) {
return unavailableProjection(err)
}
projection := shared.ProjectPublicError(err)
if !shared.IsSendEmailCodePublicErrorCode(projection.Code) {
return unavailableProjection(err)
}
return projection
}
func projectConfirmEmailCodeError(err error) shared.PublicErrorProjection {
if isTimeoutOrCanceled(err) {
return unavailableProjection(err)
}
projection := shared.ProjectPublicError(err)
if !shared.IsConfirmEmailCodePublicErrorCode(projection.Code) {
return unavailableProjection(err)
}
return projection
}
func unavailableProjection(err error) shared.PublicErrorProjection {
return shared.ProjectPublicError(shared.ServiceUnavailable(err))
}
func isTimeoutOrCanceled(err error) bool {
return errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)
}
@@ -0,0 +1,463 @@
package publichttp
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"galaxy/authsession/internal/service/confirmemailcode"
"galaxy/authsession/internal/service/sendemailcode"
"galaxy/authsession/internal/service/shared"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func TestSendEmailCodeHandlerSuccess(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{ChallengeID: "challenge-123"}, nil
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, errors.New("unexpected call")
}),
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/send-email-code",
bytes.NewBufferString(`{"email":" pilot@example.com "}`),
)
request.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assert.JSONEq(t, `{"challenge_id":"challenge-123"}`, recorder.Body.String())
}
func TestConfirmEmailCodeHandlerSuccess(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(_ context.Context, input confirmemailcode.Input) (confirmemailcode.Result, error) {
assert.Equal(t, confirmemailcode.Input{
ChallengeID: "challenge-123",
Code: "123456",
ClientPublicKey: "public-key-material",
}, input)
return confirmemailcode.Result{DeviceSessionID: "device-session-123"}, nil
}),
})
recorder := httptest.NewRecorder()
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 "}`),
)
request.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assert.JSONEq(t, `{"device_session_id":"device-session-123"}`, recorder.Body.String())
}
func TestPublicAuthHandlersRejectInvalidRequests(t *testing.T) {
t.Parallel()
tests := []struct {
name string
target string
body string
wantStatus int
wantBody string
}{
{
name: "empty body",
target: "/api/v1/public/auth/send-email-code",
body: ``,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body must not be empty"}}`,
},
{
name: "malformed json",
target: "/api/v1/public/auth/send-email-code",
body: `{"email":`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body contains malformed JSON"}}`,
},
{
name: "multiple objects",
target: "/api/v1/public/auth/send-email-code",
body: `{"email":"pilot@example.com"}{"email":"next@example.com"}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body must contain a single JSON object"}}`,
},
{
name: "unknown field",
target: "/api/v1/public/auth/send-email-code",
body: `{"email":"pilot@example.com","extra":true}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body contains unknown field \"extra\""}}`,
},
{
name: "invalid json type",
target: "/api/v1/public/auth/send-email-code",
body: `{"email":123}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body contains an invalid value for \"email\""}}`,
},
{
name: "invalid email",
target: "/api/v1/public/auth/send-email-code",
body: `{"email":"not-an-email"}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"email must be a single valid email address"}}`,
},
{
name: "empty code",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":" ","client_public_key":"public-key-material"}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"code must not be empty"}}`,
},
}
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, errors.New("unexpected call")
}),
})
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, tt.target, bytes.NewBufferString(tt.body))
if tt.body != "" {
request.Header.Set("Content-Type", "application/json")
}
handler.ServeHTTP(recorder, request)
assert.Equal(t, tt.wantStatus, recorder.Code)
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assert.JSONEq(t, tt.wantBody, recorder.Body.String())
})
}
}
func TestPublicAuthHandlersMapServiceErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
target string
body string
deps Dependencies
wantStatus int
wantBody string
}{
{
name: "send route hides blocked by policy",
target: "/api/v1/public/auth/send-email-code",
body: `{"email":"pilot@example.com"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, shared.BlockedByPolicy()
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, errors.New("unexpected call")
}),
},
wantStatus: http.StatusServiceUnavailable,
wantBody: `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`,
},
{
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"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, shared.InvalidClientPublicKey()
}),
},
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_client_public_key","message":"client_public_key is not a valid base64-encoded raw 32-byte Ed25519 public key"}}`,
},
{
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"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, shared.ChallengeNotFound()
}),
},
wantStatus: http.StatusNotFound,
wantBody: `{"error":{"code":"challenge_not_found","message":"challenge not found"}}`,
},
{
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"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, shared.ChallengeExpired()
}),
},
wantStatus: http.StatusGone,
wantBody: `{"error":{"code":"challenge_expired","message":"challenge expired"}}`,
},
{
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"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, shared.BlockedByPolicy()
}),
},
wantStatus: http.StatusForbidden,
wantBody: `{"error":{"code":"blocked_by_policy","message":"authentication is blocked by policy"}}`,
},
{
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"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, shared.SessionLimitExceeded()
}),
},
wantStatus: http.StatusConflict,
wantBody: `{"error":{"code":"session_limit_exceeded","message":"active session limit would be exceeded"}}`,
},
{
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"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, shared.InternalError(errors.New("broken invariant"))
}),
},
wantStatus: http.StatusServiceUnavailable,
wantBody: `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, DefaultConfig(), tt.deps)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, tt.target, bytes.NewBufferString(tt.body))
request.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(recorder, request)
assert.Equal(t, tt.wantStatus, recorder.Code)
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assert.JSONEq(t, tt.wantBody, recorder.Body.String())
})
}
}
func TestPublicAuthHandlerTimeoutMapsToServiceUnavailable(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.RequestTimeout = 5 * time.Millisecond
handler := mustNewHandler(t, cfg, Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, context.DeadlineExceeded
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, errors.New("unexpected call")
}),
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/send-email-code",
bytes.NewBufferString(`{"email":"pilot@example.com"}`),
)
request.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusServiceUnavailable, recorder.Code)
assert.JSONEq(t, `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`, recorder.Body.String())
}
func TestPublicAuthHandlersRejectInvalidSuccessPayloads(t *testing.T) {
t.Parallel()
tests := []struct {
name string
target string
body string
deps Dependencies
wantBody string
}{
{
name: "send email blank challenge id",
target: "/api/v1/public/auth/send-email-code",
body: `{"email":"pilot@example.com"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{ChallengeID: " "}, nil
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, errors.New("unexpected call")
}),
},
wantBody: `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`,
},
{
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"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{DeviceSessionID: " "}, nil
}),
},
wantBody: `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, DefaultConfig(), tt.deps)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, tt.target, bytes.NewBufferString(tt.body))
request.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusServiceUnavailable, recorder.Code)
assert.JSONEq(t, tt.wantBody, recorder.Body.String())
})
}
}
func TestPublicAuthLogsDoNotContainSensitiveFields(t *testing.T) {
t.Parallel()
logger, buffer := newObservedLogger()
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
Logger: logger,
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{DeviceSessionID: "device-session-123"}, nil
}),
})
recorder := httptest.NewRecorder()
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"}`),
)
request.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
logOutput := buffer.String()
assert.NotContains(t, logOutput, "challenge-123")
assert.NotContains(t, logOutput, "123456")
assert.NotContains(t, logOutput, "public-key-material")
assert.NotContains(t, logOutput, "pilot@example.com")
assert.NotContains(t, logOutput, "device-session-123")
}
func mustNewHandler(t *testing.T, cfg Config, deps Dependencies) http.Handler {
t.Helper()
handler, err := newHandlerWithConfig(cfg, deps)
require.NoError(t, err)
return handler
}
type sendEmailCodeFunc func(ctx context.Context, input sendemailcode.Input) (sendemailcode.Result, error)
func (f sendEmailCodeFunc) Execute(ctx context.Context, input sendemailcode.Input) (sendemailcode.Result, error) {
return f(ctx, input)
}
type confirmEmailCodeFunc func(ctx context.Context, input confirmemailcode.Input) (confirmemailcode.Result, error)
func (f confirmEmailCodeFunc) Execute(ctx context.Context, input confirmemailcode.Input) (confirmemailcode.Result, error) {
return f(ctx, input)
}
func newObservedLogger() (*zap.Logger, *bytes.Buffer) {
buffer := &bytes.Buffer{}
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.TimeKey = ""
core := zapcore.NewCore(
zapcore.NewJSONEncoder(encoderConfig),
zapcore.AddSync(buffer),
zap.DebugLevel,
)
return zap.New(core), buffer
}
@@ -0,0 +1,93 @@
package publichttp
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"galaxy/authsession/internal/service/shared"
"github.com/gin-gonic/gin"
)
const publicErrorCodeContextKey = "public_error_code"
type malformedJSONRequestError struct {
message string
}
func (e *malformedJSONRequestError) Error() string {
if e == nil {
return ""
}
return e.message
}
func decodeJSONRequest(request *http.Request, target any) error {
if request == nil || request.Body == nil {
return &malformedJSONRequestError{message: "request body must not be empty"}
}
return decodeJSONReader(request.Body, target)
}
func decodeJSONReader(reader io.Reader, target any) error {
decoder := json.NewDecoder(reader)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return describeJSONDecodeError(err)
}
if err := decoder.Decode(&struct{}{}); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return &malformedJSONRequestError{message: "request body must contain a single JSON object"}
}
return &malformedJSONRequestError{message: "request body must contain a single JSON object"}
}
func describeJSONDecodeError(err error) error {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
switch {
case errors.Is(err, io.EOF):
return &malformedJSONRequestError{message: "request body must not be empty"}
case errors.As(err, &syntaxErr):
return &malformedJSONRequestError{message: "request body contains malformed JSON"}
case errors.Is(err, io.ErrUnexpectedEOF):
return &malformedJSONRequestError{message: "request body contains malformed JSON"}
case errors.As(err, &typeErr):
if strings.TrimSpace(typeErr.Field) != "" {
return &malformedJSONRequestError{
message: fmt.Sprintf("request body contains an invalid value for %q", typeErr.Field),
}
}
return &malformedJSONRequestError{message: "request body contains an invalid JSON value"}
case strings.HasPrefix(err.Error(), "json: unknown field "):
return &malformedJSONRequestError{
message: fmt.Sprintf("request body contains unknown field %s", strings.TrimPrefix(err.Error(), "json: unknown field ")),
}
default:
return &malformedJSONRequestError{message: "request body contains invalid JSON"}
}
}
func abortWithProjection(c *gin.Context, projection shared.PublicErrorProjection) {
c.Set(publicErrorCodeContextKey, projection.Code)
c.AbortWithStatusJSON(projection.StatusCode, errorResponse{
Error: errorBody{
Code: projection.Code,
Message: projection.Message,
},
})
}
@@ -0,0 +1,86 @@
package publichttp
import (
"time"
authlogging "galaxy/authsession/internal/logging"
"galaxy/authsession/internal/telemetry"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel/attribute"
"go.uber.org/zap"
)
type edgeOutcome string
const (
edgeOutcomeSuccess edgeOutcome = "success"
edgeOutcomeRejected edgeOutcome = "rejected"
edgeOutcomeFailed edgeOutcome = "failed"
)
func withPublicObservability(logger *zap.Logger, metrics *telemetry.Runtime) gin.HandlerFunc {
if logger == nil {
logger = zap.NewNop()
}
return func(c *gin.Context) {
start := time.Now()
c.Next()
statusCode := c.Writer.Status()
route := c.FullPath()
if route == "" {
route = "unmatched"
}
errorCode, _ := c.Get(publicErrorCodeContextKey)
errorCodeValue, _ := errorCode.(string)
outcome := outcomeFromStatusCode(statusCode)
duration := time.Since(start)
fields := []zap.Field{
zap.String("component", "public_http"),
zap.String("transport", "http"),
zap.String("route", route),
zap.String("method", c.Request.Method),
zap.Int("status_code", statusCode),
zap.Float64("duration_ms", float64(duration.Microseconds())/1000),
zap.String("edge_outcome", string(outcome)),
}
if errorCodeValue != "" {
fields = append(fields, zap.String("error_code", errorCodeValue))
}
fields = append(fields, authlogging.TraceFieldsFromContext(c.Request.Context())...)
metricAttrs := []attribute.KeyValue{
attribute.String("route", route),
attribute.String("method", c.Request.Method),
attribute.String("edge_outcome", string(outcome)),
}
if errorCodeValue != "" {
metricAttrs = append(metricAttrs, attribute.String("error_code", errorCodeValue))
}
metrics.RecordPublicHTTPRequest(c.Request.Context(), metricAttrs, duration)
switch outcome {
case edgeOutcomeSuccess:
logger.Info("public request completed", fields...)
case edgeOutcomeFailed:
logger.Error("public request failed", fields...)
default:
logger.Warn("public request rejected", fields...)
}
}
}
func outcomeFromStatusCode(statusCode int) edgeOutcome {
switch {
case statusCode >= 500:
return edgeOutcomeFailed
case statusCode >= 400:
return edgeOutcomeRejected
default:
return edgeOutcomeSuccess
}
}
@@ -0,0 +1,114 @@
package publichttp
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"galaxy/authsession/internal/service/confirmemailcode"
"galaxy/authsession/internal/service/sendemailcode"
authtelemetry "galaxy/authsession/internal/telemetry"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/attribute"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
)
func TestPublicHandlerEmitsTraceFieldsAndMetrics(t *testing.T) {
t.Parallel()
logger, buffer := newObservedLogger()
telemetryRuntime, reader, recorder := newObservedPublicTelemetryRuntime(t)
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
Logger: logger,
Telemetry: telemetryRuntime,
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{ChallengeID: "challenge-123"}, nil
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, nil
}),
})
recorderHTTP := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/send-email-code",
bytes.NewBufferString(`{"email":"pilot@example.com"}`),
)
request.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(recorderHTTP, request)
require.Equal(t, http.StatusOK, recorderHTTP.Code)
require.NotEmpty(t, recorder.Ended())
assert.Contains(t, buffer.String(), "otel_trace_id")
assert.Contains(t, buffer.String(), "otel_span_id")
assertMetricCount(t, reader, "authsession.public_http.requests", map[string]string{
"route": "/api/v1/public/auth/send-email-code",
"method": http.MethodPost,
"edge_outcome": "success",
}, 1)
}
func newObservedPublicTelemetryRuntime(t *testing.T) (*authtelemetry.Runtime, *sdkmetric.ManualReader, *tracetest.SpanRecorder) {
t.Helper()
reader := sdkmetric.NewManualReader()
meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
recorder := tracetest.NewSpanRecorder()
tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
runtime, err := authtelemetry.NewWithProviders(meterProvider, tracerProvider)
require.NoError(t, err)
return runtime, reader, recorder
}
func assertMetricCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantValue int64) {
t.Helper()
var resourceMetrics metricdata.ResourceMetrics
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
for _, metric := range scopeMetrics.Metrics {
if metric.Name != metricName {
continue
}
sum, ok := metric.Data.(metricdata.Sum[int64])
require.True(t, ok)
for _, point := range sum.DataPoints {
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
assert.Equal(t, wantValue, point.Value)
return
}
}
}
}
require.Failf(t, "test failed", "metric %q with attrs %v not found", metricName, wantAttrs)
}
func hasMetricAttributes(values []attribute.KeyValue, want map[string]string) bool {
if len(values) != len(want) {
return false
}
for _, value := range values {
if want[string(value.Key)] != value.Value.AsString() {
return false
}
}
return true
}
@@ -0,0 +1,228 @@
package publichttp
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"sync"
"time"
"galaxy/authsession/internal/service/confirmemailcode"
"galaxy/authsession/internal/service/sendemailcode"
"galaxy/authsession/internal/telemetry"
"go.uber.org/zap"
)
const (
defaultAddr = ":8080"
defaultReadHeaderTimeout = 2 * time.Second
defaultReadTimeout = 10 * time.Second
defaultIdleTimeout = time.Minute
defaultRequestTimeout = 3 * time.Second
)
// SendEmailCodeUseCase describes the public send-email-code application
// service consumed by the HTTP transport layer.
type SendEmailCodeUseCase interface {
// Execute validates input and creates a new login challenge.
Execute(ctx context.Context, input sendemailcode.Input) (sendemailcode.Result, error)
}
// ConfirmEmailCodeUseCase describes the public confirm-email-code application
// service consumed by the HTTP transport layer.
type ConfirmEmailCodeUseCase interface {
// Execute validates input and completes an existing login challenge.
Execute(ctx context.Context, input confirmemailcode.Input) (confirmemailcode.Result, error)
}
// Config describes the public HTTP listener owned by authsession.
type Config struct {
// Addr is the TCP listen address used by the public HTTP server.
Addr string
// ReadHeaderTimeout bounds how long the listener may spend reading request
// headers before the server rejects the connection.
ReadHeaderTimeout time.Duration
// ReadTimeout bounds how long the listener may spend reading one public
// request.
ReadTimeout time.Duration
// IdleTimeout bounds how long the listener keeps an idle keep-alive
// connection open.
IdleTimeout time.Duration
// RequestTimeout bounds one application-layer public-auth use-case call.
RequestTimeout time.Duration
}
// Validate reports whether cfg contains a usable public HTTP listener
// configuration.
func (cfg Config) Validate() error {
switch {
case cfg.Addr == "":
return errors.New("public HTTP addr must not be empty")
case cfg.ReadHeaderTimeout <= 0:
return errors.New("public HTTP read header timeout must be positive")
case cfg.ReadTimeout <= 0:
return errors.New("public HTTP read timeout must be positive")
case cfg.IdleTimeout <= 0:
return errors.New("public HTTP idle timeout must be positive")
case cfg.RequestTimeout <= 0:
return errors.New("public HTTP request timeout must be positive")
default:
return nil
}
}
// DefaultConfig returns the default public HTTP listener settings aligned with
// the gateway public-auth transport timeouts.
func DefaultConfig() Config {
return Config{
Addr: defaultAddr,
ReadHeaderTimeout: defaultReadHeaderTimeout,
ReadTimeout: defaultReadTimeout,
IdleTimeout: defaultIdleTimeout,
RequestTimeout: defaultRequestTimeout,
}
}
// Dependencies describes the collaborators used by the public HTTP transport
// layer.
type Dependencies struct {
// SendEmailCode executes the public send-email-code use case.
SendEmailCode SendEmailCodeUseCase
// ConfirmEmailCode executes the public confirm-email-code use case.
ConfirmEmailCode ConfirmEmailCodeUseCase
// Logger writes structured transport logs. When nil, a no-op logger is
// used.
Logger *zap.Logger
// Telemetry records OpenTelemetry spans and low-cardinality HTTP metrics.
// When nil, the transport still serves requests with no-op providers.
Telemetry *telemetry.Runtime
}
// Server owns the public auth HTTP listener exposed by authsession.
type Server struct {
cfg Config
handler http.Handler
logger *zap.Logger
stateMu sync.RWMutex
server *http.Server
listener net.Listener
}
// NewServer constructs one public auth HTTP server for cfg and deps.
func NewServer(cfg Config, deps Dependencies) (*Server, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("new public HTTP server: %w", err)
}
handler, err := newHandlerWithConfig(cfg, deps)
if err != nil {
return nil, fmt.Errorf("new public HTTP server: %w", err)
}
logger := deps.Logger
if logger == nil {
logger = zap.NewNop()
}
logger = logger.Named("public_http")
return &Server{
cfg: cfg,
handler: handler,
logger: logger,
}, nil
}
// Run binds the configured listener and serves the public auth HTTP surface
// until Shutdown closes the server.
func (s *Server) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run public HTTP server: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
listener, err := net.Listen("tcp", s.cfg.Addr)
if err != nil {
return fmt.Errorf("run public HTTP server: listen on %q: %w", s.cfg.Addr, err)
}
server := &http.Server{
Handler: s.handler,
ReadHeaderTimeout: s.cfg.ReadHeaderTimeout,
ReadTimeout: s.cfg.ReadTimeout,
IdleTimeout: s.cfg.IdleTimeout,
}
s.stateMu.Lock()
s.server = server
s.listener = listener
s.stateMu.Unlock()
s.logger.Info("public HTTP server started", zap.String("addr", listener.Addr().String()))
defer func() {
s.stateMu.Lock()
s.server = nil
s.listener = nil
s.stateMu.Unlock()
}()
err = server.Serve(listener)
switch {
case err == nil:
return nil
case errors.Is(err, http.ErrServerClosed):
s.logger.Info("public HTTP server stopped")
return nil
default:
return fmt.Errorf("run public HTTP server: serve on %q: %w", s.cfg.Addr, err)
}
}
// Shutdown gracefully stops the public HTTP server within ctx.
func (s *Server) Shutdown(ctx context.Context) error {
if ctx == nil {
return errors.New("shutdown public HTTP server: nil context")
}
s.stateMu.RLock()
server := s.server
s.stateMu.RUnlock()
if server == nil {
return nil
}
if err := server.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("shutdown public HTTP server: %w", err)
}
return nil
}
func normalizeDependencies(deps Dependencies) (Dependencies, error) {
switch {
case deps.SendEmailCode == nil:
return Dependencies{}, errors.New("send email code use case must not be nil")
case deps.ConfirmEmailCode == nil:
return Dependencies{}, errors.New("confirm email code use case must not be nil")
case deps.Logger == nil:
deps.Logger = zap.NewNop()
}
deps.Logger = deps.Logger.Named("public_http")
return deps, nil
}
@@ -0,0 +1,81 @@
package publichttp
import (
"bytes"
"context"
"net/http"
"testing"
"time"
"galaxy/authsession/internal/service/confirmemailcode"
"galaxy/authsession/internal/service/sendemailcode"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewServerRejectsInvalidConfiguration(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.Addr = ""
_, err := NewServer(cfg, Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, nil
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{}, nil
}),
})
require.Error(t, err)
assert.Contains(t, err.Error(), "addr")
}
func TestServerRunAndShutdown(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.Addr = "127.0.0.1:0"
server, err := NewServer(cfg, Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{ChallengeID: "challenge-123"}, nil
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
return confirmemailcode.Result{DeviceSessionID: "device-session-123"}, nil
}),
})
require.NoError(t, err)
runErr := make(chan error, 1)
go func() {
runErr <- server.Run(context.Background())
}()
require.Eventually(t, func() bool {
server.stateMu.RLock()
defer server.stateMu.RUnlock()
return server.listener != nil
}, time.Second, 10*time.Millisecond)
server.stateMu.RLock()
addr := server.listener.Addr().String()
server.stateMu.RUnlock()
response, err := http.Post(
"http://"+addr+"/api/v1/public/auth/send-email-code",
"application/json",
bytes.NewBufferString(`{"email":"pilot@example.com"}`),
)
require.NoError(t, err)
defer response.Body.Close()
assert.Equal(t, http.StatusOK, response.StatusCode)
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
require.NoError(t, server.Shutdown(shutdownCtx))
require.NoError(t, <-runErr)
}