feat: authsession service
This commit is contained in:
@@ -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="
|
||||
)
|
||||
Reference in New Issue
Block a user