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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user