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,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="
)