Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/cool-ways-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

Replace admin password login with NIP-98 HTTP auth kind-27235 `Authorization: Nostr` headers verified against an `admin.pubkeys` allowlist
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ NIPs with a relay-specific implementation are listed here.
- [x] NIP-45: Event Counts
- [x] NIP-62: Request to Vanish
- [x] NIP-65: Relay List Metadata
- [x] NIP-98: HTTP Auth (admin console login)

## Requirements

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
44,
45,
50,
65
65,
98
],
"supportedNipExtensions": [],
"supportedMips": [
Expand Down
74 changes: 74 additions & 0 deletions resources/admin-dashboard.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin - {{name}}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<link rel="stylesheet" href="{{path_prefix}}/css/style.css">
</head>
<body lang="en" data-path-prefix="{{path_prefix}}">
<main class="container">
<div class="row">
<div class="col">
<h1 class="mt-4 mb-2 text-center text-nowrap">{{name}}</h1>
</div>
</div>
<div class="row">
<div class="col text-center mb-4">
<p class="text-muted">Relay Administration</p>
</div>
</div>

<div class="row justify-content-center">
<div class="col col-md-8 col-lg-5">

<div class="card mb-4">
<div class="card-body text-center">
<h5 class="card-title">Admin Console</h5>
<p class="card-text">You are signed in with NIP-98.</p>
<p id="session-info" class="text-muted small"></p>
<button id="logout" type="button" class="btn btn-outline-secondary w-100">Sign out</button>
</div>
</div>

<div class="d-flex justify-content-center gap-3 mt-2 mb-5">
<a href="{{path_prefix}}/" class="text-muted small">Back to relay</a>
</div>

</div>
</div>
</main>
<script nonce="{{nonce}}">
// Check for system preference on load
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.body.classList.add('dark-theme');
}

(() => {
const pathPrefix = document.body.dataset.pathPrefix || ''
const sessionInfo = document.getElementById('session-info')
const logoutButton = document.getElementById('logout')

fetch(`${pathPrefix}/admin/session`, { credentials: 'same-origin' })
.then((response) => (response.ok ? response.json() : undefined))
.then((session) => {
if (session && typeof session.expiresAt === 'number') {
sessionInfo.textContent = `Session expires ${new Date(session.expiresAt * 1000).toLocaleString()}.`
}
})
.catch(() => undefined)

logoutButton.addEventListener('click', async () => {
logoutButton.disabled = true
try {
await fetch(`${pathPrefix}/admin/logout`, { method: 'POST', credentials: 'same-origin' })
} catch (error) {
// ignore and send the operator back to the login page
}
location.assign(`${pathPrefix}/admin/login`)
})
})()
</script>
</body>
</html>
117 changes: 117 additions & 0 deletions resources/admin-login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin Login - {{name}}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<link rel="stylesheet" href="{{path_prefix}}/css/style.css">
</head>
<body lang="en" data-path-prefix="{{path_prefix}}">
<main class="container">
<div class="row">
<div class="col">
<h1 class="mt-4 mb-2 text-center text-nowrap">{{name}}</h1>
</div>
</div>
<div class="row">
<div class="col text-center mb-4">
<p class="text-muted">Relay Administration</p>
</div>
</div>

<div class="row justify-content-center">
<div class="col col-md-8 col-lg-5">

<div class="card mb-4">
<div class="card-body text-center">
<h5 class="card-title">Admin Login</h5>
<p class="card-text">
Sign in with your Nostr identity. Your signer extension or connected
wallet (NIP-07) will be asked to sign a NIP-98 authentication event.
</p>
<button id="sign-in" type="button" class="btn btn-warning w-100">Sign in with Nostr</button>
<p id="status" class="mt-3 mb-0" role="status"></p>
<p id="no-extension" class="text-muted small mt-3 mb-0 hidden">
No NIP-07 signer detected. Install a Nostr signer extension such as
Alby or nos2x, or open this page from a wallet with a built-in signer,
then reload this page.
</p>
</div>
</div>

<div class="d-flex justify-content-center gap-3 mt-2 mb-5">
<a href="{{path_prefix}}/" class="text-muted small">Back to relay</a>
</div>

</div>
</div>
</main>
<script nonce="{{nonce}}">
// Check for system preference on load
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.body.classList.add('dark-theme');
}

(() => {
const pathPrefix = document.body.dataset.pathPrefix || ''
const loginUrl = `${location.origin}${pathPrefix}/admin/login`
const signInButton = document.getElementById('sign-in')
const status = document.getElementById('status')

const setStatus = (message, isError) => {
status.textContent = message
status.className = isError ? 'mt-3 mb-0 text-danger' : 'mt-3 mb-0'
}

if (!window.nostr || typeof window.nostr.signEvent !== 'function') {
signInButton.disabled = true
document.getElementById('no-extension').classList.remove('hidden')
return
}

signInButton.addEventListener('click', async () => {
signInButton.disabled = true
setStatus('Waiting for your signer to approve…', false)

try {
const signedEvent = await window.nostr.signEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', loginUrl],
['method', 'POST'],
],
content: '',
})

const response = await fetch(loginUrl, {
method: 'POST',
credentials: 'same-origin',
headers: {
Authorization: `Nostr ${btoa(JSON.stringify(signedEvent))}`,
},
})

if (response.ok) {
setStatus('Signed in. Redirecting…', false)
location.assign(`${pathPrefix}/admin`)
return
}

setStatus(
response.status === 401
? 'Login rejected. Your pubkey isn\'t allowed.'
: 'Login failed. Please try again.',
true,
)
} catch (error) {
setStatus('Signing was cancelled or failed.', true)
}

signInButton.disabled = false
})
})()
</script>
</body>
</html>
1 change: 1 addition & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,4 @@ limits:
admin:
enabled: false
sessionTtlSeconds: 86400
pubkeys: []
10 changes: 9 additions & 1 deletion src/@types/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import { Request, Response } from 'express'

export interface IAdminAuthProvider {
handleLogin(request: Request, response: Response): Promise<void>
isRequestAuthenticated(request: Request): boolean
isRequestAuthenticated(request: Request): Promise<boolean>
getSessionExpiresAt(request: Request): number | undefined
}

export interface INip98ReplayGuard {
/**
* Returns true when eventId was not seen before (and is now registered),
* false when the event id is being replayed.
*/
registerEventId(eventId: string, ttlSeconds: number): Promise<boolean>
}
11 changes: 10 additions & 1 deletion src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,8 +281,17 @@ export interface Nip05Settings {

export interface AdminSettings {
enabled: boolean
passwordHash?: string
/**
* NIP-98 admin allowlist: 64-char hex pubkeys or npub1... entries.
* Empty or unset means no one can authenticate.
*/
pubkeys?: string[]
sessionTtlSeconds?: number
/**
* Maximum allowed |now - created_at| for NIP-98 auth events, in seconds.
* Defaults to 60 when unset.
*/
authTimestampToleranceSeconds?: number
}
export interface WoTSettings {
enabled: boolean
Expand Down
Loading
Loading