Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ LOG_LEVEL=debug
# Set to true/1/yes when behind a reverse proxy (e.g. Caddy) so client IP is read from X-Forwarded-For
# TRUST_PROXY=true

# Refresh-cookie Secure flag. Defaults to true when NODE_ENV=production. A Secure cookie is dropped by
# the browser over plain HTTP on a non-localhost host, which logs users out on every page reload (the
# reload restores the session only by exchanging this cookie). If you serve the app over plain HTTP on
# a trusted network (e.g. a LAN-only http://host:port), set this to false. Prefer HTTPS where possible.
# COOKIE_SECURE=false

# CORS (comma-separated origins; keep strict — do not use * with credentials)
CORS_ORIGINS=http://localhost:5173

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ This tool implements the full [NIP-46 Nostr Remote Signing](https://nips.nostr.c

- **URI Support**: Parse and generate both `bunker://` and `nostrconnect://` URIs
- **All RPC Methods**: `connect`, `sign_event`, `ping`, `get_public_key`, `nip04_encrypt/decrypt`, `nip44_encrypt/decrypt`, `switch_relays`
- **Fine-grained Permissions**: Per-connection method and event kind restrictions
- **Fine-grained Permissions**: Per-connection method and event-kind restrictions, enforced default-deny (a connection with no permissions can sign/decrypt nothing). New connections get a conservative default set (common signing kinds, no decryption). Following the NIP-46 model for a non-interactive signer, a connecting client may declare the scope it needs in its `connect` request and that becomes the connection's permission set, so **only connect clients you trust with the bound key**; operators can tighten any connection's permissions in the dashboard at any time.
- **NIP-44 Encryption**: All communication encrypted using NIP-44
- **Auth Challenges**: Support for out-of-band authentication
- **Relay Management**: Configurable relays per connection with automatic reconnection
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- Grandfather existing permission-less connections into the new default-deny permission model.
--
-- Before this change an empty permission set meant "allow everything" (fail-open). The NIP-46 RPC
-- handler is now default-deny, and newly created connections are seeded with a conservative default
-- set in application code. Existing ACTIVE/PENDING connections that currently have NO permission
-- rows were operating under the old allow-all behaviour; denying them outright would break live
-- signers. To avoid downtime we make their previous implicit access EXPLICIT: one method-level row
-- (kind = NULL = all kinds for sign_event) per gated capability method. Operators should review and
-- tighten these in the dashboard afterwards. New connections are unaffected (seeded at creation).
--
-- Idempotent: re-running is a no-op because the NOT EXISTS guard skips any connection that already
-- has permission rows.
INSERT INTO "connection_permissions" ("id", "connection_id", "method", "kind", "allowed")
SELECT gen_random_uuid()::text, c."id", m."method", NULL, true
FROM "bunker_connections" c
CROSS JOIN (VALUES
('sign_event'),
('nip04_encrypt'),
('nip04_decrypt'),
('nip44_encrypt'),
('nip44_decrypt')
) AS m("method")
WHERE c."status" IN ('ACTIVE', 'PENDING')
AND NOT EXISTS (
SELECT 1 FROM "connection_permissions" p WHERE p."connection_id" = c."id"
);
34 changes: 34 additions & 0 deletions apps/server/src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,40 @@ describe('AuthController', () => {
await expect(controller.refresh(req, mockReply)).rejects.toThrow('Missing refresh token');
expect(mockAuthService.refreshTokens).not.toHaveBeenCalled();
});

// The Secure flag governs whether a plain-HTTP (e.g. LAN) deployment can keep a session across
// reloads: a Secure cookie is dropped over http, so the reload-restore via /api/auth/refresh fails.
describe('Secure cookie flag', () => {
const req = {
headers: {},
cookies: { refresh_token: 'rt-from-cookie' },
} as unknown as FastifyRequest;
const original = process.env['COOKIE_SECURE'];
afterEach(() => {
if (original === undefined) delete process.env['COOKIE_SECURE'];
else process.env['COOKIE_SECURE'] = original;
});

it('marks the cookie Secure=false when COOKIE_SECURE=false (plain-HTTP deployment)', async () => {
process.env['COOKIE_SECURE'] = 'false';
await controller.refresh(req, mockReply);
expect(mockReply.setCookie).toHaveBeenCalledWith(
'refresh_token',
'rt2',
expect.objectContaining({ secure: false }),
);
});

it('marks the cookie Secure=true when COOKIE_SECURE=true', async () => {
process.env['COOKIE_SECURE'] = 'true';
await controller.refresh(req, mockReply);
expect(mockReply.setCookie).toHaveBeenCalledWith(
'refresh_token',
'rt2',
expect.objectContaining({ secure: true }),
);
});
});
});

describe('logout', () => {
Expand Down
17 changes: 16 additions & 1 deletion apps/server/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,29 @@ import type { FastifyRequest, FastifyReply } from 'fastify';
/** httpOnly cookie that carries the refresh token; never exposed to browser JavaScript. */
const REFRESH_COOKIE = 'refresh_token';

/**
* Whether the refresh cookie is marked `Secure` (sent only over HTTPS). Defaults to true in
* production so a real deployment never ships the refresh token over plaintext. A `Secure` cookie
* is silently dropped by the browser when the app is served over plain HTTP on a non-localhost host,
* which makes every page reload log the user out (the reload can only restore the session by
* exchanging this cookie). Operators serving over plain HTTP on a trusted network (e.g. a LAN-only
* http://host:port) must therefore set `COOKIE_SECURE=false`; ideally, serve the app over HTTPS.
*/
function isCookieSecure(): boolean {
const override = process.env['COOKIE_SECURE'];
if (override === 'true') return true;
if (override === 'false') return false;
return process.env['NODE_ENV'] === 'production';
}

/**
* Cookie attributes shared by set and clear. Browsers only delete a cookie when the clear call's
* attributes match those it was set with, so both paths must use the same values.
*/
function refreshCookieAttrs() {
return {
httpOnly: true,
secure: process.env['NODE_ENV'] === 'production',
secure: isCookieSecure(),
sameSite: 'lax' as const,
// Scope to the auth routes that consume it (refresh, logout), so it is not sent on every request.
path: '/api/auth',
Expand Down
Loading
Loading