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:
@@ -60,7 +60,7 @@ func bankingAuthorization(t *testing.T, a *App, key *rsa.PrivateKey, appID, redi
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/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}]}`)
|
||||
case "/auth":
|
||||
var req struct {
|
||||
State string `json:"state"`
|
||||
@@ -87,7 +87,7 @@ func bankingAuthorization(t *testing.T, a *App, key *rsa.PrivateKey, appID, redi
|
||||
}
|
||||
provider.BaseURL = mock.URL
|
||||
provider.HTTPClient = mock.Client()
|
||||
if _, err := a.Authorize(context.Background(), "N26", "DE", 12); err != nil {
|
||||
if _, err := a.Authorize(context.Background(), "N26", "DE", banking.PSUPersonal, 12); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pending
|
||||
@@ -244,7 +244,7 @@ func TestBankingSavedCredentialsAndDisableOverrideEnvironment(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a = reopenBankingApp(t, a)
|
||||
if _, err := a.Authorize(ctx, "N26", "DE", 12); err == nil {
|
||||
if _, err := a.Authorize(ctx, "N26", "DE", banking.PSUPersonal, 12); err == nil {
|
||||
t.Fatal("disabled saved configuration fell back to environment")
|
||||
}
|
||||
}
|
||||
|
||||
+19
-3
@@ -14,11 +14,16 @@ type authorization struct {
|
||||
Expires time.Time
|
||||
Institution string
|
||||
Country string
|
||||
PSUType string
|
||||
HistoryMonths int
|
||||
}
|
||||
type Consent struct {
|
||||
Institution string `json:"institution"`
|
||||
Country string `json:"country"`
|
||||
Institution string `json:"institution"`
|
||||
Country string `json:"country"`
|
||||
// PSUType records the account-holder kind this consent was authorized for
|
||||
// so reconnecting reuses it: a business account authorized as personal
|
||||
// shares no accounts.
|
||||
PSUType string `json:"psu_type,omitempty"`
|
||||
HistoryMonths int `json:"history_months"`
|
||||
Error string `json:"error,omitempty"`
|
||||
NeedsReconnect bool `json:"needs_reconnect"`
|
||||
@@ -27,12 +32,22 @@ type Connection struct {
|
||||
AccountID string `json:"account_id"`
|
||||
Institution string `json:"institution"`
|
||||
Country string `json:"country"`
|
||||
PSUType string `json:"psu_type"`
|
||||
HistoryMonths int `json:"history_months"`
|
||||
Status string `json:"status"`
|
||||
ValidUntil string `json:"valid_until"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// psuType keeps legacy consents, which predate the choice, on the personal
|
||||
// flow they were originally authorized with.
|
||||
func (c Consent) psuType() string {
|
||||
if !banking.ValidPSUType(c.PSUType) {
|
||||
return banking.PSUPersonal
|
||||
}
|
||||
return c.PSUType
|
||||
}
|
||||
|
||||
func (c Consent) historyMonths() int {
|
||||
if c.HistoryMonths == 0 {
|
||||
return defaultHistoryMonths
|
||||
@@ -43,7 +58,7 @@ func (c Consent) historyMonths() int {
|
||||
func (a *App) connections(d domain.Dataset) []Connection {
|
||||
out := make([]Connection, 0, len(d.Accounts))
|
||||
for _, account := range d.Accounts {
|
||||
c := Connection{AccountID: account.ID, Institution: account.Institution, Country: "DE", HistoryMonths: defaultHistoryMonths, Status: "local"}
|
||||
c := Connection{AccountID: account.ID, Institution: account.Institution, Country: "DE", PSUType: banking.PSUPersonal, HistoryMonths: defaultHistoryMonths, Status: "local"}
|
||||
if account.ExternalAccountID != "" {
|
||||
c.Status = "reconnect_required"
|
||||
c.Error = "No saved bank consent; reconnect this account"
|
||||
@@ -55,6 +70,7 @@ func (a *App) connections(d domain.Dataset) []Connection {
|
||||
}
|
||||
meta := a.ops.Consents[session.ID]
|
||||
c.HistoryMonths = meta.historyMonths()
|
||||
c.PSUType = meta.psuType()
|
||||
if meta.Institution != "" {
|
||||
c.Institution = meta.Institution
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -17,9 +18,11 @@ type historyBank struct {
|
||||
authState string
|
||||
authorizations int
|
||||
fromDates []string
|
||||
psuTypes []string
|
||||
}
|
||||
|
||||
func (b *historyBank) Authorize(_ context.Context, _, _, state string) (string, error) {
|
||||
func (b *historyBank) Authorize(_ context.Context, _, _, psuType, state string) (string, error) {
|
||||
b.psuTypes = append(b.psuTypes, psuType)
|
||||
b.authState = state
|
||||
b.authorizations++
|
||||
return "https://bank.example/authorize", nil
|
||||
@@ -87,14 +90,14 @@ func TestAuthorizedHistorySurvivesReopenAndRespectsIncrementalCursor(t *testing.
|
||||
b := &historyBank{bankScenario: bankScenario{session: banking.Session{ID: "history_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}}}}
|
||||
a.bank = b
|
||||
for _, months := range []int{-1, 0, 121} {
|
||||
if _, err := a.Authorize(ctx, "N26", "DE", months); err == nil {
|
||||
if _, err := a.Authorize(ctx, "N26", "DE", banking.PSUPersonal, months); err == nil {
|
||||
t.Fatalf("accepted invalid history choice %d", months)
|
||||
}
|
||||
}
|
||||
if b.authorizations != 0 {
|
||||
t.Fatal("invalid history choice reached the bank")
|
||||
}
|
||||
if _, err := a.Authorize(ctx, "N26", "DE", 24); err != nil {
|
||||
if _, err := a.Authorize(ctx, "N26", "DE", banking.PSUPersonal, 24); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.Callback(ctx, "one_time_code", b.authState); err != nil {
|
||||
@@ -257,7 +260,7 @@ func TestDeletedBankAccountIsNotResurrectedByLaterConnectOrSync(t *testing.T) {
|
||||
// A later connect for a different bank triggers binding recovery.
|
||||
other := domain.Account{ID: "ing_acct", DisplayName: "ING Giro", Institution: "ING", Currency: "EUR", ExternalAccountID: "ing_uid", Active: true}
|
||||
b.session = banking.Session{ID: "ing_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{other}}
|
||||
if _, err := a.Authorize(ctx, "ING", "DE", 12); err != nil {
|
||||
if _, err := a.Authorize(ctx, "ING", "DE", banking.PSUPersonal, 12); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.Callback(ctx, "one_time_code", b.authState); err != nil {
|
||||
@@ -293,7 +296,7 @@ func TestConnectWithoutSharedAccountsFailsVisibly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
b := &historyBank{bankScenario: bankScenario{session: banking.Session{ID: "empty_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339)}}}
|
||||
a.bank = b
|
||||
if _, err := a.Authorize(ctx, "Kontist", "DE", 12); err != nil {
|
||||
if _, err := a.Authorize(ctx, "Kontist", "DE", banking.PSUBusiness, 12); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := a.Callback(ctx, "one_time_code", b.authState)
|
||||
@@ -313,3 +316,48 @@ func TestConnectWithoutSharedAccountsFailsVisibly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A business consent must stay business: reconnecting a business account with
|
||||
// the personal flow authorizes a consent that shares no accounts.
|
||||
func TestSavedAccountHolderTypeSurvivesRestartForReconnect(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
ctx := context.Background()
|
||||
account := s.Data.Accounts[0]
|
||||
account.ExternalAccountID = "kontist_uid"
|
||||
b := &historyBank{bankScenario: bankScenario{session: banking.Session{ID: "kontist_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}}}}
|
||||
a.bank = b
|
||||
if _, err := a.Authorize(ctx, "Kontist", "DE", banking.PSUBusiness, 12); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.Callback(ctx, "one_time_code", b.authState); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(b.psuTypes, []string{banking.PSUBusiness}) {
|
||||
t.Fatalf("chosen account type did not reach the provider: %q", b.psuTypes)
|
||||
}
|
||||
a = reopenBankingApp(t, a)
|
||||
a.bank = b
|
||||
after, err := a.Snapshot(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(after.Connections) != 1 || after.Connections[0].PSUType != banking.PSUBusiness {
|
||||
t.Fatalf("account type unavailable for reconnecting: %+v", after.Connections)
|
||||
}
|
||||
for _, invalid := range []string{"corporate", "Personal"} {
|
||||
if _, err := a.Authorize(ctx, "Kontist", "DE", invalid, 12); err == nil {
|
||||
t.Fatalf("accepted undocumented account type %q", invalid)
|
||||
}
|
||||
}
|
||||
// Legacy consents predate the choice and stay on the personal flow.
|
||||
meta := a.ops.Consents["kontist_session"]
|
||||
meta.PSUType = ""
|
||||
a.ops.Consents["kontist_session"] = meta
|
||||
legacy, err := a.Snapshot(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if legacy.Connections[0].PSUType != banking.PSUPersonal {
|
||||
t.Fatalf("legacy consent lost its personal default: %+v", legacy.Connections)
|
||||
}
|
||||
}
|
||||
|
||||
+20
-6
@@ -174,7 +174,10 @@ func (a *App) Backfill(ctx context.Context, rev, accountID string, historyMonths
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (a *App) Authorize(ctx context.Context, institution, country string, historyMonths int) (string, error) {
|
||||
|
||||
// Authorize starts a consent for one account-holder kind. An empty psuType
|
||||
// keeps the previous personal default for existing API callers.
|
||||
func (a *App) Authorize(ctx context.Context, institution, country, psuType string, historyMonths int) (string, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if historyMonths < 1 || historyMonths > 120 {
|
||||
@@ -191,17 +194,24 @@ func (a *App) Authorize(ctx context.Context, institution, country string, histor
|
||||
if len(country) != 2 {
|
||||
return "", errors.New("country must be a two-letter code")
|
||||
}
|
||||
if psuType == "" {
|
||||
psuType = banking.PSUPersonal
|
||||
}
|
||||
if !banking.ValidPSUType(psuType) {
|
||||
return "", errors.New("account type must be personal or business")
|
||||
}
|
||||
for state, auth := range a.authStates {
|
||||
if time.Now().After(auth.Expires) {
|
||||
delete(a.authStates, state)
|
||||
}
|
||||
}
|
||||
state := domain.NewID("auth")
|
||||
url, err := a.bank.Authorize(ctx, institution, country, state)
|
||||
if err == nil {
|
||||
a.authStates[state] = authorization{Expires: time.Now().Add(15 * time.Minute), Institution: institution, Country: country, HistoryMonths: historyMonths}
|
||||
url, err := a.bank.Authorize(ctx, institution, country, psuType, state)
|
||||
if err != nil {
|
||||
return "", bankFailure(err, "bank authorization unavailable; retry connecting")
|
||||
}
|
||||
return url, err
|
||||
a.authStates[state] = authorization{Expires: time.Now().Add(15 * time.Minute), Institution: institution, Country: country, PSUType: psuType, HistoryMonths: historyMonths}
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// Institutions lists connectable banks for the country so the UI can offer
|
||||
@@ -276,7 +286,7 @@ func (a *App) Callback(ctx context.Context, code, state string) (int, error) {
|
||||
return 0, errors.New("the bank authorized the connection but shared no accounts, so nothing was linked; accounts of another type (for example business) may need a separate consent")
|
||||
}
|
||||
a.ops.Sessions = append(a.ops.Sessions, session)
|
||||
a.ops.Consents[session.ID] = Consent{Institution: auth.Institution, Country: auth.Country, HistoryMonths: auth.HistoryMonths}
|
||||
a.ops.Consents[session.ID] = Consent{Institution: auth.Institution, Country: auth.Country, PSUType: auth.PSUType, HistoryMonths: auth.HistoryMonths}
|
||||
if err = a.saveOps(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -359,6 +369,10 @@ func bankFailure(err error, fallback string) error {
|
||||
if errors.As(err, &api) {
|
||||
return api
|
||||
}
|
||||
var consent *banking.ConsentError
|
||||
if errors.As(err, &consent) {
|
||||
return consent
|
||||
}
|
||||
return errors.New(fallback)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ type bankScenario struct {
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (b *bankScenario) Authorize(context.Context, string, string, string) (string, error) {
|
||||
func (b *bankScenario) Authorize(context.Context, string, string, string, string) (string, error) {
|
||||
return "https://bank.example/authorize", nil
|
||||
}
|
||||
func (b *bankScenario) Institutions(context.Context, string) ([]banking.Institution, error) {
|
||||
|
||||
Reference in New Issue
Block a user