diff --git a/internal/app/banking_settings_test.go b/internal/app/banking_settings_test.go index 3f5c8e7..214e27a 100644 --- a/internal/app/banking_settings_test.go +++ b/internal/app/banking_settings_test.go @@ -60,7 +60,7 @@ func bankingAuthorization(t *testing.T, a *App, key *rsa.PrivateKey, appID, redi } switch r.URL.Path { case "/aspsps": - fmt.Fprint(w, `{"aspsps":[{"name":"N26","country":"DE","maximum_consent_validity":3600}]}`) + fmt.Fprint(w, `{"aspsps":[{"name":"N26","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600}]}`) case "/auth": var req struct { State string `json:"state"` @@ -87,7 +87,7 @@ func bankingAuthorization(t *testing.T, a *App, key *rsa.PrivateKey, appID, redi } provider.BaseURL = mock.URL provider.HTTPClient = mock.Client() - if _, err := a.Authorize(context.Background(), "N26", "DE", 12); err != nil { + if _, err := a.Authorize(context.Background(), "N26", "DE", banking.PSUPersonal, 12); err != nil { t.Fatal(err) } return pending @@ -244,7 +244,7 @@ func TestBankingSavedCredentialsAndDisableOverrideEnvironment(t *testing.T) { t.Fatal(err) } a = reopenBankingApp(t, a) - if _, err := a.Authorize(ctx, "N26", "DE", 12); err == nil { + if _, err := a.Authorize(ctx, "N26", "DE", banking.PSUPersonal, 12); err == nil { t.Fatal("disabled saved configuration fell back to environment") } } diff --git a/internal/app/consent.go b/internal/app/consent.go index bf46ee2..0379378 100644 --- a/internal/app/consent.go +++ b/internal/app/consent.go @@ -14,11 +14,16 @@ type authorization struct { Expires time.Time Institution string Country string + PSUType string HistoryMonths int } type Consent struct { - Institution string `json:"institution"` - Country string `json:"country"` + Institution string `json:"institution"` + Country string `json:"country"` + // PSUType records the account-holder kind this consent was authorized for + // so reconnecting reuses it: a business account authorized as personal + // shares no accounts. + PSUType string `json:"psu_type,omitempty"` HistoryMonths int `json:"history_months"` Error string `json:"error,omitempty"` NeedsReconnect bool `json:"needs_reconnect"` @@ -27,12 +32,22 @@ type Connection struct { AccountID string `json:"account_id"` Institution string `json:"institution"` Country string `json:"country"` + PSUType string `json:"psu_type"` HistoryMonths int `json:"history_months"` Status string `json:"status"` ValidUntil string `json:"valid_until"` Error string `json:"error"` } +// psuType keeps legacy consents, which predate the choice, on the personal +// flow they were originally authorized with. +func (c Consent) psuType() string { + if !banking.ValidPSUType(c.PSUType) { + return banking.PSUPersonal + } + return c.PSUType +} + func (c Consent) historyMonths() int { if c.HistoryMonths == 0 { return defaultHistoryMonths @@ -43,7 +58,7 @@ func (c Consent) historyMonths() int { func (a *App) connections(d domain.Dataset) []Connection { out := make([]Connection, 0, len(d.Accounts)) for _, account := range d.Accounts { - c := Connection{AccountID: account.ID, Institution: account.Institution, Country: "DE", HistoryMonths: defaultHistoryMonths, Status: "local"} + c := Connection{AccountID: account.ID, Institution: account.Institution, Country: "DE", PSUType: banking.PSUPersonal, HistoryMonths: defaultHistoryMonths, Status: "local"} if account.ExternalAccountID != "" { c.Status = "reconnect_required" c.Error = "No saved bank consent; reconnect this account" @@ -55,6 +70,7 @@ func (a *App) connections(d domain.Dataset) []Connection { } meta := a.ops.Consents[session.ID] c.HistoryMonths = meta.historyMonths() + c.PSUType = meta.psuType() if meta.Institution != "" { c.Institution = meta.Institution } diff --git a/internal/app/consent_test.go b/internal/app/consent_test.go index 94fd8af..c3dedcc 100644 --- a/internal/app/consent_test.go +++ b/internal/app/consent_test.go @@ -2,6 +2,7 @@ package app import ( "context" + "reflect" "slices" "strings" "testing" @@ -17,9 +18,11 @@ type historyBank struct { authState string authorizations int fromDates []string + psuTypes []string } -func (b *historyBank) Authorize(_ context.Context, _, _, state string) (string, error) { +func (b *historyBank) Authorize(_ context.Context, _, _, psuType, state string) (string, error) { + b.psuTypes = append(b.psuTypes, psuType) b.authState = state b.authorizations++ return "https://bank.example/authorize", nil @@ -87,14 +90,14 @@ func TestAuthorizedHistorySurvivesReopenAndRespectsIncrementalCursor(t *testing. b := &historyBank{bankScenario: bankScenario{session: banking.Session{ID: "history_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}}}} a.bank = b for _, months := range []int{-1, 0, 121} { - if _, err := a.Authorize(ctx, "N26", "DE", months); err == nil { + if _, err := a.Authorize(ctx, "N26", "DE", banking.PSUPersonal, months); err == nil { t.Fatalf("accepted invalid history choice %d", months) } } if b.authorizations != 0 { t.Fatal("invalid history choice reached the bank") } - if _, err := a.Authorize(ctx, "N26", "DE", 24); err != nil { + if _, err := a.Authorize(ctx, "N26", "DE", banking.PSUPersonal, 24); err != nil { t.Fatal(err) } if _, err := a.Callback(ctx, "one_time_code", b.authState); err != nil { @@ -257,7 +260,7 @@ func TestDeletedBankAccountIsNotResurrectedByLaterConnectOrSync(t *testing.T) { // A later connect for a different bank triggers binding recovery. other := domain.Account{ID: "ing_acct", DisplayName: "ING Giro", Institution: "ING", Currency: "EUR", ExternalAccountID: "ing_uid", Active: true} b.session = banking.Session{ID: "ing_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{other}} - if _, err := a.Authorize(ctx, "ING", "DE", 12); err != nil { + if _, err := a.Authorize(ctx, "ING", "DE", banking.PSUPersonal, 12); err != nil { t.Fatal(err) } if _, err := a.Callback(ctx, "one_time_code", b.authState); err != nil { @@ -293,7 +296,7 @@ func TestConnectWithoutSharedAccountsFailsVisibly(t *testing.T) { ctx := context.Background() b := &historyBank{bankScenario: bankScenario{session: banking.Session{ID: "empty_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339)}}} a.bank = b - if _, err := a.Authorize(ctx, "Kontist", "DE", 12); err != nil { + if _, err := a.Authorize(ctx, "Kontist", "DE", banking.PSUBusiness, 12); err != nil { t.Fatal(err) } _, err := a.Callback(ctx, "one_time_code", b.authState) @@ -313,3 +316,48 @@ func TestConnectWithoutSharedAccountsFailsVisibly(t *testing.T) { } } } + +// A business consent must stay business: reconnecting a business account with +// the personal flow authorizes a consent that shares no accounts. +func TestSavedAccountHolderTypeSurvivesRestartForReconnect(t *testing.T) { + a, s := testApp(t) + ctx := context.Background() + account := s.Data.Accounts[0] + account.ExternalAccountID = "kontist_uid" + b := &historyBank{bankScenario: bankScenario{session: banking.Session{ID: "kontist_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}}}} + a.bank = b + if _, err := a.Authorize(ctx, "Kontist", "DE", banking.PSUBusiness, 12); err != nil { + t.Fatal(err) + } + if _, err := a.Callback(ctx, "one_time_code", b.authState); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(b.psuTypes, []string{banking.PSUBusiness}) { + t.Fatalf("chosen account type did not reach the provider: %q", b.psuTypes) + } + a = reopenBankingApp(t, a) + a.bank = b + after, err := a.Snapshot(ctx) + if err != nil { + t.Fatal(err) + } + if len(after.Connections) != 1 || after.Connections[0].PSUType != banking.PSUBusiness { + t.Fatalf("account type unavailable for reconnecting: %+v", after.Connections) + } + for _, invalid := range []string{"corporate", "Personal"} { + if _, err := a.Authorize(ctx, "Kontist", "DE", invalid, 12); err == nil { + t.Fatalf("accepted undocumented account type %q", invalid) + } + } + // Legacy consents predate the choice and stay on the personal flow. + meta := a.ops.Consents["kontist_session"] + meta.PSUType = "" + a.ops.Consents["kontist_session"] = meta + legacy, err := a.Snapshot(ctx) + if err != nil { + t.Fatal(err) + } + if legacy.Connections[0].PSUType != banking.PSUPersonal { + t.Fatalf("legacy consent lost its personal default: %+v", legacy.Connections) + } +} diff --git a/internal/app/import.go b/internal/app/import.go index a50d13e..fa85e48 100644 --- a/internal/app/import.go +++ b/internal/app/import.go @@ -174,7 +174,10 @@ func (a *App) Backfill(ctx context.Context, rev, accountID string, historyMonths } return result, nil } -func (a *App) Authorize(ctx context.Context, institution, country string, historyMonths int) (string, error) { + +// Authorize starts a consent for one account-holder kind. An empty psuType +// keeps the previous personal default for existing API callers. +func (a *App) Authorize(ctx context.Context, institution, country, psuType string, historyMonths int) (string, error) { a.mu.Lock() defer a.mu.Unlock() if historyMonths < 1 || historyMonths > 120 { @@ -191,17 +194,24 @@ func (a *App) Authorize(ctx context.Context, institution, country string, histor if len(country) != 2 { return "", errors.New("country must be a two-letter code") } + if psuType == "" { + psuType = banking.PSUPersonal + } + if !banking.ValidPSUType(psuType) { + return "", errors.New("account type must be personal or business") + } for state, auth := range a.authStates { if time.Now().After(auth.Expires) { delete(a.authStates, state) } } state := domain.NewID("auth") - url, err := a.bank.Authorize(ctx, institution, country, state) - if err == nil { - a.authStates[state] = authorization{Expires: time.Now().Add(15 * time.Minute), Institution: institution, Country: country, HistoryMonths: historyMonths} + url, err := a.bank.Authorize(ctx, institution, country, psuType, state) + if err != nil { + return "", bankFailure(err, "bank authorization unavailable; retry connecting") } - return url, err + a.authStates[state] = authorization{Expires: time.Now().Add(15 * time.Minute), Institution: institution, Country: country, PSUType: psuType, HistoryMonths: historyMonths} + return url, nil } // Institutions lists connectable banks for the country so the UI can offer @@ -276,7 +286,7 @@ func (a *App) Callback(ctx context.Context, code, state string) (int, error) { return 0, errors.New("the bank authorized the connection but shared no accounts, so nothing was linked; accounts of another type (for example business) may need a separate consent") } a.ops.Sessions = append(a.ops.Sessions, session) - a.ops.Consents[session.ID] = Consent{Institution: auth.Institution, Country: auth.Country, HistoryMonths: auth.HistoryMonths} + a.ops.Consents[session.ID] = Consent{Institution: auth.Institution, Country: auth.Country, PSUType: auth.PSUType, HistoryMonths: auth.HistoryMonths} if err = a.saveOps(); err != nil { return 0, err } @@ -359,6 +369,10 @@ func bankFailure(err error, fallback string) error { if errors.As(err, &api) { return api } + var consent *banking.ConsentError + if errors.As(err, &consent) { + return consent + } return errors.New(fallback) } diff --git a/internal/app/sync_test.go b/internal/app/sync_test.go index 3ba1244..ef8648f 100644 --- a/internal/app/sync_test.go +++ b/internal/app/sync_test.go @@ -21,7 +21,7 @@ type bankScenario struct { fail bool } -func (b *bankScenario) Authorize(context.Context, string, string, string) (string, error) { +func (b *bankScenario) Authorize(context.Context, string, string, string, string) (string, error) { return "https://bank.example/authorize", nil } func (b *bankScenario) Institutions(context.Context, string) ([]banking.Institution, error) { diff --git a/internal/banking/enablebanking.go b/internal/banking/enablebanking.go index 04980c8..bccc9e4 100644 --- a/internal/banking/enablebanking.go +++ b/internal/banking/enablebanking.go @@ -56,7 +56,7 @@ type Balance struct { ReferenceDate string `json:"reference_date,omitempty"` } type Provider interface { - Authorize(context.Context, string, string, string) (string, error) + Authorize(ctx context.Context, institution, country, psuType, state string) (string, error) Exchange(context.Context, string) (Session, error) Status(context.Context, string) (SessionStatus, error) Balances(context.Context, string) ([]Balance, error) @@ -457,13 +457,29 @@ func (a accountDTO) account(institution string) (domain.Account, error) { return domain.Account{ID: "acct_" + digest("enablebanking", stable), DisplayName: name, Institution: institution, Currency: a.Currency, ExternalAccountID: a.UID, IBAN: normalizeIBAN(a.AccountID.IBAN), Active: a.UID != ""}, nil } -// Institution describes a bank available for personal account information. -// Logo is retained only when it is an https Enable Banking URL, matching the +// PSU types are the documented account-holder kinds. Enable Banking warns that +// a psu_type mismatch can authorize a consent that shares no accounts, so the +// caller must choose one the bank actually supports. +const ( + PSUPersonal = "personal" + PSUBusiness = "business" +) + +// ValidPSUType reports whether s is a documented Enable Banking PSU type. +func ValidPSUType(s string) bool { + return s == PSUPersonal || s == PSUBusiness +} + +// Institution describes a bank available for account information. Logo is +// retained only when it is an https Enable Banking URL, matching the // Content-Security-Policy image allowlist under which the UI displays it. +// PSUTypes lists the account-holder kinds this bank supports; a business-only +// bank authorized as personal shares no accounts. type Institution struct { - Name string `json:"name"` - Country string `json:"country"` - Logo string `json:"logo,omitempty"` + Name string `json:"name"` + Country string `json:"country"` + Logo string `json:"logo,omitempty"` + PSUTypes []string `json:"psu_types"` } // safeLogoURL admits only https Enable Banking brand URLs. Any other @@ -482,23 +498,38 @@ func safeLogoURL(logo string) string { type aspspDTO struct { institutionDTO - Logo string `json:"logo"` - MaximumConsentValidity int64 `json:"maximum_consent_validity"` + Logo string `json:"logo"` + PSUTypes []string `json:"psu_types"` + MaximumConsentValidity int64 `json:"maximum_consent_validity"` } +// psuTypes keeps only documented values, preserving personal before business +// so callers can offer a stable default. +func (a aspspDTO) psuTypes() []string { + out := make([]string, 0, 2) + for _, kind := range []string{PSUPersonal, PSUBusiness} { + if slices.Contains(a.PSUTypes, kind) { + out = append(out, kind) + } + } + return out +} + +// aspsps lists account-information banks for a country without filtering by +// PSU type: each entry reports the types it supports. func (p *EnableBanking) aspsps(ctx context.Context, country string) ([]aspspDTO, error) { var list struct { ASPSPs []aspspDTO `json:"aspsps"` } - query := url.Values{"country": {country}, "psu_type": {"personal"}, "service": {"AIS"}} + query := url.Values{"country": {country}, "service": {"AIS"}} if err := p.request(ctx, http.MethodGet, "/aspsps?"+query.Encode(), nil, &list); err != nil { return nil, err } return list.ASPSPs, nil } -// Institutions lists the banks connectable for personal account information -// in a country, excluding entries the Authorize flow would reject anyway. +// Institutions lists the banks connectable for account information in a +// country, excluding entries the Authorize flow would reject anyway. func (p *EnableBanking) Institutions(ctx context.Context, country string) ([]Institution, error) { country = strings.ToUpper(strings.TrimSpace(country)) if len(country) != 2 { @@ -510,34 +541,53 @@ func (p *EnableBanking) Institutions(ctx context.Context, country string) ([]Ins } result := make([]Institution, 0, len(aspsps)) for _, a := range aspsps { - if a.Name == "" || a.Country != country || a.MaximumConsentValidity <= 0 { + kinds := a.psuTypes() + if a.Name == "" || a.Country != country || a.MaximumConsentValidity <= 0 || len(kinds) == 0 { continue } - result = append(result, Institution{Name: a.Name, Country: a.Country, Logo: safeLogoURL(a.Logo)}) + result = append(result, Institution{Name: a.Name, Country: a.Country, Logo: safeLogoURL(a.Logo), PSUTypes: kinds}) } slices.SortFunc(result, func(a, b Institution) int { return strings.Compare(a.Name, b.Name) }) return result, nil } -func (p *EnableBanking) Authorize(ctx context.Context, institution, country, state string) (string, error) { +// ConsentError explains why a consent cannot be started. Its message is built +// only from the caller's own request and the documented ASPSP listing fields, +// never from provider response text, so callers may show it to the user. +type ConsentError struct{ message string } + +func (e *ConsentError) Error() string { return e.message } + +// Authorize starts a consent for the given account-holder kind. psuType must +// be one the bank supports: Enable Banking documents that a mismatch can +// authorize a consent that then shares no accounts. +func (p *EnableBanking) Authorize(ctx context.Context, institution, country, psuType, state string) (string, error) { country = strings.ToUpper(strings.TrimSpace(country)) institution = strings.TrimSpace(institution) if institution == "" || len(country) != 2 || state == "" { return "", fmt.Errorf("institution, country and authorization state are required") } + if !ValidPSUType(psuType) { + return "", fmt.Errorf("account type must be personal or business") + } aspsps, err := p.aspsps(ctx, country) if err != nil { return "", err } var validity int64 + var supported []string for _, a := range aspsps { if a.Name == institution && a.Country == country { validity = a.MaximumConsentValidity + supported = a.psuTypes() break } } if validity <= 0 { - return "", fmt.Errorf("institution is unavailable for personal account information or has no valid consent duration") + return "", &ConsentError{institution + " does not offer account information through Enable Banking, or advertises no valid consent duration"} + } + if !slices.Contains(supported, psuType) { + return "", &ConsentError{fmt.Sprintf("%s does not offer account information for %s accounts; it supports: %s", institution, psuType, strings.Join(supported, ", "))} } // Avoid overflow or unexpectedly long access while honoring each bank's limit. if validity > 180*24*3600 { @@ -553,7 +603,7 @@ func (p *EnableBanking) Authorize(ctx context.Context, institution, country, sta State string `json:"state"` RedirectURL string `json:"redirect_url"` PSUType string `json:"psu_type"` - }{ASPSP: institutionDTO{institution, country}, State: state, RedirectURL: p.redirectURL, PSUType: "personal"} + }{ASPSP: institutionDTO{institution, country}, State: state, RedirectURL: p.redirectURL, PSUType: psuType} request.Access.ValidUntil = time.Now().UTC().Add(time.Duration(validity) * time.Second).Format(time.RFC3339) request.Access.Balances = true request.Access.Transactions = true diff --git a/internal/banking/enablebanking_test.go b/internal/banking/enablebanking_test.go index a5a2299..7b60eed 100644 --- a/internal/banking/enablebanking_test.go +++ b/internal/banking/enablebanking_test.go @@ -105,10 +105,10 @@ func TestEnableBankingDocumentedFlowAndPagination(t *testing.T) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/aspsps": - if r.URL.Query().Get("country") != "DE" || r.URL.Query().Get("psu_type") != "personal" { - t.Error("institution filter missing") + if r.URL.Query().Get("country") != "DE" || r.URL.Query().Get("psu_type") != "" || r.URL.Query().Get("service") != "AIS" { + t.Errorf("wrong institution listing request: %s", r.URL) } - fmt.Fprint(w, `{"aspsps":[{"name":"N26","country":"DE","maximum_consent_validity":3600}]}`) + fmt.Fprint(w, `{"aspsps":[{"name":"N26","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600}]}`) case "/auth": if r.Method != "POST" { t.Error("wrong auth method") @@ -172,7 +172,7 @@ func TestEnableBankingDocumentedFlowAndPagination(t *testing.T) { } p, k := testProvider(t, handler) key = k - authorization, err := p.Authorize(context.Background(), "N26", "de", "csrf-state") + authorization, err := p.Authorize(context.Background(), "N26", "de", PSUPersonal, "csrf-state") if err != nil || authorization != "https://enablebanking.com/auth/consent" { t.Fatalf("authorize: %s %v", authorization, err) } @@ -253,17 +253,19 @@ func TestEnableBankingLongestStrategyIsOnlySentWhenRequested(t *testing.T) { } func TestEnableBankingInstitutionsListsOnlyConnectableBanksWithSafeLogos(t *testing.T) { p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/aspsps" || r.URL.Query().Get("country") != "DE" || r.URL.Query().Get("psu_type") != "personal" || r.URL.Query().Get("service") != "AIS" { + if r.URL.Path != "/aspsps" || r.URL.Query().Get("country") != "DE" || r.URL.Query().Get("service") != "AIS" { t.Errorf("wrong institution listing request: %s", r.URL) } fmt.Fprint(w, `{"aspsps":[ - {"name":"Sparkasse","country":"DE","maximum_consent_validity":3600,"logo":"https://enablebanking.com/brands/DE/Sparkasse/"}, - {"name":"N26","country":"DE","maximum_consent_validity":3600,"logo":"http://enablebanking.com/brands/DE/N26/"}, - {"name":"Tracker Bank","country":"DE","maximum_consent_validity":3600,"logo":"https://tracker.example/pixel.png"}, - {"name":"Evil","country":"DE","maximum_consent_validity":3600,"logo":"https://evil-enablebanking.com/logo"}, - {"name":"Dormant","country":"DE","maximum_consent_validity":0}, - {"name":"Elsewhere","country":"AT","maximum_consent_validity":3600}, - {"name":"","country":"DE","maximum_consent_validity":3600} + {"name":"Sparkasse","country":"DE","psu_types":["personal","business"],"maximum_consent_validity":3600,"logo":"https://enablebanking.com/brands/DE/Sparkasse/"}, + {"name":"N26","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600,"logo":"http://enablebanking.com/brands/DE/N26/"}, + {"name":"Kontist","country":"DE","psu_types":["business"],"maximum_consent_validity":3600}, + {"name":"Tracker Bank","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600,"logo":"https://tracker.example/pixel.png"}, + {"name":"Evil","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600,"logo":"https://evil-enablebanking.com/logo"}, + {"name":"Dormant","country":"DE","psu_types":["personal"],"maximum_consent_validity":0}, + {"name":"Typeless","country":"DE","psu_types":["corporate"],"maximum_consent_validity":3600}, + {"name":"Elsewhere","country":"AT","psu_types":["personal"],"maximum_consent_validity":3600}, + {"name":"","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600} ]}`) }) list, err := p.Institutions(context.Background(), "de") @@ -271,10 +273,11 @@ func TestEnableBankingInstitutionsListsOnlyConnectableBanksWithSafeLogos(t *test t.Fatal(err) } expected := []Institution{ - {Name: "Evil", Country: "DE"}, - {Name: "N26", Country: "DE"}, - {Name: "Sparkasse", Country: "DE", Logo: "https://enablebanking.com/brands/DE/Sparkasse/"}, - {Name: "Tracker Bank", Country: "DE"}, + {Name: "Evil", Country: "DE", PSUTypes: []string{PSUPersonal}}, + {Name: "Kontist", Country: "DE", PSUTypes: []string{PSUBusiness}}, + {Name: "N26", Country: "DE", PSUTypes: []string{PSUPersonal}}, + {Name: "Sparkasse", Country: "DE", Logo: "https://enablebanking.com/brands/DE/Sparkasse/", PSUTypes: []string{PSUPersonal, PSUBusiness}}, + {Name: "Tracker Bank", Country: "DE", PSUTypes: []string{PSUPersonal}}, } if !reflect.DeepEqual(list, expected) { t.Fatalf("wrong connectable institutions: %+v", list) @@ -283,6 +286,50 @@ func TestEnableBankingInstitutionsListsOnlyConnectableBanksWithSafeLogos(t *test t.Fatal("accepted an invalid country code") } } + +// Enable Banking documents that authorizing with the wrong psu_type yields a +// consent that shares no accounts, so the requested type must reach /auth and +// an unsupported type must fail before the user is sent to a bank. +func TestEnableBankingAuthorizesTheRequestedAccountHolderType(t *testing.T) { + var sent []string + p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/aspsps" { + fmt.Fprint(w, `{"aspsps":[ + {"name":"Kontist","country":"DE","psu_types":["business"],"maximum_consent_validity":3600}, + {"name":"N26","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600} + ]}`) + return + } + var request struct { + PSUType string `json:"psu_type"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + } + sent = append(sent, request.PSUType) + fmt.Fprint(w, `{"url":"https://enablebanking.com/auth/consent"}`) + }) + if _, err := p.Authorize(context.Background(), "Kontist", "DE", PSUBusiness, "state"); err != nil { + t.Fatalf("business consent rejected: %v", err) + } + if _, err := p.Authorize(context.Background(), "N26", "DE", PSUPersonal, "state"); err != nil { + t.Fatalf("personal consent rejected: %v", err) + } + if !reflect.DeepEqual(sent, []string{PSUBusiness, PSUPersonal}) { + t.Fatalf("requested account types did not reach the provider: %q", sent) + } + _, err := p.Authorize(context.Background(), "Kontist", "DE", PSUPersonal, "state") + var consent *ConsentError + if !errors.As(err, &consent) || !strings.Contains(err.Error(), "business") { + t.Fatalf("personal consent for a business-only bank was not refused: %v", err) + } + if len(sent) != 2 { + t.Fatal("unsupported account type still started a bank authorization") + } + if _, err := p.Authorize(context.Background(), "N26", "DE", "corporate", "state"); err == nil { + t.Fatal("accepted an undocumented account type") + } +} func TestEnableBankingLinksUsableAccountsAndCountsTheRest(t *testing.T) { p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"session_id":"session-1","access":{"valid_until":"2099-01-01T00:00:00Z"},"aspsp":{"name":"ING","country":"DE"},"accounts":[ @@ -484,7 +531,10 @@ func TestEnableBankingCooldownCoversAllEndpoints(t *testing.T) { "balances": func() error { _, err := p.Balances(context.Background(), "uid"); return err }, "transactions": func() error { _, err := p.Transactions(context.Background(), account, "", "", false); return err }, "exchange": func() error { _, err := p.Exchange(context.Background(), "once-only-code"); return err }, - "authorize": func() error { _, err := p.Authorize(context.Background(), "N26", "DE", "state"); return err }, + "authorize": func() error { + _, err := p.Authorize(context.Background(), "N26", "DE", PSUPersonal, "state") + return err + }, } { t.Run(name, func(t *testing.T) { err := request() @@ -505,7 +555,7 @@ func TestEnableBankingNeverReplaysMutationAfterRateLimit(t *testing.T) { var posts atomic.Int32 p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/aspsps" { - fmt.Fprint(w, `{"aspsps":[{"name":"N26","country":"DE","maximum_consent_validity":3600}]}`) + fmt.Fprint(w, `{"aspsps":[{"name":"N26","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600}]}`) return } if r.Method != http.MethodPost { @@ -519,7 +569,7 @@ func TestEnableBankingNeverReplaysMutationAfterRateLimit(t *testing.T) { if endpoint == "exchange" { _, err = p.Exchange(context.Background(), "once-only-code") } else { - _, err = p.Authorize(context.Background(), "N26", "DE", "state") + _, err = p.Authorize(context.Background(), "N26", "DE", PSUPersonal, "state") } var limit *ratelimit.RateLimitError if !errors.As(err, &limit) || strings.Contains(err.Error(), "private") || errors.Is(err, ErrReconnect) { diff --git a/internal/server/server.go b/internal/server/server.go index 9e9515a..551c81d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -403,12 +403,13 @@ func (s *Server) authorize(w http.ResponseWriter, r *http.Request) { var b struct { Institution string `json:"institution"` Country string `json:"country"` + PSUType string `json:"psu_type"` HistoryMonths int `json:"history_months"` } if !decode(w, r, &b) { return } - v, e := s.app.Authorize(r.Context(), b.Institution, b.Country, b.HistoryMonths) + v, e := s.app.Authorize(r.Context(), b.Institution, b.Country, b.PSUType, b.HistoryMonths) respond(w, map[string]string{"url": v}, e) } func (s *Server) callback(w http.ResponseWriter, r *http.Request) { diff --git a/nix/package.nix b/nix/package.nix index 8152068..d39f258 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -27,6 +27,7 @@ let root = ../web; fileset = lib.fileset.unions [ ../web/src + ../web/public ../web/index.html ../web/package.json ../web/package-lock.json @@ -34,7 +35,7 @@ let ../web/vite.config.ts ]; }; - npmDepsHash = "sha256-Sq4qmgNpg8b3fN8v1QHiISMQt5ZHI6oZ8J0M5svV0Ys="; + npmDepsHash = "sha256-u1pe2mJk8eyzzyZ1O55kxUhxdpHRCIyGOeuuP1RILFs="; npmFlags = [ "--ignore-scripts" ]; installPhase = '' runHook preInstall diff --git a/web/src/Accounts.tsx b/web/src/Accounts.tsx index d8be6c5..ea585fe 100644 --- a/web/src/Accounts.tsx +++ b/web/src/Accounts.tsx @@ -188,11 +188,13 @@ function AccountsContent({ async function authorize( institution: string, country: string, + psuType: string, historyMonths: number, ) { const response = await request<{ url: string }>("/api/banking/authorize", { institution, country, + psu_type: psuType, history_months: historyMonths, }); const url = new URL(response.url); @@ -302,6 +304,7 @@ function AccountCard({ await authorize( institution, connection.country || "DE", + connection.psu_type || "personal", connection.history_months, ); } catch (err) { @@ -634,8 +637,19 @@ function ConnectForm({ onError: (error: string) => void; }) { const [institution, setInstitution] = useState(""); + const [psuTypes, setPSUTypes] = useState(null); + const [psuType, setPSUType] = useState("personal"); const [country, setCountry] = useState("DE"); const [historyMonths, setHistoryMonths] = useState("12"); + // Supported account types come from the bank listing. Authorizing a business + // account with the personal flow yields a consent that shares no accounts, + // so keep the choice inside what the selected bank actually offers. + const chooseInstitution = (name: string, supported?: string[]) => { + setInstitution(name); + setPSUTypes(supported ?? null); + if (supported?.length && !supported.includes(psuType)) + setPSUType(supported[0]); + }; const [busy, setBusy] = useState(false); const [copied, setCopied] = useState(false); const callback = @@ -696,7 +710,12 @@ function ConnectForm({ setBusy(true); onError(""); try { - await authorize(institution.trim(), country, Number(historyMonths)); + await authorize( + institution.trim(), + country, + psuType, + Number(historyMonths), + ); } catch (err) { onError(err instanceof Error ? err.message : String(err)); setBusy(false); @@ -707,8 +726,28 @@ function ConnectForm({ country={country} configured={state.status.banking_configured} value={institution} - onChange={setInstitution} + onChange={chooseInstitution} /> + + + { setCountry(e.target.value.toUpperCase()); - setInstitution(""); + chooseInstitution(""); }} /> @@ -765,7 +804,7 @@ function InstitutionSelect({ country: string; configured: boolean; value: string; - onChange: (name: string) => void; + onChange: (name: string, psuTypes?: string[]) => void; }) { const [institutions, setInstitutions] = useState(null); const [loadError, setLoadError] = useState(""); @@ -840,7 +879,7 @@ function InstitutionSelect({ if (e.key === "Enter" && open) { e.preventDefault(); if (shown.length === 1) { - onChange(shown[0].name); + onChange(shown[0].name, shown[0].psu_types); setOpen(false); } } @@ -860,7 +899,7 @@ function InstitutionSelect({ aria-selected={i.name === value} onMouseDown={(e) => e.preventDefault()} onClick={() => { - onChange(i.name); + onChange(i.name, i.psu_types); setOpen(false); }} > diff --git a/web/src/api.ts b/web/src/api.ts index db3d0af..0556dcf 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -68,6 +68,7 @@ export interface Connection { account_id: string; institution: string; country: string; + psu_type: string; history_months: number; status: "local" | "connected" | "reconnect_required" | "error"; valid_until: string; @@ -77,6 +78,7 @@ export interface Institution { name: string; country: string; logo?: string; + psu_types: string[]; } export interface State { data: Dataset;