Add native NixOS deployment and UI-managed provider credentials
This commit is contained in:
@@ -19,7 +19,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -58,21 +57,29 @@ type EnableBanking struct {
|
||||
|
||||
var _ Provider = (*EnableBanking)(nil)
|
||||
|
||||
func NewEnableBanking(appID, keyFile, redirectURL string) (*EnableBanking, error) {
|
||||
if strings.TrimSpace(appID) == "" {
|
||||
return nil, fmt.Errorf("Enable Banking application ID is required")
|
||||
// MaxPrivateKeyPEM bounds uploaded and environment-loaded private keys.
|
||||
const MaxPrivateKeyPEM = 32 * 1024
|
||||
|
||||
func NewEnableBanking(appID string, keyPEM []byte, redirectURL string) (*EnableBanking, error) {
|
||||
if len(appID) == 0 || len(appID) > 256 {
|
||||
return nil, errors.New("invalid Enable Banking application ID")
|
||||
}
|
||||
for _, c := range appID {
|
||||
if c < 33 || c > 126 {
|
||||
return nil, errors.New("invalid Enable Banking application ID")
|
||||
}
|
||||
}
|
||||
redirect, err := url.Parse(redirectURL)
|
||||
if err != nil || redirect.Host == "" || (redirect.Scheme != "https" && redirect.Scheme != "http") || redirect.User != nil {
|
||||
return nil, fmt.Errorf("invalid Enable Banking redirect URL")
|
||||
if err != nil || redirect.Hostname() == "" || (redirect.Scheme != "https" && redirect.Scheme != "http") || redirect.User != nil || redirect.Opaque != "" || redirect.Path != "/api/banking/callback" || redirect.RawPath != "" || redirect.RawQuery != "" || redirect.ForceQuery || redirect.Fragment != "" || strings.Contains(redirectURL, "#") {
|
||||
return nil, errors.New("invalid Enable Banking redirect URL")
|
||||
}
|
||||
content, err := os.ReadFile(keyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read Enable Banking RSA private key: %w", err)
|
||||
if len(keyPEM) > MaxPrivateKeyPEM {
|
||||
return nil, errors.New("Enable Banking private key exceeds size limit")
|
||||
}
|
||||
block, _ := pem.Decode(content)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("Enable Banking key must be PEM encoded")
|
||||
content := bytes.TrimSpace(keyPEM)
|
||||
block, rest := pem.Decode(content)
|
||||
if block == nil || !bytes.HasPrefix(content, []byte("-----BEGIN "+block.Type+"-----")) || len(bytes.TrimSpace(rest)) != 0 || len(block.Headers) != 0 {
|
||||
return nil, errors.New("Enable Banking key must be a single PEM private key")
|
||||
}
|
||||
var key *rsa.PrivateKey
|
||||
switch block.Type {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package banking
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
@@ -14,8 +15,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -27,11 +26,8 @@ func testProvider(t *testing.T, handler http.HandlerFunc) (*EnableBanking, *rsa.
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "private.pem")
|
||||
if err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := NewEnableBanking("test-app", path, "http://localhost:8080/api/banking/callback")
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
||||
p, err := NewEnableBanking("test-app", keyPEM, "http://localhost:8080/api/banking/callback")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -268,3 +264,60 @@ func TestEnableBankingExpiredConsentRequiresReconnect(t *testing.T) {
|
||||
t.Fatalf("expired consent must request reconnection: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnableBankingValidatesUploadedCredentials(t *testing.T) {
|
||||
_, key := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("credential validation must not call provider")
|
||||
})
|
||||
pkcs1 := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
||||
der, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pkcs8 := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
|
||||
for _, content := range [][]byte{pkcs1, pkcs8} {
|
||||
p, err := NewEnableBanking("test-app", content, "https://finance.example/api/banking/callback")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := p.jwt()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
assertJWT(t, r, key)
|
||||
}
|
||||
weak, err := rsa.GenerateKey(rand.Reader, 1024)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, content := range map[string][]byte{
|
||||
"invalid": []byte("secret-invalid-key"),
|
||||
"oversized": bytes.Repeat([]byte("k"), MaxPrivateKeyPEM+1),
|
||||
"multiple": append(append([]byte{}, pkcs1...), pkcs8...),
|
||||
"prefix": append([]byte("secret-prefix\n"), pkcs1...),
|
||||
"weak": pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(weak)}),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := NewEnableBanking("test-app", content, "https://finance.example/api/banking/callback"); err == nil || strings.Contains(err.Error(), "secret") {
|
||||
t.Fatal("invalid PEM accepted or leaked")
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, appID := range []string{"", "app one", "app\none", "app\u007fone", strings.Repeat("a", 257)} {
|
||||
if _, err := NewEnableBanking(appID, pkcs1, "https://finance.example/api/banking/callback"); err == nil {
|
||||
t.Fatal("invalid app ID accepted")
|
||||
}
|
||||
}
|
||||
for _, redirect := range []string{
|
||||
"https://finance.example/", "https://finance.example/api/banking/callback?secret=value",
|
||||
"https://finance.example/api/banking/callback#", "https://finance.example/api/banking/callback?",
|
||||
"https://user:secret@finance.example/api/banking/callback", "ftp://finance.example/api/banking/callback",
|
||||
"https://finance.example/api/banking/%63allback", "https:///api/banking/callback",
|
||||
} {
|
||||
if _, err := NewEnableBanking("test-app", pkcs1, redirect); err == nil || strings.Contains(err.Error(), "secret") {
|
||||
t.Fatal("invalid callback accepted or leaked")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user