Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ import (
)

const (
loginEndpoint = "/auth/login/"
mfaEndpoint = "/auth/login/mfa/"
userAgent = "monarchmoney-go/1.0.0"
loginEndpoint = "/auth/login/"
mfaEndpoint = "/auth/login/mfa/"
defaultUserAgent = "monarchmoney-go/1.0.0"
)

// Service handles authentication operations
Expand All @@ -37,14 +37,19 @@ type Service struct {
logger types.Logger
}

// NewService creates a new auth service
func NewService(baseURL string, httpClient *http.Client, logger types.Logger) *Service {
// NewService creates a new auth service. An optional userAgent can be provided;
// if empty, the default library user-agent is used.
func NewService(baseURL string, httpClient *http.Client, logger types.Logger, userAgent string) *Service {
if userAgent == "" {
userAgent = defaultUserAgent
}
headers := map[string]string{
"Accept": "application/json",
"Content-Type": "application/json",
"Client-Platform": "web",
"User-Agent": userAgent,
"Origin": "https://app.monarchmoney.com",
"Origin": "https://app.monarch.com",
"Referer": "https://app.monarch.com/",
"device-uuid": uuid.New().String(),
}

Expand Down Expand Up @@ -91,15 +96,10 @@ func (s *Service) LoginWithTOTP(ctx context.Context, email, password, totpSecret
return s.submitMFA(ctx, email, password, code)
}

// LoginWithEmailOTP performs login with email OTP code
// LoginWithEmailOTP performs login with email OTP code.
// NOTE: The initial login must have already been attempted (which triggers the OTP email).
// Re-submits login with the OTP code in the email_otp field.
func (s *Service) LoginWithEmailOTP(ctx context.Context, email, password, otpCode string) error {
// First attempt login
err := s.login(ctx, email, password, "")
if err != nil && err.Error() != "Email OTP required" {
return err
}

// Submit OTP code
return s.submitEmailOTP(ctx, email, password, otpCode)
}

Expand Down Expand Up @@ -128,7 +128,7 @@ func (s *Service) LoginInteractive(ctx context.Context, email, password string)
// Trim whitespace and newline
otpCode = strings.TrimSpace(otpCode)

// Submit OTP code
// Submit OTP code via email_otp field
return s.submitEmailOTP(ctx, email, password, otpCode)

} else if err.Error() == "MFA required" {
Expand Down
34 changes: 28 additions & 6 deletions internal/transport/graphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"strings"
"time"

"github.com/eshaffer321/monarchmoney-go/internal/types"
Expand Down Expand Up @@ -36,8 +37,9 @@ type GraphQLTransport struct {

// GraphQLRequest represents a GraphQL request
type GraphQLRequest struct {
Query string `json:"query"`
Variables map[string]interface{} `json:"variables,omitempty"`
Query string `json:"query"`
Variables map[string]interface{} `json:"variables,omitempty"`
OperationName string `json:"operationName,omitempty"`
}

// GraphQLResponse represents a GraphQL response
Expand Down Expand Up @@ -83,7 +85,8 @@ func NewGraphQLTransport(opts *Options) *GraphQLTransport {
"Content-Type": contentType,
"Client-Platform": "web",
"User-Agent": types.UserAgent,
"Origin": "https://app.monarchmoney.com",
"Origin": "https://app.monarch.com",
"Referer": "https://app.monarch.com/",
}

// Merge custom headers
Expand Down Expand Up @@ -113,10 +116,22 @@ func (t *GraphQLTransport) Execute(ctx context.Context, query string, variables
return types.ErrSessionExpired
}

// Create request
// Create request — include operationName for servers that require it
opName := ""
for _, prefix := range []string{"mutation ", "query ", "subscription "} {
if idx := strings.Index(query, prefix); idx >= 0 {
rest := query[idx+len(prefix):]
if end := strings.IndexAny(rest, "( {"); end > 0 {
opName = rest[:end]
}
break
}
}

req := &GraphQLRequest{
Query: query,
Variables: variables,
Query: query,
Variables: variables,
OperationName: opName,
}

// Marshal request
Expand Down Expand Up @@ -268,6 +283,13 @@ func (t *GraphQLTransport) handleHTTPError(statusCode int, body []byte) error {
if msg == "" {
msg = errResp.Error
}
if msg == "" {
// Include raw body for debugging when no structured error found
msg = string(body)
if len(msg) > 500 {
msg = msg[:500]
}
}
return &types.Error{
Code: "BAD_REQUEST",
Message: msg,
Expand Down
161 changes: 161 additions & 0 deletions internal/transport/graphql_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
package transport

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/eshaffer321/monarchmoney-go/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestHandleHTTPError_ServerError_IncludesResponseBody(t *testing.T) {
Expand Down Expand Up @@ -86,3 +93,157 @@ func TestHandleHTTPError_ServerError_IncludesStatusCodeDescription(t *testing.T)
})
}
}

func TestHandleHTTPError_BadRequest_RawBody(t *testing.T) {
transport := &GraphQLTransport{}

tests := []struct {
name string
body []byte
expectedMsg string
}{
{
name: "structured JSON error",
body: []byte(`{"message": "invalid field"}`),
expectedMsg: "invalid field",
},
{
name: "no message, uses error field",
body: []byte(`{"error": "bad input"}`),
expectedMsg: "bad input",
},
{
name: "no structured error, falls back to raw body",
body: []byte(`something went wrong with processing`),
expectedMsg: "something went wrong with processing",
},
{
name: "empty body, raw body fallback is empty string",
body: []byte{},
expectedMsg: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := transport.handleHTTPError(http.StatusBadRequest, tt.body)
assert.Error(t, err)
typedErr, ok := err.(*types.Error)
require.True(t, ok)
assert.Equal(t, "BAD_REQUEST", typedErr.Code)
assert.Contains(t, typedErr.Message, tt.expectedMsg)
})
}
}

func TestNewGraphQLTransport_DefaultHeaders(t *testing.T) {
transport := NewGraphQLTransport(nil)

assert.Equal(t, "https://api.monarch.com", transport.baseURL)
assert.Equal(t, "https://app.monarch.com", transport.headers["Origin"])
assert.Equal(t, "https://app.monarch.com/", transport.headers["Referer"])
assert.Equal(t, types.UserAgent, transport.headers["User-Agent"])
}

func TestNewGraphQLTransport_CustomHeaders(t *testing.T) {
transport := NewGraphQLTransport(&Options{
Headers: map[string]string{
"X-Custom": "test-value",
},
})

assert.Equal(t, "test-value", transport.headers["X-Custom"])
// Default headers should still be present
assert.Equal(t, "https://app.monarch.com", transport.headers["Origin"])
}

func TestExecute_OperationName(t *testing.T) {
// Set up a test server that captures the request
var receivedReq GraphQLRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&receivedReq); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"data": map[string]interface{}{"test": "ok"},
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}))
defer server.Close()

transport := NewGraphQLTransport(&Options{
BaseURL: server.URL,
})
transport.session = &types.Session{
Token: "test-token",
ExpiresAt: time.Now().Add(1 * time.Hour),
}

tests := []struct {
name string
query string
expected string
}{
{"mutation", "mutation CreateTransaction($input: Input!) { create { id } }", "CreateTransaction"},
{"query", "query GetAccounts { accounts { id } }", "GetAccounts"},
{"no operation name", "{ accounts { id } }", ""},
{"mutation with parens", "mutation Common_Create($x: X) { create { id } }", "Common_Create"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
receivedReq = GraphQLRequest{} // reset between tests
var result map[string]interface{}
err := transport.Execute(context.Background(), tt.query, nil, &result)
require.NoError(t, err)
assert.Equal(t, tt.expected, receivedReq.OperationName)
})
}
}

func TestExecute_NotAuthenticated(t *testing.T) {
transport := NewGraphQLTransport(nil)
// No session set
err := transport.Execute(context.Background(), "query { test }", nil, nil)
assert.ErrorIs(t, err, types.ErrNotAuthenticated)
}

func TestExecute_SessionExpired(t *testing.T) {
transport := NewGraphQLTransport(nil)
transport.session = &types.Session{
Token: "expired-token",
ExpiresAt: time.Now().Add(-1 * time.Hour),
}
err := transport.Execute(context.Background(), "query { test }", nil, nil)
assert.ErrorIs(t, err, types.ErrSessionExpired)
}

func TestTruncateQuery(t *testing.T) {
short := "query { test }"
assert.Equal(t, short, truncateQuery(short))

long := ""
for i := 0; i < 200; i++ {
long += "x"
}
truncated := truncateQuery(long)
assert.Equal(t, 103, len(truncated)) // 100 + "..."
assert.Contains(t, truncated, "...")
}

func TestSetAuth(t *testing.T) {
transport := NewGraphQLTransport(nil)
transport.SetAuth("my-token")
assert.Equal(t, "my-token", transport.session.Token)
}

func TestSetSession(t *testing.T) {
transport := NewGraphQLTransport(nil)
session := &types.Session{Token: "tok", UserID: "uid"}
transport.SetSession(session)
assert.Equal(t, session, transport.session)
}
24 changes: 24 additions & 0 deletions pkg/monarch/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func newAuthService(client *Client) *authService {
client.baseURL,
client.httpClient,
client.options.Logger,
client.options.UserAgent,
),
}
}
Expand Down Expand Up @@ -85,6 +86,29 @@ func (a *authService) LoginWithMFA(ctx context.Context, email, password, mfaCode
return nil
}

// LoginWithEmailOTP performs login with email OTP code
func (a *authService) LoginWithEmailOTP(ctx context.Context, email, password, otpCode string) error {
if err := a.service.LoginWithEmailOTP(ctx, email, password, otpCode); err != nil {
return err
}

// Get session and update client
session, err := a.service.GetSession()
if err != nil {
return err
}

a.client.session = a.convertSession(session)
a.client.transport.SetSession(session)

// Save session if configured
if a.client.options.SessionFile != "" {
_ = a.service.SaveSession(a.client.options.SessionFile)
}

return nil
}

// LoginWithTOTP performs login with TOTP secret
func (a *authService) LoginWithTOTP(ctx context.Context, email, password, totpSecret string) error {
if err := a.service.LoginWithTOTP(ctx, email, password, totpSecret); err != nil {
Expand Down
3 changes: 3 additions & 0 deletions pkg/monarch/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ type ClientOptions struct {
// SessionFile path for session persistence
SessionFile string

// UserAgent overrides the default user-agent string for auth requests
UserAgent string

// Logger for debug logging
Logger Logger

Expand Down
3 changes: 3 additions & 0 deletions pkg/monarch/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ type AuthService interface {
// LoginWithMFA performs login with MFA
LoginWithMFA(ctx context.Context, email, password, mfaCode string) error

// LoginWithEmailOTP performs login with email OTP code
LoginWithEmailOTP(ctx context.Context, email, password, otpCode string) error

// LoginWithTOTP performs login with TOTP secret
LoginWithTOTP(ctx context.Context, email, password, totpSecret string) error

Expand Down
Loading