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
+69 -19
View File
@@ -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) {