Authorize consents for the account-holder type the bank supports

Kontist authorized but shared no accounts: psu_type was hardcoded to
personal, and Enable Banking documents that a psu_type mismatch can
yield a consent without the expected accounts. The bank listing now
reports each institution's supported psu_types, the connect form offers
only those, the chosen type reaches POST /auth, and an unsupported
combination is refused before the user is sent to a bank. The choice is
stored per consent so reconnecting reuses it; consents predating the
choice stay personal.

Also repairs the frontend derivation, which the Montserrat dependency
broke: npmDepsHash was stale and web/public was missing from the
fileset, so the traced duck icon never reached the built assets.
This commit is contained in:
Lars Nolden
2026-09-11 13:39:45 +02:00
parent 35d91a5c48
commit c33e8d5573
11 changed files with 282 additions and 61 deletions
+66 -16
View File
@@ -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