Add native NixOS deployment and UI-managed provider credentials
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user