Skip to content

Commit

Permalink
Feat: NWA auth for self-hosted hubs (#1016)
Browse files Browse the repository at this point in the history
* feat: nwc create_connection command (WIP)

* feat: allow creating superuser apps from the ui

* fix: pass methods rather than scopes in create_connection method

* chore: add extra tests

* fix: use browser router in http mode

* fix: update links to not use hash router

* fix: add redirect from hash router url

* fix: return nostrWalletConnectUrl in nwc connection success event and message

* feat: publish nwa event

* chore: use nwc info event instead of nwa event

* feat: create custom alby go detail page

* chore: allow creating/editing apps with the same name

* fix: convert budget from msats to sats (#1111)

* chore: address NWA feedback

* chore: address feedback

- adjust button copy
- make create_connection methods consistent with http deeplink flow
- remove unused constant
- add empty string check before adding lud16 tag
- fix test

* feat: add support for notification_types in create_connection method

* fix: shorter button copy

* fix: do not include lud16 tag in published info event

* Feat: add lud16 to get_info response (#1128)

feat: add lud16 to get_info response

* chore: minor alby go screen improvements

* fix: incorrect unlock password error message to create app with superuser access

* chore: minor ui improvements on alby go detail page

* chore: avoid duplicate app names by adding a suffix

* chore: move scopes check to apps service, add new tests, DRY controller test setup

* fix: scopes component full access scopes and isolated scope group check

* fix: use supported capabilities for Alby Go

* fix: return correct app name

* chore: address minor comments

---------

Co-authored-by: im-adithya <imadithyavardhan@gmail.com>
  • Loading branch information
rolznz and im-adithya authored Feb 27, 2025
1 parent 1d5730a commit 3e1d16d
Show file tree
Hide file tree
Showing 49 changed files with 1,856 additions and 297 deletions.
3 changes: 3 additions & 0 deletions .mockery.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ packages:
github.com/getAlby/hub/lnclient:
interfaces:
LNClient:
github.com/getAlby/hub/config:
interfaces:
Config:
2 changes: 1 addition & 1 deletion alby/alby_oauth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -673,7 +673,7 @@ func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient.
scopes = append(scopes, constants.NOTIFICATIONS_SCOPE)
}

app, _, err := apps.NewAppsService(svc.db, svc.eventPublisher, svc.keys).CreateApp(
app, _, err := apps.NewAppsService(svc.db, svc.eventPublisher, svc.keys, svc.cfg).CreateApp(
ALBY_ACCOUNT_APP_NAME,
connectionPubkey,
budget,
Expand Down
37 changes: 18 additions & 19 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ type api struct {
func NewAPI(svc service.Service, gormDB *gorm.DB, config config.Config, keys keys.Keys, albyOAuthSvc alby.AlbyOAuthService, eventPublisher events.EventPublisher) *api {
return &api{
db: gormDB,
appsSvc: apps.NewAppsService(gormDB, eventPublisher, keys),
appsSvc: apps.NewAppsService(gormDB, eventPublisher, keys, config),
cfg: config,
svc: svc,
permissionsSvc: permissions.NewPermissionsService(gormDB, eventPublisher),
Expand All @@ -58,24 +58,18 @@ func NewAPI(svc service.Service, gormDB *gorm.DB, config config.Config, keys key
}

func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppResponse, error) {
backendType, _ := api.cfg.Get("LNBackendType", "")
if createAppRequest.Isolated &&
backendType != config.LDKBackendType &&
backendType != config.LNDBackendType &&
backendType != config.PhoenixBackendType {
return nil, fmt.Errorf(
"sub-wallets are currently not supported on your node backend. Try LDK or LND")
if slices.Contains(createAppRequest.Scopes, constants.SUPERUSER_SCOPE) {
if !api.cfg.CheckUnlockPassword(createAppRequest.UnlockPassword) {
return nil, fmt.Errorf(
"incorrect unlock password to create app with superuser permission")
}
}

expiresAt, err := api.parseExpiresAt(createAppRequest.ExpiresAt)
if err != nil {
return nil, fmt.Errorf("invalid expiresAt: %v", err)
}

if len(createAppRequest.Scopes) == 0 {
return nil, fmt.Errorf("won't create an app without scopes")
}

for _, scope := range createAppRequest.Scopes {
if !slices.Contains(permissions.AllScopes(), scope) {
return nil, fmt.Errorf("did not recognize requested scope: %s", scope)
Expand Down Expand Up @@ -106,7 +100,7 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons

responseBody := &CreateAppResponse{}
responseBody.Id = app.ID
responseBody.Name = createAppRequest.Name
responseBody.Name = app.Name
responseBody.Pubkey = app.AppPubkey
responseBody.PairingSecret = pairingSecretKey
responseBody.WalletPubkey = *app.WalletPubkey
Expand Down Expand Up @@ -208,12 +202,17 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e
existingScopeMap[perm.Scope] = true
}

if slices.Contains(newScopes, constants.SUPERUSER_SCOPE) && !existingScopeMap[constants.SUPERUSER_SCOPE] {
return fmt.Errorf(
"cannot update app to add superuser permission")
}

// Add new permissions
for _, method := range newScopes {
if !existingScopeMap[method] {
for _, scope := range newScopes {
if !existingScopeMap[scope] {
perm := db.AppPermission{
App: *userApp,
Scope: method,
Scope: scope,
ExpiresAt: expiresAt,
MaxAmountSat: int(maxAmount),
BudgetRenewal: budgetRenewal,
Expand All @@ -222,12 +221,12 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e
return err
}
}
delete(existingScopeMap, method)
delete(existingScopeMap, scope)
}

// Remove old permissions
for method := range existingScopeMap {
if err := tx.Where("app_id = ? AND scope = ?", userApp.ID, method).Delete(&db.AppPermission{}).Error; err != nil {
for scope := range existingScopeMap {
if err := tx.Where("app_id = ? AND scope = ?", userApp.ID, scope).Delete(&db.AppPermission{}).Error; err != nil {
return err
}
}
Expand Down
23 changes: 23 additions & 0 deletions api/apps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package api

import (
"testing"

"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/tests/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCreateApp_SuperuserScopeIncorrectPassword(t *testing.T) {
cfg := mocks.NewMockConfig(t)
cfg.On("CheckUnlockPassword", "").Return(false)
theAPI := &api{svc: mocks.NewMockService(t), cfg: cfg}
response, err := theAPI.CreateApp(&CreateAppRequest{
Scopes: []string{constants.SUPERUSER_SCOPE},
})

assert.Nil(t, response)
require.Error(t, err)
assert.Equal(t, "incorrect unlock password to create app with superuser permission", err.Error())
}
19 changes: 10 additions & 9 deletions api/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,16 @@ type TopupIsolatedAppRequest struct {
}

type CreateAppRequest struct {
Name string `json:"name"`
Pubkey string `json:"pubkey"`
MaxAmountSat uint64 `json:"maxAmount"`
BudgetRenewal string `json:"budgetRenewal"`
ExpiresAt string `json:"expiresAt"`
Scopes []string `json:"scopes"`
ReturnTo string `json:"returnTo"`
Isolated bool `json:"isolated"`
Metadata Metadata `json:"metadata,omitempty"`
Name string `json:"name"`
Pubkey string `json:"pubkey"`
MaxAmountSat uint64 `json:"maxAmount"`
BudgetRenewal string `json:"budgetRenewal"`
ExpiresAt string `json:"expiresAt"`
Scopes []string `json:"scopes"`
ReturnTo string `json:"returnTo"`
Isolated bool `json:"isolated"`
Metadata Metadata `json:"metadata,omitempty"`
UnlockPassword string `json:"unlockPassword"`
}

type StartRequest struct {
Expand Down
60 changes: 55 additions & 5 deletions apps/apps_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import (
"errors"
"fmt"
"slices"
"strings"
"time"

"github.com/getAlby/hub/config"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/events"
Expand All @@ -28,20 +30,45 @@ type appsService struct {
db *gorm.DB
eventPublisher events.EventPublisher
keys keys.Keys
cfg config.Config
}

func NewAppsService(db *gorm.DB, eventPublisher events.EventPublisher, keys keys.Keys) *appsService {
func NewAppsService(db *gorm.DB, eventPublisher events.EventPublisher, keys keys.Keys, cfg config.Config) *appsService {
return &appsService{
db: db,
eventPublisher: eventPublisher,
keys: keys,
cfg: cfg,
}
}

func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool, metadata map[string]interface{}) (*db.App, string, error) {
if isolated && (slices.Contains(scopes, constants.SIGN_MESSAGE_SCOPE)) {
// cannot sign messages because the isolated app is a custodial sub-wallet
return nil, "", errors.New("Sub-wallet app connection cannot have sign_message scope")
if isolated {
if slices.Contains(scopes, constants.SIGN_MESSAGE_SCOPE) {
// cannot sign messages because the isolated app is a custodial sub-wallet
return nil, "", errors.New("Sub-wallet app connection cannot have sign_message scope")
}

backendType, _ := svc.cfg.Get("LNBackendType", "")
if backendType != config.LDKBackendType &&
backendType != config.LNDBackendType &&
backendType != config.PhoenixBackendType {
return nil, "", fmt.Errorf(
"sub-wallets are currently not supported on your node backend. Try LDK or LND")
}
}

if budgetRenewal == "" {
budgetRenewal = constants.BUDGET_RENEWAL_NEVER
}

if !slices.Contains(constants.GetBudgetRenewals(), budgetRenewal) {
return nil, "", fmt.Errorf("invalid budget renewal. Must be one of %s", strings.Join(constants.GetBudgetRenewals(), ","))
}

// ensure there is at least one scope
if scopes == nil || len(scopes) == 0 {
return nil, "", errors.New("no scopes provided")
}

var pairingPublicKey string
Expand Down Expand Up @@ -69,7 +96,21 @@ func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint6
}
}

app := db.App{Name: name, AppPubkey: pairingPublicKey, Isolated: isolated, Metadata: datatypes.JSON(metadataBytes)}
// use a suffix to avoid duplicate names
nameIndex := 0
var freeName string
for ; ; nameIndex++ {
freeName = name
if nameIndex > 0 {
freeName += fmt.Sprintf(" (%d)", nameIndex)
}
existingApp := svc.GetAppByName(freeName)
if existingApp == nil {
break
}
}

app := db.App{Name: freeName, AppPubkey: pairingPublicKey, Isolated: isolated, Metadata: datatypes.JSON(metadataBytes)}

err := svc.db.Transaction(func(tx *gorm.DB) error {
err := tx.Save(&app).Error
Expand Down Expand Up @@ -151,3 +192,12 @@ func (svc *appsService) GetAppByPubkey(pubkey string) *db.App {
}
return &dbApp
}

func (svc *appsService) GetAppByName(name string) *db.App {
dbApp := db.App{}
findResult := svc.db.Where("name = ?", name).First(&dbApp)
if findResult.RowsAffected == 0 {
return nil
}
return &dbApp
}
58 changes: 58 additions & 0 deletions apps/tests/apps_service_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package tests

import (
"testing"

"github.com/getAlby/hub/apps"
"github.com/getAlby/hub/config"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestHandleCreateApp_NilScopes(t *testing.T) {
// ctx := context.TODO()
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()

appsService := apps.NewAppsService(svc.DB, svc.EventPublisher, svc.Keys, svc.Cfg)
app, secretKey, err := appsService.CreateApp("Test", "", 0, "monthly", nil, nil, false, nil)

assert.Nil(t, app)
assert.Equal(t, "", secretKey)
require.Error(t, err)
assert.Equal(t, "no scopes provided", err.Error())
}

func TestHandleCreateApp_EmptyScopes(t *testing.T) {
// ctx := context.TODO()
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()

appsService := apps.NewAppsService(svc.DB, svc.EventPublisher, svc.Keys, svc.Cfg)
app, secretKey, err := appsService.CreateApp("Test", "", 0, "monthly", nil, []string{}, false, nil)

assert.Nil(t, app)
assert.Equal(t, "", secretKey)
require.Error(t, err)
assert.Equal(t, "no scopes provided", err.Error())
}

func TestHandleCreateApp_IsolatedUnsupportedBackendType(t *testing.T) {
// ctx := context.TODO()
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
svc.Cfg.SetUpdate("BackendType", config.CashuBackendType, "")

appsService := apps.NewAppsService(svc.DB, svc.EventPublisher, svc.Keys, svc.Cfg)
app, secretKey, err := appsService.CreateApp("Test", "", 0, "monthly", nil, []string{constants.GET_INFO_SCOPE}, true, nil)

assert.Nil(t, app)
assert.Equal(t, "", secretKey)
require.Error(t, err)
assert.Equal(t, "sub-wallets are currently not supported on your node backend. Try LDK or LND", err.Error())
}
11 changes: 11 additions & 0 deletions constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ const (
BUDGET_RENEWAL_NEVER = "never"
)

func GetBudgetRenewals() []string {
return []string{
BUDGET_RENEWAL_DAILY,
BUDGET_RENEWAL_WEEKLY,
BUDGET_RENEWAL_MONTHLY,
BUDGET_RENEWAL_YEARLY,
BUDGET_RENEWAL_NEVER,
}
}

const (
PAY_INVOICE_SCOPE = "pay_invoice" // also covers pay_keysend and multi_* payment methods
GET_BALANCE_SCOPE = "get_balance"
Expand All @@ -28,6 +38,7 @@ const (
LIST_TRANSACTIONS_SCOPE = "list_transactions"
SIGN_MESSAGE_SCOPE = "sign_message"
NOTIFICATIONS_SCOPE = "notifications" // covers all notification types
SUPERUSER_SCOPE = "superuser"
)

// limit encoded metadata length, otherwise relays may have trouble listing multiple transactions
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/components/Permissions.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BrickWall, PlusCircle } from "lucide-react";
import { AlertTriangleIcon, BrickWall, PlusCircle } from "lucide-react";
import React from "react";
import BudgetAmountSelect from "src/components/BudgetAmountSelect";
import BudgetRenewalSelect from "src/components/BudgetRenewalSelect";
Expand Down Expand Up @@ -227,6 +227,20 @@ const Permissions: React.FC<PermissionsProps> = ({
</>
)}
</>

{permissions.scopes.includes("superuser") && (
<>
<div className="flex items-center gap-2 mt-4">
<AlertTriangleIcon className="w-4 h-4" />
<p className="text-sm font-medium">
This app can create other app connections
</p>
</div>
<p className="text-muted-foreground text-sm">
Make sure to set budgets on connections created by this app.
</p>
</>
)}
</div>
);
};
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/components/Scopes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,17 @@ const Scopes: React.FC<ScopesProps> = ({
}, [capabilities.scopes]);

const [scopeGroup, setScopeGroup] = React.useState<ScopeGroup>(() => {
if (isolated && scopes.length === capabilities.scopes.length) {
if (
isolated &&
scopes.length === isolatedScopes.length &&
scopes.every((scope) => isolatedScopes.includes(scope))
) {
return "isolated";
}
if (scopes.length === capabilities.scopes.length) {
if (
scopes.length === fullAccessScopes.length &&
scopes.every((scope) => fullAccessScopes.includes(scope))
) {
return "full_access";
}
if (
Expand Down
Loading

0 comments on commit 3e1d16d

Please sign in to comment.