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:
Lars Nolden
2026-09-11 11:17:03 +02:00
parent a8722d58c3
commit 3df9bda989
9 changed files with 345 additions and 24 deletions
+68 -9
View File
@@ -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