package restapi import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/mail" "strings" "time" "github.com/gin-gonic/gin" ) var errPublicAuthIdentityNotApplicable = errors.New("public auth identity does not apply to this route") type malformedJSONRequestError struct { message string reason PublicMalformedRequestReason } func (e *malformedJSONRequestError) Error() string { if e == nil { return "" } return e.message } type publicAuthIdentity struct { kind string value string } // AuthServiceClient defines the consumer-side contract used by public auth // REST handlers to delegate unauthenticated authentication commands to the // Auth / Session Service. type AuthServiceClient interface { // SendEmailCode starts a login challenge for input.Email and returns the // challenge identifier that the client must later confirm. SendEmailCode(ctx context.Context, input SendEmailCodeInput) (SendEmailCodeResult, error) // ConfirmEmailCode completes a previously issued challenge, registers // input.ClientPublicKey for the new device session, and returns the created // device session identifier. ConfirmEmailCode(ctx context.Context, input ConfirmEmailCodeInput) (ConfirmEmailCodeResult, error) } // SendEmailCodeInput describes the public REST and adapter payload used to // request a login code for a single e-mail address. type SendEmailCodeInput struct { // Email is the single client e-mail address that should receive the login // code challenge. Email string `json:"email"` // PreferredLanguage stores the canonical BCP 47 language tag derived from // the public Accept-Language header for upstream auth-mail localization and // create-only user registration context. PreferredLanguage string `json:"-"` } // SendEmailCodeResult describes the public REST and adapter payload returned // after the Auth / Session Service creates a login challenge. type SendEmailCodeResult struct { // ChallengeID identifies the issued challenge that must be confirmed by the // client in the next public auth step. ChallengeID string `json:"challenge_id"` } // ConfirmEmailCodeInput describes the public REST and adapter payload used to // complete a previously issued login challenge. type ConfirmEmailCodeInput struct { // ChallengeID identifies the challenge previously returned by // SendEmailCode. ChallengeID string `json:"challenge_id"` // Code is the verification code delivered to the client by the Auth / // Session Service. Code string `json:"code"` // ClientPublicKey is the standard base64-encoded raw 32-byte Ed25519 public // key that should be registered for the created device session. ClientPublicKey string `json:"client_public_key"` // TimeZone is the client-selected IANA time zone name forwarded to the // Auth / Session Service as registration context for first-time user // creation. TimeZone string `json:"time_zone"` } // ConfirmEmailCodeResult describes the public REST and adapter payload // returned after the Auth / Session Service creates a device session. type ConfirmEmailCodeResult struct { // DeviceSessionID is the stable identifier of the created device session. DeviceSessionID string `json:"device_session_id"` } // AuthServiceError allows an auth adapter to project a stable public REST // error without teaching the gateway transport layer about upstream business // rules. type AuthServiceError struct { // StatusCode is the HTTP status that the public REST handler should expose. StatusCode int // Code is the stable edge-level error code written into the JSON envelope. Code string // Message is the human-readable client-safe error description. Message string } // Error returns a readable representation of the projected auth service error. func (e *AuthServiceError) Error() string { if e == nil { return "" } switch { case strings.TrimSpace(e.Code) == "" && strings.TrimSpace(e.Message) == "": return http.StatusText(e.normalizedStatusCode()) case strings.TrimSpace(e.Code) == "": return e.Message case strings.TrimSpace(e.Message) == "": return e.Code default: return e.Code + ": " + e.Message } } func (e *AuthServiceError) normalizedStatusCode() int { if e == nil || e.StatusCode < 400 || e.StatusCode > 599 { return http.StatusInternalServerError } return e.StatusCode } func (e *AuthServiceError) normalizedCode() string { if e == nil { return errorCodeInternalError } code := strings.TrimSpace(e.Code) if code == "" { switch e.normalizedStatusCode() { case http.StatusServiceUnavailable: return errorCodeServiceUnavailable case http.StatusBadRequest: return errorCodeInvalidRequest default: return errorCodeInternalError } } return code } func (e *AuthServiceError) normalizedMessage() string { if e == nil { return "internal server error" } message := strings.TrimSpace(e.Message) if message == "" { switch e.normalizedStatusCode() { case http.StatusServiceUnavailable: return "auth service is unavailable" case http.StatusBadRequest: return "request is invalid" default: return "internal server error" } } return message } // unavailableAuthServiceClient keeps the public auth surface mounted until a // concrete upstream adapter is wired into the gateway process. type unavailableAuthServiceClient struct{} func (unavailableAuthServiceClient) SendEmailCode(context.Context, SendEmailCodeInput) (SendEmailCodeResult, error) { return SendEmailCodeResult{}, &AuthServiceError{ StatusCode: http.StatusServiceUnavailable, Code: errorCodeServiceUnavailable, Message: "auth service is unavailable", } } func (unavailableAuthServiceClient) ConfirmEmailCode(context.Context, ConfirmEmailCodeInput) (ConfirmEmailCodeResult, error) { return ConfirmEmailCodeResult{}, &AuthServiceError{ StatusCode: http.StatusServiceUnavailable, Code: errorCodeServiceUnavailable, Message: "auth service is unavailable", } } func handleSendEmailCode(authService AuthServiceClient, timeout time.Duration) gin.HandlerFunc { return func(c *gin.Context) { var input SendEmailCodeInput if err := decodeJSONRequest(c.Request, &input); err != nil { abortInvalidRequest(c, err.Error()) return } if err := validateSendEmailCodeInput(&input); err != nil { abortInvalidRequest(c, err.Error()) return } input.PreferredLanguage = resolvePreferredLanguage(c.Request.Header.Get("Accept-Language")) callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout) defer cancel() result, err := authService.SendEmailCode(callCtx, input) if err != nil { if errors.Is(err, context.DeadlineExceeded) { abortWithError(c, http.StatusServiceUnavailable, errorCodeServiceUnavailable, "auth service is unavailable") return } abortWithAuthServiceError(c, err) return } if err := validateSendEmailCodeResult(&result); err != nil { abortWithError(c, http.StatusInternalServerError, errorCodeInternalError, "internal server error") return } c.JSON(http.StatusOK, result) } } func handleConfirmEmailCode(authService AuthServiceClient, timeout time.Duration) gin.HandlerFunc { return func(c *gin.Context) { var input ConfirmEmailCodeInput if err := decodeJSONRequest(c.Request, &input); err != nil { abortInvalidRequest(c, err.Error()) return } if err := validateConfirmEmailCodeInput(&input); err != nil { abortInvalidRequest(c, err.Error()) return } callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout) defer cancel() result, err := authService.ConfirmEmailCode(callCtx, input) if err != nil { if errors.Is(err, context.DeadlineExceeded) { abortWithError(c, http.StatusServiceUnavailable, errorCodeServiceUnavailable, "auth service is unavailable") return } abortWithAuthServiceError(c, err) return } if err := validateConfirmEmailCodeResult(&result); err != nil { abortWithError(c, http.StatusInternalServerError, errorCodeInternalError, "internal server error") return } c.JSON(http.StatusOK, result) } } func abortInvalidRequest(c *gin.Context, message string) { abortWithError(c, http.StatusBadRequest, errorCodeInvalidRequest, message) } func abortWithAuthServiceError(c *gin.Context, err error) { var authErr *AuthServiceError if errors.As(err, &authErr) { abortWithError(c, authErr.normalizedStatusCode(), authErr.normalizedCode(), authErr.normalizedMessage()) return } abortWithError(c, http.StatusInternalServerError, errorCodeInternalError, "internal server error") } func decodeJSONRequest(r *http.Request, target any) error { if r == nil || r.Body == nil { return &malformedJSONRequestError{ message: "request body must not be empty", reason: PublicMalformedRequestReasonEmptyBody, } } return decodeJSONReader(r.Body, target) } func decodeJSONBytes(bodyBytes []byte, target any) error { return decodeJSONReader(bytes.NewReader(bodyBytes), 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", reason: PublicMalformedRequestReasonMultipleJSONObjects, } } return &malformedJSONRequestError{ message: "request body must contain a single JSON object", reason: PublicMalformedRequestReasonMultipleJSONObjects, } } 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", reason: PublicMalformedRequestReasonEmptyBody, } case errors.As(err, &syntaxErr): return &malformedJSONRequestError{ message: "request body contains malformed JSON", reason: PublicMalformedRequestReasonMalformedJSON, } case errors.Is(err, io.ErrUnexpectedEOF): return &malformedJSONRequestError{ message: "request body contains malformed JSON", reason: PublicMalformedRequestReasonMalformedJSON, } 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), reason: PublicMalformedRequestReasonInvalidJSONValue, } } return &malformedJSONRequestError{ message: "request body contains an invalid JSON value", reason: PublicMalformedRequestReasonInvalidJSONValue, } 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 ")), reason: PublicMalformedRequestReasonUnknownField, } default: return &malformedJSONRequestError{ message: "request body contains invalid JSON", reason: PublicMalformedRequestReasonMalformedJSON, } } } func validateSendEmailCodeInput(input *SendEmailCodeInput) error { input.Email = strings.TrimSpace(input.Email) if input.Email == "" { return errors.New("email must not be empty") } parsedAddress, err := mail.ParseAddress(input.Email) if err != nil || parsedAddress.Name != "" || parsedAddress.Address != input.Email { return errors.New("email must be a single valid email address") } return nil } func validateSendEmailCodeResult(result *SendEmailCodeResult) error { result.ChallengeID = strings.TrimSpace(result.ChallengeID) if result.ChallengeID == "" { return errors.New("auth service returned an empty challenge_id") } return nil } func validateConfirmEmailCodeInput(input *ConfirmEmailCodeInput) error { input.ChallengeID = strings.TrimSpace(input.ChallengeID) if input.ChallengeID == "" { return errors.New("challenge_id must not be empty") } input.Code = strings.TrimSpace(input.Code) if input.Code == "" { return errors.New("code must not be empty") } input.ClientPublicKey = strings.TrimSpace(input.ClientPublicKey) if input.ClientPublicKey == "" { return errors.New("client_public_key must not be empty") } input.TimeZone = strings.TrimSpace(input.TimeZone) if input.TimeZone == "" { return errors.New("time_zone must not be empty") } return nil } func validateConfirmEmailCodeResult(result *ConfirmEmailCodeResult) error { result.DeviceSessionID = strings.TrimSpace(result.DeviceSessionID) if result.DeviceSessionID == "" { return errors.New("auth service returned an empty device_session_id") } return nil } func malformedRequestReasonFromError(err error) (PublicMalformedRequestReason, bool) { var malformedErr *malformedJSONRequestError if !errors.As(err, &malformedErr) { return "", false } return malformedErr.reason, true } func extractPublicAuthIdentity(requestPath string, bodyBytes []byte) (publicAuthIdentity, error) { switch requestPath { case "/api/v1/public/auth/send-email-code": var input SendEmailCodeInput if err := decodeJSONBytes(bodyBytes, &input); err != nil { return publicAuthIdentity{}, err } if err := validateSendEmailCodeInput(&input); err != nil { return publicAuthIdentity{}, err } return publicAuthIdentity{ kind: "email", value: input.Email, }, nil case "/api/v1/public/auth/confirm-email-code": var input ConfirmEmailCodeInput if err := decodeJSONBytes(bodyBytes, &input); err != nil { return publicAuthIdentity{}, err } if err := validateConfirmEmailCodeInput(&input); err != nil { return publicAuthIdentity{}, err } return publicAuthIdentity{ kind: "challenge", value: input.ChallengeID, }, nil default: return publicAuthIdentity{}, errPublicAuthIdentityNotApplicable } }