Replace free-text institution entry with a searchable bank picker
GET /api/banking/institutions lists the banks Enable Banking can connect for a country (personal AIS, connectable consents only), with logos restricted to https Enable Banking hosts to match the CSP image allowlist. The connect form offers a filterable dropdown with bank logos, falling back to the previous free-text input when the list is unavailable or banking is not configured.
This commit is contained in:
@@ -20,6 +20,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -56,6 +57,7 @@ type Provider interface {
|
||||
Status(context.Context, string) (SessionStatus, error)
|
||||
Balances(context.Context, string) ([]Balance, error)
|
||||
Transactions(ctx context.Context, account domain.Account, from, to string, longest bool) ([]domain.Facts, error)
|
||||
Institutions(ctx context.Context, country string) ([]Institution, error)
|
||||
}
|
||||
type EnableBanking struct {
|
||||
HTTPClient *http.Client
|
||||
@@ -450,24 +452,81 @@ 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
|
||||
// Content-Security-Policy image allowlist under which the UI displays it.
|
||||
type Institution struct {
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country"`
|
||||
Logo string `json:"logo,omitempty"`
|
||||
}
|
||||
|
||||
// safeLogoURL admits only https Enable Banking brand URLs. Any other
|
||||
// provider-supplied location renders no logo rather than a third-party fetch.
|
||||
func safeLogoURL(logo string) string {
|
||||
u, err := url.Parse(logo)
|
||||
if err != nil || u.Scheme != "https" || u.User != nil {
|
||||
return ""
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
if host != "enablebanking.com" && !strings.HasSuffix(host, ".enablebanking.com") {
|
||||
return ""
|
||||
}
|
||||
return logo
|
||||
}
|
||||
|
||||
type aspspDTO struct {
|
||||
institutionDTO
|
||||
Logo string `json:"logo"`
|
||||
MaximumConsentValidity int64 `json:"maximum_consent_validity"`
|
||||
}
|
||||
|
||||
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"}}
|
||||
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.
|
||||
func (p *EnableBanking) Institutions(ctx context.Context, country string) ([]Institution, error) {
|
||||
country = strings.ToUpper(strings.TrimSpace(country))
|
||||
if len(country) != 2 {
|
||||
return nil, fmt.Errorf("country must be a two-letter code")
|
||||
}
|
||||
aspsps, err := p.aspsps(ctx, country)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]Institution, 0, len(aspsps))
|
||||
for _, a := range aspsps {
|
||||
if a.Name == "" || a.Country != country || a.MaximumConsentValidity <= 0 {
|
||||
continue
|
||||
}
|
||||
result = append(result, Institution{Name: a.Name, Country: a.Country, Logo: safeLogoURL(a.Logo)})
|
||||
}
|
||||
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) {
|
||||
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")
|
||||
}
|
||||
var list struct {
|
||||
ASPSPs []struct {
|
||||
institutionDTO
|
||||
MaximumConsentValidity int64 `json:"maximum_consent_validity"`
|
||||
} `json:"aspsps"`
|
||||
}
|
||||
query := url.Values{"country": {country}, "psu_type": {"personal"}, "service": {"AIS"}}
|
||||
if err := p.request(ctx, http.MethodGet, "/aspsps?"+query.Encode(), nil, &list); err != nil {
|
||||
aspsps, err := p.aspsps(ctx, country)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var validity int64
|
||||
for _, a := range list.ASPSPs {
|
||||
for _, a := range aspsps {
|
||||
if a.Name == institution && a.Country == country {
|
||||
validity = a.MaximumConsentValidity
|
||||
break
|
||||
|
||||
@@ -251,6 +251,38 @@ func TestEnableBankingLongestStrategyIsOnlySentWhenRequested(t *testing.T) {
|
||||
t.Fatalf("wrong fetching strategies requested: %q", strategies)
|
||||
}
|
||||
}
|
||||
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" {
|
||||
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}
|
||||
]}`)
|
||||
})
|
||||
list, err := p.Institutions(context.Background(), "de")
|
||||
if err != nil {
|
||||
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"},
|
||||
}
|
||||
if !reflect.DeepEqual(list, expected) {
|
||||
t.Fatalf("wrong connectable institutions: %+v", list)
|
||||
}
|
||||
if _, err := p.Institutions(context.Background(), "DEU"); err == nil {
|
||||
t.Fatal("accepted an invalid country code")
|
||||
}
|
||||
}
|
||||
func TestEnableBankingSurfacesOnlyDocumentedErrorCodes(t *testing.T) {
|
||||
body := ""
|
||||
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user