204 lines
6.2 KiB
Go
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)
|
|
}
|