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 internalhttp exposes the trusted internal HTTP API used for session
// read, revoke, and block operations.
package internalhttp
@@ -0,0 +1,286 @@
package internalhttp
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"galaxy/authsession/internal/adapters/userservice"
"galaxy/authsession/internal/domain/common"
"galaxy/authsession/internal/domain/devicesession"
"galaxy/authsession/internal/service/blockuser"
"galaxy/authsession/internal/service/getsession"
"galaxy/authsession/internal/service/listusersessions"
"galaxy/authsession/internal/service/revokeallusersessions"
"galaxy/authsession/internal/service/revokedevicesession"
"galaxy/authsession/internal/testkit"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInternalHTTPEndToEndGetSession(t *testing.T) {
t.Parallel()
app := newEndToEndApp(t)
require.NoError(t, app.sessionStore.Create(context.Background(), activeSession("device-session-1", "user-1", testClientPublicKey(t, validClientPublicKey), time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC))))
server := httptest.NewServer(app.handler)
defer server.Close()
response := getJSON(t, server.URL+"/api/v1/internal/sessions/device-session-1")
assert.Equal(t, http.StatusOK, response.StatusCode)
assert.JSONEq(t, `{"session":{"device_session_id":"device-session-1","user_id":"user-1","client_public_key":"`+validClientPublicKey+`","status":"active","created_at":"2026-04-05T12:00:00Z"}}`, response.Body)
}
func TestInternalHTTPEndToEndListUserSessions(t *testing.T) {
t.Parallel()
app := newEndToEndApp(t)
key := testClientPublicKey(t, validClientPublicKey)
require.NoError(t, app.sessionStore.Create(context.Background(), activeSession("device-session-1", "user-1", key, time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC))))
require.NoError(t, app.sessionStore.Create(context.Background(), activeSession("device-session-2", "user-1", key, time.Date(2026, 4, 5, 12, 1, 0, 0, time.UTC))))
server := httptest.NewServer(app.handler)
defer server.Close()
response := getJSON(t, server.URL+"/api/v1/internal/users/user-1/sessions")
assert.Equal(t, http.StatusOK, response.StatusCode)
assert.Contains(t, response.Body, `"device_session_id":"device-session-2"`)
assert.Contains(t, response.Body, `"device_session_id":"device-session-1"`)
assert.Less(t, bytes.Index([]byte(response.Body), []byte(`"device_session_id":"device-session-2"`)), bytes.Index([]byte(response.Body), []byte(`"device_session_id":"device-session-1"`)))
}
func TestInternalHTTPEndToEndListUserSessionsUnknownUserReturnsEmptyArray(t *testing.T) {
t.Parallel()
app := newEndToEndApp(t)
server := httptest.NewServer(app.handler)
defer server.Close()
response := getJSON(t, server.URL+"/api/v1/internal/users/unknown-user/sessions")
assert.Equal(t, http.StatusOK, response.StatusCode)
assert.JSONEq(t, `{"sessions":[]}`, response.Body)
}
func TestInternalHTTPEndToEndGetSessionNotFound(t *testing.T) {
t.Parallel()
app := newEndToEndApp(t)
server := httptest.NewServer(app.handler)
defer server.Close()
response := getJSON(t, server.URL+"/api/v1/internal/sessions/missing-session")
assert.Equal(t, http.StatusNotFound, response.StatusCode)
assert.JSONEq(t, `{"error":{"code":"session_not_found","message":"session not found"}}`, response.Body)
}
func TestInternalHTTPEndToEndRevokeDeviceSession(t *testing.T) {
t.Parallel()
app := newEndToEndApp(t)
require.NoError(t, app.sessionStore.Create(context.Background(), activeSession("device-session-1", "user-1", testClientPublicKey(t, validClientPublicKey), time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC))))
server := httptest.NewServer(app.handler)
defer server.Close()
response := postJSON(t, server.URL+"/api/v1/internal/sessions/device-session-1/revoke", `{"reason_code":"admin_revoke","actor":{"type":"system"}}`)
assert.Equal(t, http.StatusOK, response.StatusCode)
assert.JSONEq(t, `{"outcome":"revoked","device_session_id":"device-session-1","affected_session_count":1}`, response.Body)
}
func TestInternalHTTPEndToEndRevokeAllUserSessions(t *testing.T) {
t.Parallel()
app := newEndToEndApp(t)
require.NoError(t, app.userDirectory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1")))
key := testClientPublicKey(t, validClientPublicKey)
require.NoError(t, app.sessionStore.Create(context.Background(), activeSession("device-session-1", "user-1", key, time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC))))
require.NoError(t, app.sessionStore.Create(context.Background(), activeSession("device-session-2", "user-1", key, time.Date(2026, 4, 5, 12, 1, 0, 0, time.UTC))))
server := httptest.NewServer(app.handler)
defer server.Close()
response := postJSON(t, server.URL+"/api/v1/internal/users/user-1/sessions/revoke-all", `{"reason_code":"logout_all","actor":{"type":"system"}}`)
assert.Equal(t, http.StatusOK, response.StatusCode)
assert.JSONEq(t, `{"outcome":"revoked","user_id":"user-1","affected_session_count":2,"affected_device_session_ids":["device-session-2","device-session-1"]}`, response.Body)
}
func TestInternalHTTPEndToEndRevokeAllUserSessionsNoActiveSessions(t *testing.T) {
t.Parallel()
app := newEndToEndApp(t)
require.NoError(t, app.userDirectory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1")))
server := httptest.NewServer(app.handler)
defer server.Close()
response := postJSON(t, server.URL+"/api/v1/internal/users/user-1/sessions/revoke-all", `{"reason_code":"logout_all","actor":{"type":"system"}}`)
assert.Equal(t, http.StatusOK, response.StatusCode)
assert.JSONEq(t, `{"outcome":"no_active_sessions","user_id":"user-1","affected_session_count":0,"affected_device_session_ids":[]}`, response.Body)
}
func TestInternalHTTPEndToEndRevokeAllUserSessionsUnknownUser(t *testing.T) {
t.Parallel()
app := newEndToEndApp(t)
server := httptest.NewServer(app.handler)
defer server.Close()
response := postJSON(t, server.URL+"/api/v1/internal/users/missing-user/sessions/revoke-all", `{"reason_code":"logout_all","actor":{"type":"system"}}`)
assert.Equal(t, http.StatusNotFound, response.StatusCode)
assert.JSONEq(t, `{"error":{"code":"subject_not_found","message":"subject not found"}}`, response.Body)
}
func TestInternalHTTPEndToEndBlockUserByEmail(t *testing.T) {
t.Parallel()
app := newEndToEndApp(t)
server := httptest.NewServer(app.handler)
defer server.Close()
response := postJSON(t, server.URL+"/api/v1/internal/user-blocks", `{"email":"pilot@example.com","reason_code":"policy_blocked","actor":{"type":"admin"}}`)
assert.Equal(t, http.StatusOK, response.StatusCode)
assert.JSONEq(t, `{"outcome":"blocked","subject_kind":"email","subject_value":"pilot@example.com","affected_session_count":0,"affected_device_session_ids":[]}`, response.Body)
}
func TestInternalHTTPEndToEndBlockUserByUserID(t *testing.T) {
t.Parallel()
app := newEndToEndApp(t)
require.NoError(t, app.userDirectory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1")))
require.NoError(t, app.sessionStore.Create(context.Background(), activeSession("device-session-1", "user-1", testClientPublicKey(t, validClientPublicKey), time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC))))
server := httptest.NewServer(app.handler)
defer server.Close()
response := postJSON(t, server.URL+"/api/v1/internal/user-blocks", `{"user_id":"user-1","reason_code":"policy_blocked","actor":{"type":"admin"}}`)
assert.Equal(t, http.StatusOK, response.StatusCode)
assert.JSONEq(t, `{"outcome":"blocked","subject_kind":"user_id","subject_value":"user-1","affected_session_count":1,"affected_device_session_ids":["device-session-1"]}`, response.Body)
}
func TestInternalHTTPEndToEndBlockUserUnknownUserID(t *testing.T) {
t.Parallel()
app := newEndToEndApp(t)
server := httptest.NewServer(app.handler)
defer server.Close()
response := postJSON(t, server.URL+"/api/v1/internal/user-blocks", `{"user_id":"missing-user","reason_code":"policy_blocked","actor":{"type":"admin"}}`)
assert.Equal(t, http.StatusNotFound, response.StatusCode)
assert.JSONEq(t, `{"error":{"code":"subject_not_found","message":"subject not found"}}`, response.Body)
}
type endToEndApp struct {
handler http.Handler
sessionStore *testkit.InMemorySessionStore
userDirectory *userservice.StubDirectory
}
func newEndToEndApp(t *testing.T) endToEndApp {
t.Helper()
sessionStore := &testkit.InMemorySessionStore{}
userDirectory := &userservice.StubDirectory{}
publisher := &testkit.RecordingProjectionPublisher{}
clock := testkit.FixedClock{Time: time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC)}
getSessionService, err := getsession.New(sessionStore)
require.NoError(t, err)
listUserSessionsService, err := listusersessions.New(sessionStore)
require.NoError(t, err)
revokeDeviceSessionService, err := revokedevicesession.New(sessionStore, publisher, clock)
require.NoError(t, err)
revokeAllUserSessionsService, err := revokeallusersessions.New(sessionStore, userDirectory, publisher, clock)
require.NoError(t, err)
blockUserService, err := blockuser.New(userDirectory, sessionStore, publisher, clock)
require.NoError(t, err)
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
GetSession: getSessionService,
ListUserSessions: listUserSessionsService,
RevokeDeviceSession: revokeDeviceSessionService,
RevokeAllUserSessions: revokeAllUserSessionsService,
BlockUser: blockUserService,
})
return endToEndApp{
handler: handler,
sessionStore: sessionStore,
userDirectory: userDirectory,
}
}
type httpResponse struct {
StatusCode int
Body string
}
func getJSON(t *testing.T, url string) httpResponse {
t.Helper()
response, err := http.Get(url)
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 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 activeSession(id string, userID string, key common.ClientPublicKey, createdAt time.Time) devicesession.Session {
return devicesession.Session{
ID: common.DeviceSessionID(id),
UserID: common.UserID(userID),
ClientPublicKey: key,
Status: devicesession.StatusActive,
CreatedAt: createdAt,
}
}
func testClientPublicKey(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="
@@ -0,0 +1,513 @@
package internalhttp
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"galaxy/authsession/internal/service/blockuser"
"galaxy/authsession/internal/service/getsession"
"galaxy/authsession/internal/service/listusersessions"
"galaxy/authsession/internal/service/revokeallusersessions"
"galaxy/authsession/internal/service/revokedevicesession"
"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 internalHTTPServiceName = "galaxy-authsession-internal"
type errorResponse struct {
Error errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
type actorRequest struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
}
type sessionResponseDTO struct {
DeviceSessionID string `json:"device_session_id"`
UserID string `json:"user_id"`
ClientPublicKey string `json:"client_public_key"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
RevokedAt *string `json:"revoked_at,omitempty"`
RevokeReasonCode *string `json:"revoke_reason_code,omitempty"`
RevokeActorType *string `json:"revoke_actor_type,omitempty"`
RevokeActorID *string `json:"revoke_actor_id,omitempty"`
}
type getSessionResponse struct {
Session sessionResponseDTO `json:"session"`
}
type listUserSessionsResponse struct {
Sessions []sessionResponseDTO `json:"sessions"`
}
type revokeDeviceSessionRequest struct {
ReasonCode string `json:"reason_code"`
Actor actorRequest `json:"actor"`
}
type revokeDeviceSessionResponse struct {
Outcome string `json:"outcome"`
DeviceSessionID string `json:"device_session_id"`
AffectedSessionCount int64 `json:"affected_session_count"`
}
type revokeAllUserSessionsRequest struct {
ReasonCode string `json:"reason_code"`
Actor actorRequest `json:"actor"`
}
type revokeAllUserSessionsResponse struct {
Outcome string `json:"outcome"`
UserID string `json:"user_id"`
AffectedSessionCount int64 `json:"affected_session_count"`
AffectedDeviceSessionIDs []string `json:"affected_device_session_ids"`
}
type blockUserRequest struct {
UserID string `json:"user_id,omitempty"`
Email string `json:"email,omitempty"`
ReasonCode string `json:"reason_code"`
Actor actorRequest `json:"actor"`
}
type blockUserResponse struct {
Outcome string `json:"outcome"`
SubjectKind string `json:"subject_kind"`
SubjectValue string `json:"subject_value"`
AffectedSessionCount int64 `json:"affected_session_count"`
AffectedDeviceSessionIDs []string `json:"affected_device_session_ids"`
}
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(withInternalObservability(normalizedDeps.Logger, normalizedDeps.Telemetry))
engine.GET("/api/v1/internal/sessions/:device_session_id", handleGetSession(normalizedDeps.GetSession, cfg.RequestTimeout))
engine.GET("/api/v1/internal/users/:user_id/sessions", handleListUserSessions(normalizedDeps.ListUserSessions, cfg.RequestTimeout))
engine.POST("/api/v1/internal/sessions/:device_session_id/revoke", handleRevokeDeviceSession(normalizedDeps.RevokeDeviceSession, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/sessions/revoke-all", handleRevokeAllUserSessions(normalizedDeps.RevokeAllUserSessions, cfg.RequestTimeout))
engine.POST("/api/v1/internal/user-blocks", handleBlockUser(normalizedDeps.BlockUser, 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(internalHTTPServiceName, options...)
}
func handleGetSession(useCase GetSessionUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, getsession.Input{
DeviceSessionID: c.Param("device_session_id"),
})
if err != nil {
abortWithProjection(c, projectInternalError(err))
return
}
if err := validateGetSessionResult(&result); err != nil {
abortWithProjection(c, internalErrorProjection(fmt.Errorf("get session response: %w", err)))
return
}
c.JSON(http.StatusOK, getSessionResponse{Session: toSessionResponseDTO(result.Session)})
}
}
func handleListUserSessions(useCase ListUserSessionsUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, listusersessions.Input{
UserID: c.Param("user_id"),
})
if err != nil {
abortWithProjection(c, projectInternalError(err))
return
}
if err := validateListUserSessionsResult(&result); err != nil {
abortWithProjection(c, internalErrorProjection(fmt.Errorf("list user sessions response: %w", err)))
return
}
c.JSON(http.StatusOK, listUserSessionsResponse{Sessions: toSessionResponseDTOs(result.Sessions)})
}
}
func handleRevokeDeviceSession(useCase RevokeDeviceSessionUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request revokeDeviceSessionRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error())))
return
}
if err := validateAuditRequest(request.ReasonCode, request.Actor); err != nil {
abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, revokedevicesession.Input{
DeviceSessionID: c.Param("device_session_id"),
ReasonCode: request.ReasonCode,
ActorType: request.Actor.Type,
ActorID: request.Actor.ID,
})
if err != nil {
abortWithProjection(c, projectInternalError(err))
return
}
if err := validateRevokeDeviceSessionResult(&result); err != nil {
abortWithProjection(c, internalErrorProjection(fmt.Errorf("revoke device session response: %w", err)))
return
}
c.JSON(http.StatusOK, revokeDeviceSessionResponse{
Outcome: result.Outcome,
DeviceSessionID: result.DeviceSessionID,
AffectedSessionCount: result.AffectedSessionCount,
})
}
}
func handleRevokeAllUserSessions(useCase RevokeAllUserSessionsUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request revokeAllUserSessionsRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error())))
return
}
if err := validateAuditRequest(request.ReasonCode, request.Actor); err != nil {
abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, revokeallusersessions.Input{
UserID: c.Param("user_id"),
ReasonCode: request.ReasonCode,
ActorType: request.Actor.Type,
ActorID: request.Actor.ID,
})
if err != nil {
abortWithProjection(c, projectInternalError(err))
return
}
if err := validateRevokeAllUserSessionsResult(&result); err != nil {
abortWithProjection(c, internalErrorProjection(fmt.Errorf("revoke all user sessions response: %w", err)))
return
}
c.JSON(http.StatusOK, revokeAllUserSessionsResponse{
Outcome: result.Outcome,
UserID: result.UserID,
AffectedSessionCount: result.AffectedSessionCount,
AffectedDeviceSessionIDs: cloneStrings(result.AffectedDeviceSessionIDs),
})
}
}
func handleBlockUser(useCase BlockUserUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request blockUserRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error())))
return
}
if err := validateBlockUserRequest(&request); err != nil {
abortWithProjection(c, projectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, blockuser.Input{
UserID: request.UserID,
Email: request.Email,
ReasonCode: request.ReasonCode,
ActorType: request.Actor.Type,
ActorID: request.Actor.ID,
})
if err != nil {
abortWithProjection(c, projectInternalError(err))
return
}
if err := validateBlockUserResult(&result); err != nil {
abortWithProjection(c, internalErrorProjection(fmt.Errorf("block user response: %w", err)))
return
}
c.JSON(http.StatusOK, blockUserResponse{
Outcome: result.Outcome,
SubjectKind: result.SubjectKind,
SubjectValue: result.SubjectValue,
AffectedSessionCount: result.AffectedSessionCount,
AffectedDeviceSessionIDs: cloneStrings(result.AffectedDeviceSessionIDs),
})
}
}
func toSessionResponseDTO(session shared.Session) sessionResponseDTO {
return sessionResponseDTO{
DeviceSessionID: session.DeviceSessionID,
UserID: session.UserID,
ClientPublicKey: session.ClientPublicKey,
Status: session.Status,
CreatedAt: session.CreatedAt,
RevokedAt: cloneStringPointer(session.RevokedAt),
RevokeReasonCode: cloneStringPointer(session.RevokeReasonCode),
RevokeActorType: cloneStringPointer(session.RevokeActorType),
RevokeActorID: cloneStringPointer(session.RevokeActorID),
}
}
func toSessionResponseDTOs(sessions []shared.Session) []sessionResponseDTO {
result := make([]sessionResponseDTO, 0, len(sessions))
for _, session := range sessions {
result = append(result, toSessionResponseDTO(session))
}
return result
}
func cloneStrings(values []string) []string {
result := make([]string, 0, len(values))
return append(result, values...)
}
func cloneStringPointer(value *string) *string {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
func validateAuditRequest(reasonCode string, actor actorRequest) error {
if strings.TrimSpace(reasonCode) == "" {
return errors.New("reason_code must not be empty")
}
if strings.TrimSpace(actor.Type) == "" {
return errors.New("actor.type must not be empty")
}
return nil
}
func validateBlockUserRequest(request *blockUserRequest) error {
if err := validateAuditRequest(request.ReasonCode, request.Actor); err != nil {
return err
}
hasUserID := strings.TrimSpace(request.UserID) != ""
hasEmail := strings.TrimSpace(request.Email) != ""
switch {
case hasUserID && hasEmail:
return errors.New("exactly one of user_id or email must be provided")
case !hasUserID && !hasEmail:
return errors.New("exactly one of user_id or email must be provided")
default:
return nil
}
}
func validateSessionDTO(session *shared.Session) error {
switch {
case strings.TrimSpace(session.DeviceSessionID) == "":
return errors.New("session.device_session_id must not be empty")
case strings.TrimSpace(session.UserID) == "":
return errors.New("session.user_id must not be empty")
case strings.TrimSpace(session.ClientPublicKey) == "":
return errors.New("session.client_public_key must not be empty")
case strings.TrimSpace(session.CreatedAt) == "":
return errors.New("session.created_at must not be empty")
}
if _, err := time.Parse(time.RFC3339, session.CreatedAt); err != nil {
return fmt.Errorf("session.created_at: %w", err)
}
switch session.Status {
case "active":
if session.RevokedAt != nil || session.RevokeReasonCode != nil || session.RevokeActorType != nil || session.RevokeActorID != nil {
return errors.New("active session must not contain revoke metadata")
}
case "revoked":
switch {
case session.RevokedAt == nil || strings.TrimSpace(*session.RevokedAt) == "":
return errors.New("revoked session must contain revoked_at")
case session.RevokeReasonCode == nil || strings.TrimSpace(*session.RevokeReasonCode) == "":
return errors.New("revoked session must contain revoke_reason_code")
case session.RevokeActorType == nil || strings.TrimSpace(*session.RevokeActorType) == "":
return errors.New("revoked session must contain revoke_actor_type")
}
if _, err := time.Parse(time.RFC3339, *session.RevokedAt); err != nil {
return fmt.Errorf("session.revoked_at: %w", err)
}
default:
return fmt.Errorf("session.status %q is unsupported", session.Status)
}
return nil
}
func validateGetSessionResult(result *getsession.Result) error {
return validateSessionDTO(&result.Session)
}
func validateListUserSessionsResult(result *listusersessions.Result) error {
if result.Sessions == nil {
return errors.New("sessions must not be null")
}
for index := range result.Sessions {
if err := validateSessionDTO(&result.Sessions[index]); err != nil {
return fmt.Errorf("sessions[%d]: %w", index, err)
}
}
return nil
}
func validateRevokeDeviceSessionResult(result *revokedevicesession.Result) error {
switch result.Outcome {
case "revoked":
if result.AffectedSessionCount != 1 {
return errors.New("revoked outcome must affect exactly one session")
}
case "already_revoked":
if result.AffectedSessionCount != 0 {
return errors.New("already_revoked outcome must affect zero sessions")
}
default:
return fmt.Errorf("revoke device session outcome %q is unsupported", result.Outcome)
}
if strings.TrimSpace(result.DeviceSessionID) == "" {
return errors.New("device_session_id must not be empty")
}
return nil
}
func validateRevokeAllUserSessionsResult(result *revokeallusersessions.Result) error {
switch result.Outcome {
case "revoked", "no_active_sessions":
default:
return fmt.Errorf("revoke all user sessions outcome %q is unsupported", result.Outcome)
}
if strings.TrimSpace(result.UserID) == "" {
return errors.New("user_id must not be empty")
}
if result.AffectedSessionCount < 0 {
return errors.New("affected_session_count must not be negative")
}
if result.AffectedDeviceSessionIDs == nil {
return errors.New("affected_device_session_ids must not be null")
}
if int64(len(result.AffectedDeviceSessionIDs)) != result.AffectedSessionCount {
return errors.New("affected_device_session_ids length must match affected_session_count")
}
for index, deviceSessionID := range result.AffectedDeviceSessionIDs {
if strings.TrimSpace(deviceSessionID) == "" {
return fmt.Errorf("affected_device_session_ids[%d] must not be empty", index)
}
}
return nil
}
func validateBlockUserResult(result *blockuser.Result) error {
switch result.Outcome {
case "blocked", "already_blocked":
default:
return fmt.Errorf("block user outcome %q is unsupported", result.Outcome)
}
switch result.SubjectKind {
case blockuser.SubjectKindUserID, blockuser.SubjectKindEmail:
default:
return fmt.Errorf("subject_kind %q is unsupported", result.SubjectKind)
}
if strings.TrimSpace(result.SubjectValue) == "" {
return errors.New("subject_value must not be empty")
}
if result.AffectedSessionCount < 0 {
return errors.New("affected_session_count must not be negative")
}
if result.AffectedDeviceSessionIDs == nil {
return errors.New("affected_device_session_ids must not be null")
}
if int64(len(result.AffectedDeviceSessionIDs)) != result.AffectedSessionCount {
return errors.New("affected_device_session_ids length must match affected_session_count")
}
for index, deviceSessionID := range result.AffectedDeviceSessionIDs {
if strings.TrimSpace(deviceSessionID) == "" {
return fmt.Errorf("affected_device_session_ids[%d] must not be empty", index)
}
}
return nil
}
func projectInternalError(err error) shared.InternalErrorProjection {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return shared.ProjectInternalError(shared.ServiceUnavailable(err))
}
return shared.ProjectInternalError(err)
}
func internalErrorProjection(err error) shared.InternalErrorProjection {
return shared.ProjectInternalError(shared.InternalError(err))
}
@@ -0,0 +1,784 @@
package internalhttp
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"galaxy/authsession/internal/service/blockuser"
"galaxy/authsession/internal/service/getsession"
"galaxy/authsession/internal/service/listusersessions"
"galaxy/authsession/internal/service/revokeallusersessions"
"galaxy/authsession/internal/service/revokedevicesession"
"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 TestGetSessionHandlerSuccess(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
GetSession: getSessionFunc(func(_ context.Context, input getsession.Input) (getsession.Result, error) {
assert.Equal(t, getsession.Input{DeviceSessionID: "device-session-123"}, input)
return getsession.Result{
Session: validSessionDTO(),
}, nil
}),
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
BlockUser: blockUserFunc(unexpectedBlockUser),
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/sessions/device-session-123", nil)
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assert.JSONEq(t, `{"session":{"device_session_id":"device-session-123","user_id":"user-123","client_public_key":"public-key-material","status":"active","created_at":"2026-04-05T12:00:00Z"}}`, recorder.Body.String())
}
func TestListUserSessionsHandlerSuccess(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
GetSession: getSessionFunc(unexpectedGetSession),
ListUserSessions: listUserSessionsFunc(func(_ context.Context, input listusersessions.Input) (listusersessions.Result, error) {
assert.Equal(t, listusersessions.Input{UserID: "user-123"}, input)
first := validSessionDTO()
second := validRevokedSessionDTO()
second.DeviceSessionID = "device-session-122"
return listusersessions.Result{Sessions: []shared.Session{first, second}}, nil
}),
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
BlockUser: blockUserFunc(unexpectedBlockUser),
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/users/user-123/sessions", nil)
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assert.Contains(t, recorder.Body.String(), `"sessions":[`)
assert.Contains(t, recorder.Body.String(), `"device_session_id":"device-session-123"`)
assert.Contains(t, recorder.Body.String(), `"device_session_id":"device-session-122"`)
}
func TestListUserSessionsHandlerUnknownUserReturnsEmptyArray(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
GetSession: getSessionFunc(unexpectedGetSession),
ListUserSessions: listUserSessionsFunc(func(_ context.Context, input listusersessions.Input) (listusersessions.Result, error) {
assert.Equal(t, listusersessions.Input{UserID: "unknown-user"}, input)
return listusersessions.Result{Sessions: []shared.Session{}}, nil
}),
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
BlockUser: blockUserFunc(unexpectedBlockUser),
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/users/unknown-user/sessions", nil)
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assert.JSONEq(t, `{"sessions":[]}`, recorder.Body.String())
}
func TestRevokeDeviceSessionHandlerAlreadyRevoked(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
GetSession: getSessionFunc(unexpectedGetSession),
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
RevokeDeviceSession: revokeDeviceSessionFunc(func(_ context.Context, input revokedevicesession.Input) (revokedevicesession.Result, error) {
assert.Equal(t, revokedevicesession.Input{
DeviceSessionID: "device-session-123",
ReasonCode: "admin_revoke",
ActorType: "system",
}, input)
return revokedevicesession.Result{
Outcome: "already_revoked",
DeviceSessionID: "device-session-123",
AffectedSessionCount: 0,
}, nil
}),
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
BlockUser: blockUserFunc(unexpectedBlockUser),
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/internal/sessions/device-session-123/revoke",
bytes.NewBufferString(`{"reason_code":"admin_revoke","actor":{"type":"system"}}`),
)
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, `{"outcome":"already_revoked","device_session_id":"device-session-123","affected_session_count":0}`, recorder.Body.String())
}
func TestRevokeAllUserSessionsHandlerNoActiveSessions(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
GetSession: getSessionFunc(unexpectedGetSession),
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
RevokeAllUserSessions: revokeAllUserSessionsFunc(func(_ context.Context, input revokeallusersessions.Input) (revokeallusersessions.Result, error) {
assert.Equal(t, revokeallusersessions.Input{
UserID: "user-123",
ReasonCode: "logout_all",
ActorType: "system",
}, input)
return revokeallusersessions.Result{
Outcome: "no_active_sessions",
UserID: "user-123",
AffectedSessionCount: 0,
AffectedDeviceSessionIDs: []string{},
}, nil
}),
BlockUser: blockUserFunc(unexpectedBlockUser),
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/internal/users/user-123/sessions/revoke-all",
bytes.NewBufferString(`{"reason_code":"logout_all","actor":{"type":"system"}}`),
)
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, `{"outcome":"no_active_sessions","user_id":"user-123","affected_session_count":0,"affected_device_session_ids":[]}`, recorder.Body.String())
}
func TestBlockUserHandlerSuccessByEmail(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
GetSession: getSessionFunc(unexpectedGetSession),
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
BlockUser: blockUserFunc(func(_ context.Context, input blockuser.Input) (blockuser.Result, error) {
assert.Equal(t, blockuser.Input{
Email: "pilot@example.com",
ReasonCode: "policy_blocked",
ActorType: "admin",
}, input)
return blockuser.Result{
Outcome: "blocked",
SubjectKind: blockuser.SubjectKindEmail,
SubjectValue: "pilot@example.com",
AffectedSessionCount: 0,
AffectedDeviceSessionIDs: []string{},
}, nil
}),
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/internal/user-blocks",
bytes.NewBufferString(`{"email":"pilot@example.com","reason_code":"policy_blocked","actor":{"type":"admin"}}`),
)
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, `{"outcome":"blocked","subject_kind":"email","subject_value":"pilot@example.com","affected_session_count":0,"affected_device_session_ids":[]}`, recorder.Body.String())
}
func TestBlockUserHandlerSuccessByUserID(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
GetSession: getSessionFunc(unexpectedGetSession),
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
BlockUser: blockUserFunc(func(_ context.Context, input blockuser.Input) (blockuser.Result, error) {
assert.Equal(t, blockuser.Input{
UserID: "user-123",
ReasonCode: "policy_blocked",
ActorType: "admin",
}, input)
return blockuser.Result{
Outcome: "already_blocked",
SubjectKind: blockuser.SubjectKindUserID,
SubjectValue: "user-123",
AffectedSessionCount: 0,
AffectedDeviceSessionIDs: []string{},
}, nil
}),
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/internal/user-blocks",
bytes.NewBufferString(`{"user_id":"user-123","reason_code":"policy_blocked","actor":{"type":"admin"}}`),
)
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, `{"outcome":"already_blocked","subject_kind":"user_id","subject_value":"user-123","affected_session_count":0,"affected_device_session_ids":[]}`, recorder.Body.String())
}
func TestInternalHandlersRejectInvalidPathParams(t *testing.T) {
t.Parallel()
tests := []struct {
name string
method string
target string
body string
wantStatus int
wantBody string
}{
{
name: "get session empty device session id",
method: http.MethodGet,
target: "/api/v1/internal/sessions/%20",
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"device session id must not be empty"}}`,
},
{
name: "list sessions empty user id",
method: http.MethodGet,
target: "/api/v1/internal/users/%20/sessions",
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"user id must not be empty"}}`,
},
{
name: "revoke all empty user id",
method: http.MethodPost,
target: "/api/v1/internal/users/%20/sessions/revoke-all",
body: `{"reason_code":"logout_all","actor":{"type":"system"}}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"user id must not be empty"}}`,
},
}
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
GetSession: getSessionFunc(func(context.Context, getsession.Input) (getsession.Result, error) {
return getsession.Result{}, shared.InvalidRequest("device session id must not be empty")
}),
ListUserSessions: listUserSessionsFunc(func(context.Context, listusersessions.Input) (listusersessions.Result, error) {
return listusersessions.Result{}, shared.InvalidRequest("user id must not be empty")
}),
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
RevokeAllUserSessions: revokeAllUserSessionsFunc(func(context.Context, revokeallusersessions.Input) (revokeallusersessions.Result, error) {
return revokeallusersessions.Result{}, shared.InvalidRequest("user id must not be empty")
}),
BlockUser: blockUserFunc(unexpectedBlockUser),
})
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(tt.method, 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 TestInternalMutationHandlersRejectInvalidRequests(t *testing.T) {
t.Parallel()
tests := []struct {
name string
method string
target string
body string
wantStatus int
wantBody string
}{
{
name: "revoke device session empty body",
method: http.MethodPost,
target: "/api/v1/internal/sessions/device-session-123/revoke",
body: ``,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body must not be empty"}}`,
},
{
name: "revoke device session malformed json",
method: http.MethodPost,
target: "/api/v1/internal/sessions/device-session-123/revoke",
body: `{"reason_code":`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body contains malformed JSON"}}`,
},
{
name: "revoke device session multiple objects",
method: http.MethodPost,
target: "/api/v1/internal/sessions/device-session-123/revoke",
body: `{"reason_code":"admin_revoke","actor":{"type":"system"}}{"reason_code":"admin_revoke","actor":{"type":"system"}}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body must contain a single JSON object"}}`,
},
{
name: "revoke device session unknown field",
method: http.MethodPost,
target: "/api/v1/internal/sessions/device-session-123/revoke",
body: `{"reason_code":"admin_revoke","actor":{"type":"system"},"extra":true}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body contains unknown field \"extra\""}}`,
},
{
name: "revoke device session invalid json type",
method: http.MethodPost,
target: "/api/v1/internal/sessions/device-session-123/revoke",
body: `{"reason_code":123,"actor":{"type":"system"}}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body contains an invalid value for \"reason_code\""}}`,
},
{
name: "revoke all missing reason code",
method: http.MethodPost,
target: "/api/v1/internal/users/user-123/sessions/revoke-all",
body: `{"reason_code":" ","actor":{"type":"system"}}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"reason_code must not be empty"}}`,
},
{
name: "block user missing actor type",
method: http.MethodPost,
target: "/api/v1/internal/user-blocks",
body: `{"email":"pilot@example.com","reason_code":"policy_blocked","actor":{"type":" "}}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"actor.type must not be empty"}}`,
},
{
name: "block user missing subject",
method: http.MethodPost,
target: "/api/v1/internal/user-blocks",
body: `{"reason_code":"policy_blocked","actor":{"type":"system"}}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"exactly one of user_id or email must be provided"}}`,
},
{
name: "block user conflicting subjects",
method: http.MethodPost,
target: "/api/v1/internal/user-blocks",
body: `{"user_id":"user-123","email":"pilot@example.com","reason_code":"policy_blocked","actor":{"type":"system"}}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"exactly one of user_id or email must be provided"}}`,
},
}
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
GetSession: getSessionFunc(unexpectedGetSession),
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
BlockUser: blockUserFunc(unexpectedBlockUser),
})
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(tt.method, 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 TestInternalHandlersMapServiceErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
method string
target string
body string
deps Dependencies
wantStatus int
wantBody string
}{
{
name: "get session not found",
method: http.MethodGet,
target: "/api/v1/internal/sessions/missing",
deps: Dependencies{
GetSession: getSessionFunc(func(context.Context, getsession.Input) (getsession.Result, error) {
return getsession.Result{}, shared.SessionNotFound()
}),
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
BlockUser: blockUserFunc(unexpectedBlockUser),
},
wantStatus: http.StatusNotFound,
wantBody: `{"error":{"code":"session_not_found","message":"session not found"}}`,
},
{
name: "revoke all subject not found",
method: http.MethodPost,
target: "/api/v1/internal/users/missing/sessions/revoke-all",
body: `{"reason_code":"logout_all","actor":{"type":"system"}}`,
deps: Dependencies{
GetSession: getSessionFunc(unexpectedGetSession),
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
RevokeAllUserSessions: revokeAllUserSessionsFunc(func(context.Context, revokeallusersessions.Input) (revokeallusersessions.Result, error) {
return revokeallusersessions.Result{}, shared.SubjectNotFound()
}),
BlockUser: blockUserFunc(unexpectedBlockUser),
},
wantStatus: http.StatusNotFound,
wantBody: `{"error":{"code":"subject_not_found","message":"subject not found"}}`,
},
{
name: "service unavailable",
method: http.MethodGet,
target: "/api/v1/internal/sessions/device-session-123",
deps: Dependencies{
GetSession: getSessionFunc(func(context.Context, getsession.Input) (getsession.Result, error) {
return getsession.Result{}, shared.ServiceUnavailable(errors.New("redis timeout"))
}),
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
BlockUser: blockUserFunc(unexpectedBlockUser),
},
wantStatus: http.StatusServiceUnavailable,
wantBody: `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`,
},
{
name: "internal error",
method: http.MethodGet,
target: "/api/v1/internal/sessions/device-session-123",
deps: Dependencies{
GetSession: getSessionFunc(func(context.Context, getsession.Input) (getsession.Result, error) {
return getsession.Result{}, shared.InternalError(errors.New("broken invariant"))
}),
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
BlockUser: blockUserFunc(unexpectedBlockUser),
},
wantStatus: http.StatusInternalServerError,
wantBody: `{"error":{"code":"internal_error","message":"internal server error"}}`,
},
{
name: "unexpected error hidden",
method: http.MethodGet,
target: "/api/v1/internal/sessions/device-session-123",
deps: Dependencies{
GetSession: getSessionFunc(func(context.Context, getsession.Input) (getsession.Result, error) {
return getsession.Result{}, errors.New("boom")
}),
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
BlockUser: blockUserFunc(unexpectedBlockUser),
},
wantStatus: http.StatusInternalServerError,
wantBody: `{"error":{"code":"internal_error","message":"internal server error"}}`,
},
}
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(tt.method, 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 TestInternalHandlerTimeoutMapsToServiceUnavailable(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.RequestTimeout = 5 * time.Millisecond
handler := mustNewHandler(t, cfg, Dependencies{
GetSession: getSessionFunc(func(context.Context, getsession.Input) (getsession.Result, error) {
return getsession.Result{}, context.DeadlineExceeded
}),
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
BlockUser: blockUserFunc(unexpectedBlockUser),
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/sessions/device-session-123", nil)
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 TestInternalHandlersRejectInvalidSuccessPayloads(t *testing.T) {
t.Parallel()
tests := []struct {
name string
method string
target string
body string
deps Dependencies
}{
{
name: "get session malformed response",
method: http.MethodGet,
target: "/api/v1/internal/sessions/device-session-123",
deps: Dependencies{
GetSession: getSessionFunc(func(context.Context, getsession.Input) (getsession.Result, error) {
dto := validSessionDTO()
dto.DeviceSessionID = ""
return getsession.Result{Session: dto}, nil
}),
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
BlockUser: blockUserFunc(unexpectedBlockUser),
},
},
{
name: "revoke all malformed response",
method: http.MethodPost,
target: "/api/v1/internal/users/user-123/sessions/revoke-all",
body: `{"reason_code":"logout_all","actor":{"type":"system"}}`,
deps: Dependencies{
GetSession: getSessionFunc(unexpectedGetSession),
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
RevokeAllUserSessions: revokeAllUserSessionsFunc(func(context.Context, revokeallusersessions.Input) (revokeallusersessions.Result, error) {
return revokeallusersessions.Result{
Outcome: "revoked",
UserID: "user-123",
AffectedSessionCount: 2,
AffectedDeviceSessionIDs: []string{"device-session-1"},
}, nil
}),
BlockUser: blockUserFunc(unexpectedBlockUser),
},
},
}
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(tt.method, tt.target, bytes.NewBufferString(tt.body))
if tt.body != "" {
request.Header.Set("Content-Type", "application/json")
}
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusInternalServerError, recorder.Code)
assert.JSONEq(t, `{"error":{"code":"internal_error","message":"internal server error"}}`, recorder.Body.String())
})
}
}
func TestInternalHandlerLogsDoNotContainSensitiveFields(t *testing.T) {
t.Parallel()
logger, buffer := newObservedLogger()
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
Logger: logger,
GetSession: getSessionFunc(unexpectedGetSession),
ListUserSessions: listUserSessionsFunc(unexpectedListUserSessions),
RevokeDeviceSession: revokeDeviceSessionFunc(unexpectedRevokeDeviceSession),
RevokeAllUserSessions: revokeAllUserSessionsFunc(unexpectedRevokeAllUserSessions),
BlockUser: blockUserFunc(func(context.Context, blockuser.Input) (blockuser.Result, error) {
return blockuser.Result{
Outcome: "blocked",
SubjectKind: blockuser.SubjectKindEmail,
SubjectValue: "pilot@example.com",
AffectedSessionCount: 0,
AffectedDeviceSessionIDs: []string{},
}, nil
}),
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/internal/user-blocks",
bytes.NewBufferString(`{"email":"pilot@example.com","reason_code":"policy_blocked","actor":{"type":"admin","id":"admin-1"}}`),
)
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, "pilot@example.com")
assert.NotContains(t, logOutput, "admin-1")
assert.NotContains(t, logOutput, "reason_code")
}
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 getSessionFunc func(ctx context.Context, input getsession.Input) (getsession.Result, error)
func (f getSessionFunc) Execute(ctx context.Context, input getsession.Input) (getsession.Result, error) {
return f(ctx, input)
}
type listUserSessionsFunc func(ctx context.Context, input listusersessions.Input) (listusersessions.Result, error)
func (f listUserSessionsFunc) Execute(ctx context.Context, input listusersessions.Input) (listusersessions.Result, error) {
return f(ctx, input)
}
type revokeDeviceSessionFunc func(ctx context.Context, input revokedevicesession.Input) (revokedevicesession.Result, error)
func (f revokeDeviceSessionFunc) Execute(ctx context.Context, input revokedevicesession.Input) (revokedevicesession.Result, error) {
return f(ctx, input)
}
type revokeAllUserSessionsFunc func(ctx context.Context, input revokeallusersessions.Input) (revokeallusersessions.Result, error)
func (f revokeAllUserSessionsFunc) Execute(ctx context.Context, input revokeallusersessions.Input) (revokeallusersessions.Result, error) {
return f(ctx, input)
}
type blockUserFunc func(ctx context.Context, input blockuser.Input) (blockuser.Result, error)
func (f blockUserFunc) Execute(ctx context.Context, input blockuser.Input) (blockuser.Result, error) {
return f(ctx, input)
}
func validSessionDTO() shared.Session {
return shared.Session{
DeviceSessionID: "device-session-123",
UserID: "user-123",
ClientPublicKey: "public-key-material",
Status: "active",
CreatedAt: "2026-04-05T12:00:00Z",
}
}
func validRevokedSessionDTO() shared.Session {
dto := validSessionDTO()
dto.Status = "revoked"
revokedAt := "2026-04-05T12:01:00Z"
reasonCode := "admin_revoke"
actorType := "admin"
actorID := "admin-1"
dto.RevokedAt = &revokedAt
dto.RevokeReasonCode = &reasonCode
dto.RevokeActorType = &actorType
dto.RevokeActorID = &actorID
return dto
}
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
}
func unexpectedGetSession(context.Context, getsession.Input) (getsession.Result, error) {
return getsession.Result{}, errors.New("unexpected call")
}
func unexpectedListUserSessions(context.Context, listusersessions.Input) (listusersessions.Result, error) {
return listusersessions.Result{}, errors.New("unexpected call")
}
func unexpectedRevokeDeviceSession(context.Context, revokedevicesession.Input) (revokedevicesession.Result, error) {
return revokedevicesession.Result{}, errors.New("unexpected call")
}
func unexpectedRevokeAllUserSessions(context.Context, revokeallusersessions.Input) (revokeallusersessions.Result, error) {
return revokeallusersessions.Result{}, errors.New("unexpected call")
}
func unexpectedBlockUser(context.Context, blockuser.Input) (blockuser.Result, error) {
return blockuser.Result{}, errors.New("unexpected call")
}
@@ -0,0 +1,93 @@
package internalhttp
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"galaxy/authsession/internal/service/shared"
"github.com/gin-gonic/gin"
)
const internalErrorCodeContextKey = "internal_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.InternalErrorProjection) {
c.Set(internalErrorCodeContextKey, projection.Code)
c.AbortWithStatusJSON(projection.StatusCode, errorResponse{
Error: errorBody{
Code: projection.Code,
Message: projection.Message,
},
})
}
@@ -0,0 +1,86 @@
package internalhttp
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 withInternalObservability(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(internalErrorCodeContextKey)
errorCodeValue, _ := errorCode.(string)
outcome := outcomeFromStatusCode(statusCode)
duration := time.Since(start)
fields := []zap.Field{
zap.String("component", "internal_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.RecordInternalHTTPRequest(c.Request.Context(), metricAttrs, duration)
switch outcome {
case edgeOutcomeSuccess:
logger.Info("internal request completed", fields...)
case edgeOutcomeFailed:
logger.Error("internal request failed", fields...)
default:
logger.Warn("internal 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,121 @@
package internalhttp
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"galaxy/authsession/internal/service/blockuser"
"galaxy/authsession/internal/service/getsession"
"galaxy/authsession/internal/service/listusersessions"
"galaxy/authsession/internal/service/revokeallusersessions"
"galaxy/authsession/internal/service/revokedevicesession"
"galaxy/authsession/internal/service/shared"
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 TestInternalHandlerEmitsTraceFieldsAndMetrics(t *testing.T) {
t.Parallel()
logger, buffer := newObservedLogger()
telemetryRuntime, reader, recorder := newObservedInternalTelemetryRuntime(t)
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
Logger: logger,
Telemetry: telemetryRuntime,
GetSession: getSessionFunc(func(context.Context, getsession.Input) (getsession.Result, error) {
return getsession.Result{Session: validSessionDTO()}, nil
}),
ListUserSessions: listUserSessionsFunc(func(context.Context, listusersessions.Input) (listusersessions.Result, error) {
return listusersessions.Result{Sessions: []shared.Session{}}, nil
}),
RevokeDeviceSession: revokeDeviceSessionFunc(func(context.Context, revokedevicesession.Input) (revokedevicesession.Result, error) {
return revokedevicesession.Result{}, nil
}),
RevokeAllUserSessions: revokeAllUserSessionsFunc(func(context.Context, revokeallusersessions.Input) (revokeallusersessions.Result, error) {
return revokeallusersessions.Result{}, nil
}),
BlockUser: blockUserFunc(func(context.Context, blockuser.Input) (blockuser.Result, error) {
return blockuser.Result{}, nil
}),
})
recorderHTTP := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/sessions/device-session-123", nil)
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.internal_http.requests", map[string]string{
"route": "/api/v1/internal/sessions/:device_session_id",
"method": http.MethodGet,
"edge_outcome": "success",
}, 1)
}
func newObservedInternalTelemetryRuntime(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,271 @@
package internalhttp
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"sync"
"time"
"galaxy/authsession/internal/service/blockuser"
"galaxy/authsession/internal/service/getsession"
"galaxy/authsession/internal/service/listusersessions"
"galaxy/authsession/internal/service/revokeallusersessions"
"galaxy/authsession/internal/service/revokedevicesession"
"galaxy/authsession/internal/telemetry"
"go.uber.org/zap"
)
const (
defaultAddr = ":8081"
defaultReadHeaderTimeout = 2 * time.Second
defaultReadTimeout = 10 * time.Second
defaultIdleTimeout = time.Minute
defaultRequestTimeout = 3 * time.Second
)
// GetSessionUseCase describes the trusted internal get-session service
// consumed by the HTTP transport layer.
type GetSessionUseCase interface {
// Execute loads one device session for trusted internal callers.
Execute(ctx context.Context, input getsession.Input) (getsession.Result, error)
}
// ListUserSessionsUseCase describes the trusted internal list-user-sessions
// service consumed by the HTTP transport layer.
type ListUserSessionsUseCase interface {
// Execute lists all sessions of one user for trusted internal callers.
Execute(ctx context.Context, input listusersessions.Input) (listusersessions.Result, error)
}
// RevokeDeviceSessionUseCase describes the trusted internal single-session
// revoke service consumed by the HTTP transport layer.
type RevokeDeviceSessionUseCase interface {
// Execute revokes one device session and returns the frozen
// acknowledgement.
Execute(ctx context.Context, input revokedevicesession.Input) (revokedevicesession.Result, error)
}
// RevokeAllUserSessionsUseCase describes the trusted internal bulk-revoke
// service consumed by the HTTP transport layer.
type RevokeAllUserSessionsUseCase interface {
// Execute revokes all active sessions of one user and returns the frozen
// acknowledgement.
Execute(ctx context.Context, input revokeallusersessions.Input) (revokeallusersessions.Result, error)
}
// BlockUserUseCase describes the trusted internal block-user service consumed
// by the HTTP transport layer.
type BlockUserUseCase interface {
// Execute applies a block state to one subject and returns the frozen
// acknowledgement.
Execute(ctx context.Context, input blockuser.Input) (blockuser.Result, error)
}
// Config describes the trusted internal HTTP listener owned by authsession.
type Config struct {
// Addr is the TCP listen address used by the trusted internal 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 trusted
// internal 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 internal use-case call.
RequestTimeout time.Duration
}
// Validate reports whether cfg contains a usable internal HTTP listener
// configuration.
func (cfg Config) Validate() error {
switch {
case cfg.Addr == "":
return errors.New("internal HTTP addr must not be empty")
case cfg.ReadHeaderTimeout <= 0:
return errors.New("internal HTTP read header timeout must be positive")
case cfg.ReadTimeout <= 0:
return errors.New("internal HTTP read timeout must be positive")
case cfg.IdleTimeout <= 0:
return errors.New("internal HTTP idle timeout must be positive")
case cfg.RequestTimeout <= 0:
return errors.New("internal HTTP request timeout must be positive")
default:
return nil
}
}
// DefaultConfig returns the default trusted internal HTTP listener settings.
func DefaultConfig() Config {
return Config{
Addr: defaultAddr,
ReadHeaderTimeout: defaultReadHeaderTimeout,
ReadTimeout: defaultReadTimeout,
IdleTimeout: defaultIdleTimeout,
RequestTimeout: defaultRequestTimeout,
}
}
// Dependencies describes the collaborators used by the trusted internal HTTP
// transport layer.
type Dependencies struct {
// GetSession executes the trusted internal get-session use case.
GetSession GetSessionUseCase
// ListUserSessions executes the trusted internal list-user-sessions use
// case.
ListUserSessions ListUserSessionsUseCase
// RevokeDeviceSession executes the trusted internal single-session revoke
// use case.
RevokeDeviceSession RevokeDeviceSessionUseCase
// RevokeAllUserSessions executes the trusted internal bulk-revoke use case.
RevokeAllUserSessions RevokeAllUserSessionsUseCase
// BlockUser executes the trusted internal block-user use case.
BlockUser BlockUserUseCase
// 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 trusted internal 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 trusted internal 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 internal HTTP server: %w", err)
}
handler, err := newHandlerWithConfig(cfg, deps)
if err != nil {
return nil, fmt.Errorf("new internal HTTP server: %w", err)
}
logger := deps.Logger
if logger == nil {
logger = zap.NewNop()
}
logger = logger.Named("internal_http")
return &Server{
cfg: cfg,
handler: handler,
logger: logger,
}, nil
}
// Run binds the configured listener and serves the trusted internal HTTP
// surface until Shutdown closes the server.
func (s *Server) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run internal 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 internal 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("internal 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("internal HTTP server stopped")
return nil
default:
return fmt.Errorf("run internal HTTP server: serve on %q: %w", s.cfg.Addr, err)
}
}
// Shutdown gracefully stops the trusted internal HTTP server within ctx.
func (s *Server) Shutdown(ctx context.Context) error {
if ctx == nil {
return errors.New("shutdown internal 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 internal HTTP server: %w", err)
}
return nil
}
func normalizeDependencies(deps Dependencies) (Dependencies, error) {
switch {
case deps.GetSession == nil:
return Dependencies{}, errors.New("get session use case must not be nil")
case deps.ListUserSessions == nil:
return Dependencies{}, errors.New("list user sessions use case must not be nil")
case deps.RevokeDeviceSession == nil:
return Dependencies{}, errors.New("revoke device session use case must not be nil")
case deps.RevokeAllUserSessions == nil:
return Dependencies{}, errors.New("revoke all user sessions use case must not be nil")
case deps.BlockUser == nil:
return Dependencies{}, errors.New("block user use case must not be nil")
case deps.Logger == nil:
deps.Logger = zap.NewNop()
}
deps.Logger = deps.Logger.Named("internal_http")
return deps, nil
}
@@ -0,0 +1,106 @@
package internalhttp
import (
"bytes"
"context"
"net/http"
"testing"
"time"
"galaxy/authsession/internal/service/blockuser"
"galaxy/authsession/internal/service/getsession"
"galaxy/authsession/internal/service/listusersessions"
"galaxy/authsession/internal/service/revokeallusersessions"
"galaxy/authsession/internal/service/revokedevicesession"
"galaxy/authsession/internal/service/shared"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewServerRejectsInvalidConfiguration(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.Addr = ""
_, err := NewServer(cfg, validDependencies())
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, validDependencies())
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/internal/sessions/device-session-123/revoke",
"application/json",
bytes.NewBufferString(`{"reason_code":"admin_revoke","actor":{"type":"system"}}`),
)
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)
}
func validDependencies() Dependencies {
return Dependencies{
GetSession: getSessionFunc(func(context.Context, getsession.Input) (getsession.Result, error) {
return getsession.Result{Session: validSessionDTO()}, nil
}),
ListUserSessions: listUserSessionsFunc(func(context.Context, listusersessions.Input) (listusersessions.Result, error) {
return listusersessions.Result{Sessions: []shared.Session{validSessionDTO()}}, nil
}),
RevokeDeviceSession: revokeDeviceSessionFunc(func(context.Context, revokedevicesession.Input) (revokedevicesession.Result, error) {
return revokedevicesession.Result{
Outcome: "revoked",
DeviceSessionID: "device-session-123",
AffectedSessionCount: 1,
}, nil
}),
RevokeAllUserSessions: revokeAllUserSessionsFunc(func(context.Context, revokeallusersessions.Input) (revokeallusersessions.Result, error) {
return revokeallusersessions.Result{
Outcome: "revoked",
UserID: "user-123",
AffectedSessionCount: 1,
AffectedDeviceSessionIDs: []string{"device-session-123"},
}, nil
}),
BlockUser: blockUserFunc(func(context.Context, blockuser.Input) (blockuser.Result, error) {
return blockuser.Result{
Outcome: "blocked",
SubjectKind: blockuser.SubjectKindEmail,
SubjectValue: "pilot@example.com",
AffectedSessionCount: 0,
AffectedDeviceSessionIDs: []string{},
}, nil
}),
}
}