Files
finance-duck/internal/app/banking_settings.go
T

204 lines
6.2 KiB
Go

package app
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"finance-duck/internal/banking"
"finance-duck/internal/domain"
)
// Scope binds operational sessions to an application generation. It is committed
// with credentials, so a crash before saving sync-state cannot revive old sessions.
type bankingSettings struct {
AppID string `json:"app_id"`
RedirectURL string `json:"redirect_url"`
PrivateKey string `json:"private_key"`
Disabled bool `json:"disabled"`
Scope string `json:"scope"`
}
func (a *App) clearBankingSessions(scope string) {
a.ops.Sessions = nil
a.ops.Consents = make(map[string]Consent)
a.ops.AccountSync = make(map[string]string)
a.ops.LastSync = ""
a.ops.SyncError = ""
a.ops.BankingScope = scope
}
func (a *App) loadBankingSettings() error {
path := filepath.Join(a.dir, "state", "enablebanking.json")
f, err := os.Open(path)
var cfg bankingSettings
var provider *banking.EnableBanking
fromEnvironment := os.IsNotExist(err)
if fromEnvironment {
cfg.AppID = os.Getenv("ENABLEBANKING_APP_ID")
cfg.RedirectURL = os.Getenv("ENABLEBANKING_REDIRECT_URL")
keyFile := os.Getenv("ENABLEBANKING_KEY_FILE")
cfg.Disabled = cfg.AppID == "" && cfg.RedirectURL == "" && keyFile == ""
if !cfg.Disabled {
if cfg.AppID == "" || cfg.RedirectURL == "" || keyFile == "" {
return errors.New("Enable Banking requires APP_ID, KEY_FILE and REDIRECT_URL environment variables")
}
key, e := os.Open(keyFile)
if e != nil {
return errors.New("cannot read Enable Banking private key")
}
b, e := io.ReadAll(io.LimitReader(key, banking.MaxPrivateKeyPEM+1))
key.Close()
if e != nil {
return errors.New("cannot read Enable Banking private key")
}
cfg.PrivateKey = string(b)
}
// A stable environment identity detects app-ID changes on restart while
// retaining sessions through key or callback rotation of the same app.
hash := sha256.Sum256([]byte(cfg.AppID))
cfg.Scope = "env_" + hex.EncodeToString(hash[:])
} else {
if err != nil {
return errors.New("cannot read saved Enable Banking settings")
}
defer f.Close()
const limit = 256 * 1024 // Allows JSON escaping of a maximum-size PEM.
b, e := io.ReadAll(io.LimitReader(f, limit+1))
invalid := errors.New("invalid saved Enable Banking settings")
if e != nil || len(b) > limit {
return invalid
}
decoder := json.NewDecoder(bytes.NewReader(b))
if token, e := decoder.Token(); e != nil || token != json.Delim('{') {
return invalid
}
seen := make(map[string]bool, 5)
for decoder.More() {
token, e := decoder.Token()
name, ok := token.(string)
if e != nil || !ok || seen[name] {
return invalid
}
seen[name] = true
value, e := decoder.Token()
if e != nil {
return invalid
}
if name == "disabled" {
cfg.Disabled, ok = value.(bool)
} else {
var text string
text, ok = value.(string)
switch name {
case "app_id":
cfg.AppID = text
case "redirect_url":
cfg.RedirectURL = text
case "private_key":
cfg.PrivateKey = text
case "scope":
cfg.Scope = text
default:
return invalid
}
}
if !ok {
return invalid
}
}
if token, e := decoder.Token(); e != nil || token != json.Delim('}') {
return invalid
}
if _, e := decoder.Token(); e != io.EOF || len(seen) != 5 || cfg.Scope == "" || len(cfg.Scope) > 256 {
return invalid
}
if cfg.Disabled && (cfg.AppID != "" || cfg.RedirectURL != "" || cfg.PrivateKey != "") {
return invalid
}
}
if !cfg.Disabled {
provider, err = banking.NewEnableBanking(cfg.AppID, []byte(cfg.PrivateKey), cfg.RedirectURL)
if err != nil {
return errors.New("invalid Enable Banking settings")
}
}
if a.ops.BankingScope != cfg.Scope {
if a.ops.BankingScope == "" && fromEnvironment && !cfg.Disabled {
// Legacy sessions predate Settings and belong to the validated env app.
a.ops.BankingScope = cfg.Scope
} else {
a.clearBankingSessions(cfg.Scope)
}
// Fail closed if legacy binding or mismatch invalidation cannot persist.
if err = a.saveOps(); err != nil {
return errors.New("cannot bind Enable Banking sessions")
}
}
a.bankingSettings = cfg
a.callbackURL = cfg.RedirectURL
if provider != nil {
a.bank = provider
}
return nil
}
func (a *App) SaveBankingSettings(ctx context.Context, appID string, privateKey *string, redirectURL string) (State, error) {
a.mu.Lock()
defer a.mu.Unlock()
key := ""
if privateKey != nil {
key = *privateKey
} else if !a.bankingSettings.Disabled && appID == a.bankingSettings.AppID {
key = a.bankingSettings.PrivateKey
}
if key == "" {
return State{}, errors.New("an Enable Banking private key is required for this application")
}
provider, err := banking.NewEnableBanking(appID, []byte(key), redirectURL)
if err != nil {
return State{}, err
}
cfg := bankingSettings{AppID: appID, PrivateKey: key, RedirectURL: redirectURL, Scope: a.bankingSettings.Scope}
if a.bankingSettings.Disabled || appID != a.bankingSettings.AppID || cfg.Scope == "" {
cfg.Scope = domain.NewID("bank")
}
return a.persistBankingSettings(ctx, cfg, provider)
}
func (a *App) RemoveBankingSettings(ctx context.Context) (State, error) {
a.mu.Lock()
defer a.mu.Unlock()
return a.persistBankingSettings(ctx, bankingSettings{Disabled: true, Scope: domain.NewID("bank")}, nil)
}
// Caller holds mu. Only the credential file must commit: an older sync-state
// remains unusable because its scope differs. The next operational save or Open
// writes the cleared sessions, without a fallible two-file transaction here.
func (a *App) persistBankingSettings(ctx context.Context, cfg bankingSettings, provider *banking.EnableBanking) (State, error) {
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return State{}, errors.New("cannot encode Enable Banking settings")
}
if err = atomicFile(filepath.Join(a.dir, "state", "enablebanking.json"), append(b, '\n')); err != nil {
return State{}, errors.New("cannot save Enable Banking settings")
}
if cfg.Scope != a.ops.BankingScope {
a.clearBankingSessions(cfg.Scope)
}
a.authStates = make(map[string]authorization)
a.bankingSettings = cfg
a.callbackURL = cfg.RedirectURL
a.bank = nil
if provider != nil {
a.bank = provider
}
return a.snapshot(ctx)
}