diff --git a/spec.yaml b/spec.yaml index 1e42a1d..e866aa9 100644 --- a/spec.yaml +++ b/spec.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: Dcntrlized Maestro API version: 1.0.0 - description: The API of the Maestro orchestration router. Maestro manages the lifecycle of Dcntrlized service containers and routes inter-service action calls to them. + description: The API of the Maestro orchestration router. Maestro manages the lifecycle of Dcntrlized service containers and routes inter-service action calls to them. Every /api/* route except /api/auth/* requires an instance-owner session (cookies issued by the auth endpoints); in setup mode gated routes answer 428 ERR_SETUP_REQUIRED, otherwise 401 ERR_UNAUTHENTICATED. Mutating requests from browser origins are additionally checked against the request host (CSRF). components: schemas: @@ -29,6 +29,65 @@ components: message: type: string + ApiError: + type: object + description: The uniform API error shape (onboarding theme M1). + properties: + error: + type: object + properties: + code: + type: string + example: "ERR_UNAUTHENTICATED" + + AuthStatus: + type: object + description: The instance-owner gate state; the SPA bootstraps from it. + properties: + setup_required: + type: boolean + description: True until the first owner account exists. + authenticated: + type: boolean + username: + type: string + + Credentials: + type: object + required: [username, password] + properties: + username: + type: string + description: "3-64 chars: lowercase letters, digits and . _ - ; must start alphanumeric." + password: + type: string + minLength: 10 + + SessionResponse: + type: object + description: >- + Login/setup/rotation result. The access and refresh tokens ride as + HttpOnly SameSite=Lax cookies (maestro_access / maestro_refresh). + properties: + username: + type: string + role: + type: string + example: "admin" + access_ttl_s: + type: integer + example: 900 + + PasswordChange: + type: object + required: [current_password, new_password] + properties: + current_password: + type: string + new_password: + type: string + minLength: 10 + PendingAuthorization: type: object description: A first-contact authorization request awaiting the instance owner (M2 §3). @@ -447,14 +506,135 @@ paths: '502': description: Failed to connect to upstream registry + /api/auth/status: + get: + summary: Instance-owner gate state (public) + tags: ["auth"] + description: >- + The SPA bootstraps from this endpoint. All other /api/* routes are + gated by the instance-owner session (onboarding theme M1): in setup + mode they answer 428 ERR_SETUP_REQUIRED, otherwise 401 + ERR_UNAUTHENTICATED without a valid session. + responses: + '200': + description: Gate state + content: + application/json: + schema: + $ref: '#/components/schemas/AuthStatus' + '500': + description: Internal server error + + /api/auth/setup: + post: + summary: Create the first owner account (admin) + tags: ["auth"] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Credentials' + responses: + '200': + description: Owner created; session cookies issued + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + '400': + description: Invalid credentials (ERR_INVALID_REQUEST / ERR_INVALID_CREDENTIALS) + '409': + description: Setup already completed (ERR_SETUP_COMPLETED) + '429': + description: Rate limited (ERR_RATE_LIMITED) + + /api/auth/login: + post: + summary: Log in as the instance owner + tags: ["auth"] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Credentials' + responses: + '200': + description: Session cookies issued + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + '400': + description: Invalid JSON payload + '401': + description: Unknown user or wrong password (ERR_INVALID_CREDENTIALS) + '429': + description: Rate limited (ERR_RATE_LIMITED) with Retry-After + + /api/auth/refresh: + post: + summary: Rotate the access/refresh pair + tags: ["auth"] + description: >- + Uses the maestro_refresh cookie. Reuse of an already-rotated jti is a + theft signal and revokes every session of the user + (ERR_SESSION_REVOKED). + responses: + '200': + description: Fresh pair issued + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + '401': + description: Missing/invalid/replayed refresh cookie + + /api/auth/logout: + post: + summary: Revoke the current refresh token and clear cookies + tags: ["auth"] + responses: + '200': + description: Logged out (idempotent, bodyless-safe) + + /api/auth/change-password: + post: + summary: Change the owner's password (session required) + tags: ["auth"] + description: Revokes every existing session of the user on success. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PasswordChange' + responses: + '200': + description: Password changed; all sessions revoked + '400': + description: Invalid payload or wrong current password + '401': + description: Not authenticated + '429': + description: Rate limited (ERR_RATE_LIMITED) + /api/ws: get: summary: WebSocket endpoint for live container events tags: ["containers"] - description: Establishes a WebSocket connection that streams container lifecycle events as JSON. + description: >- + Establishes a WebSocket connection that streams container lifecycle + events as JSON. The upgrade is gated by the instance-owner session; + anonymous handshakes close with 401 (428 in setup mode). responses: '101': description: Switching Protocols - WebSocket upgrade + '401': + description: Not authenticated + '428': + description: Setup required /api/peers: get: diff --git a/src/frontend/src/app.tsx b/src/frontend/src/app.tsx index f298f9e..f0a75be 100644 --- a/src/frontend/src/app.tsx +++ b/src/frontend/src/app.tsx @@ -19,9 +19,11 @@ import { useMemo, useState } from 'preact/hooks' import { useContainerStore } from '@lib/hooks/useContainerStore' import { useMasterRegistry } from '@lib/hooks/useMasterRegistry' import { useTrustStore } from '@lib/hooks/useTrustStore' +import { useAuth } from '@lib/hooks/useAuth' import { mergeServices } from '@lib/domain/merge' import { filterServices, serviceCounts } from '@lib/domain/filter' import type { DatabaseStatus, ServiceFilter } from '@lib/types' +import { AuthScreen } from '@components/auth/auth-screen' import { Header } from '@components/layout/header' import { Toolbar } from '@components/layout/toolbar' import { ServiceGrid } from '@components/service/service-grid' @@ -32,12 +34,14 @@ import { EmptyState, LoadingState, RegistryErrorState } from '@components/layout const DATABASE_NAME = 'dcntrlized-db' export function App() { - const { containers, status } = useContainerStore() - const registry = useMasterRegistry() + const auth = useAuth() + const authed = auth.gate === 'ready' + const { containers, status } = useContainerStore(authed) + const registry = useMasterRegistry(authed) const [query, setQuery] = useState('') const [filter, setFilter] = useState('all') const [settingsOpen, setSettingsOpen] = useState(false) - const trust = useTrustStore(settingsOpen) + const trust = useTrustStore(settingsOpen, authed) const services = useMemo(() => { const items = registry.state.kind === 'ok' ? registry.state.items : [] @@ -88,6 +92,22 @@ export function App() { const pendingTrust = trust.load.kind === 'ok' ? trust.load.state.pending.length : 0 + if (auth.gate === 'loading') { + return ( +
+ +
+ ) + } + + if (auth.gate === 'setup' || auth.gate === 'login') { + return ( +
+ +
+ ) + } + return (
{ + setSettingsOpen(false) + void auth.signOut() + }} /> diff --git a/src/frontend/src/components/auth/auth-screen.tsx b/src/frontend/src/components/auth/auth-screen.tsx new file mode 100644 index 0000000..f215bd1 --- /dev/null +++ b/src/frontend/src/components/auth/auth-screen.tsx @@ -0,0 +1,149 @@ +/** + * Copyright (C) 2026 Dcntrlized + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { useState } from 'preact/hooks' +import { BrandMark } from '@components/brand-mark' +import { Button } from '@components/ui/button' +import { Input } from '@components/ui/input' +import { loginOwner, setupOwner } from '@lib/api/auth' + +const USERNAME_PATTERN = /^[a-z0-9][a-z0-9._-]{2,63}$/ + +interface AuthScreenProps { + mode: 'setup' | 'login' + onAuthenticated: (username: string) => void +} + +/** The instance-owner gate screen: first-run setup wizard and login. */ +export function AuthScreen({ mode, onAuthenticated }: AuthScreenProps) { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + const submit = async (event: Event) => { + event.preventDefault() + setError(null) + + if (!USERNAME_PATTERN.test(username)) { + setError('Username must be 3-64 characters: lowercase letters, digits and . _ -') + return + } + if (password.length < 10) { + setError('Password must be at least 10 characters.') + return + } + if (mode === 'setup' && password !== confirm) { + setError('Passwords do not match.') + return + } + + setBusy(true) + try { + const session = + mode === 'setup' + ? await setupOwner(username, password) + : await loginOwner(username, password) + onAuthenticated(session.username) + } catch (err) { + const status = err instanceof Error && 'status' in err ? Number(err.status) : 0 + if (status === 409) { + setError('This instance already has an owner. Log in instead.') + } else if (status === 429) { + setError('Too many attempts. Wait a moment and try again.') + } else { + setError(mode === 'setup' ? 'Could not create the account.' : 'Wrong username or password.') + } + } finally { + setBusy(false) + } + } + + return ( +
+
+
+ +

+ {mode === 'setup' ? 'Set up your Dcntrlized node' : 'Sign in to Maestro'} +

+

+ {mode === 'setup' + ? 'Create the owner account for this instance. It stays on your hardware.' + : 'Use the owner account you created for this instance.'} +

+
+ +
+ + + + + {mode === 'setup' && ( + + )} + + {error && ( +

+ {error} +

+ )} + + +
+
+
+ ) +} diff --git a/src/frontend/src/components/settings/account-section.tsx b/src/frontend/src/components/settings/account-section.tsx new file mode 100644 index 0000000..e967102 --- /dev/null +++ b/src/frontend/src/components/settings/account-section.tsx @@ -0,0 +1,137 @@ +/** + * Copyright (C) 2026 Dcntrlized + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { useState } from 'preact/hooks' +import { KeyRound, LogOut } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@components/ui/button' +import { Input } from '@components/ui/input' +import { changePassword } from '@lib/api/auth' + +interface AccountSectionProps { + username: string + onSignOut: () => void +} + +function describeError(error: unknown): string { + const status = error instanceof Error && 'status' in error ? Number(error.status) : 0 + if (status === 429) return 'Too many attempts. Wait a moment and try again.' + if (status === 400 || status === 401) return 'Current password is wrong.' + if (status === 403) return 'Your session was rejected — sign in again.' + return 'Could not change the password.' +} + +/** Owner account controls inside the settings dialog: password + logout. */ +export function AccountSection({ username, onSignOut }: AccountSectionProps) { + const [current, setCurrent] = useState('') + const [next, setNext] = useState('') + const [confirm, setConfirm] = useState('') + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + const submit = async (event: Event) => { + event.preventDefault() + setError(null) + if (next.length < 10) { + setError('New password must be at least 10 characters.') + return + } + if (next !== confirm) { + setError('Passwords do not match.') + return + } + + setBusy(true) + try { + await changePassword(current, next) + toast.success('Password changed', { + description: 'All sessions were signed out — sign in with the new password.', + }) + onSignOut() + } catch (err) { + setError(describeError(err)) + } finally { + setBusy(false) + } + } + + return ( +
+

+

+

+ Signed in as{' '} + {username}. Changing the password signs + out every session, including this one. +

+ +
+
+ setCurrent((e.target as HTMLInputElement).value)} + autoComplete="current-password" + required + /> +
+ setNext((e.target as HTMLInputElement).value)} + autoComplete="new-password" + minLength={10} + required + /> + setConfirm((e.target as HTMLInputElement).value)} + autoComplete="new-password" + minLength={10} + required + /> +
+ + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+
+
+ ) +} diff --git a/src/frontend/src/components/settings/settings-dialog.tsx b/src/frontend/src/components/settings/settings-dialog.tsx index 78a6bde..9706885 100644 --- a/src/frontend/src/components/settings/settings-dialog.tsx +++ b/src/frontend/src/components/settings/settings-dialog.tsx @@ -18,6 +18,7 @@ import { Settings, ShieldCheck, X } from 'lucide-react' import { Dialog, Modal, ModalOverlay } from 'react-aria-components' import { Button } from '@components/ui/button' +import { AccountSection } from '@components/settings/account-section' import { TrustedServicesSection } from '@components/service/trusted-services' import type { TrustLoadState } from '@lib/hooks/useTrustStore' @@ -26,9 +27,18 @@ interface SettingsDialogProps { onOpenChange: (open: boolean) => void trust: TrustLoadState onRefreshTrust: () => void + username: string + onSignOut: () => void } -export function SettingsDialog({ open, onOpenChange, trust, onRefreshTrust }: SettingsDialogProps) { +export function SettingsDialog({ + open, + onOpenChange, + trust, + onRefreshTrust, + username, + onSignOut, +}: SettingsDialogProps) { return (
+ +

. + */ + +import { get, post } from './client' +import type { AuthStatus, SessionResponse } from '../types' + +/** Fetches the instance-owner gate state; the SPA bootstraps from it. */ +export function fetchAuthStatus(): Promise { + return get('/api/auth/status') +} + +/** Creates the first owner account and starts its session. */ +export function setupOwner(username: string, password: string): Promise { + return post('/api/auth/setup', { username, password }) +} + +/** Logs in as the instance owner. */ +export function loginOwner(username: string, password: string): Promise { + return post('/api/auth/login', { username, password }) +} + +/** Rotates the access/refresh pair using the refresh cookie. */ +export function rotateSession(): Promise { + return post('/api/auth/refresh') +} + +/** Revokes the current refresh token and clears both cookies. */ +export function logoutOwner(): Promise { + return post('/api/auth/logout') +} + +/** Changes the owner's password; every existing session is revoked. */ +export function changePassword(currentPassword: string, newPassword: string): Promise { + return post('/api/auth/change-password', { + current_password: currentPassword, + new_password: newPassword, + }) +} diff --git a/src/frontend/src/lib/api/client.ts b/src/frontend/src/lib/api/client.ts index 537226b..4a307f9 100644 --- a/src/frontend/src/lib/api/client.ts +++ b/src/frontend/src/lib/api/client.ts @@ -35,8 +35,37 @@ async function readJson(res: Response): Promise { } } -async function request(path: string, init?: RequestInit): Promise { +/** In-flight session rotation shared by concurrent 401s (no refresh storms). */ +let refreshing: Promise | null = null + +function refreshSession(): Promise { + refreshing ??= (async () => { + try { + const res = await fetch('/api/auth/refresh', { method: 'POST' }) + return res.ok + } catch { + return false + } finally { + refreshing = null + } + })() + return refreshing +} + +async function request(path: string, init?: RequestInit, retried = false): Promise { const res = await fetch(path, init) + if (!path.startsWith('/api/auth/')) { + // Silent session recovery: rotate once and replay the original call. + if (res.status === 401 && !retried) { + if (await refreshSession()) { + return request(path, init, true) + } + } + // Surface gate loss to the app shell so it drops back to login/setup. + if (res.status === 401 || res.status === 428) { + window.dispatchEvent(new CustomEvent('maestro:gate-lost', { detail: res.status })) + } + } if (!res.ok) { throw new ApiError(`Request failed (${res.status})`, res.status) } diff --git a/src/frontend/src/lib/hooks/useAuth.ts b/src/frontend/src/lib/hooks/useAuth.ts new file mode 100644 index 0000000..104108f --- /dev/null +++ b/src/frontend/src/lib/hooks/useAuth.ts @@ -0,0 +1,82 @@ +/** + * Copyright (C) 2026 Dcntrlized + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { useCallback, useEffect, useState } from 'preact/hooks' +import { fetchAuthStatus, logoutOwner, rotateSession } from '@lib/api/auth' + +/** Which screen the app shell should render. */ +export type AuthGate = 'loading' | 'setup' | 'login' | 'ready' + +/** + * Tracks the instance-owner gate: bootstraps from /api/auth/status, silently + * recovers an expired access token through refresh rotation, and listens for + * gate-loss events emitted by the API client (401/428 on gated routes). + */ +export function useAuth() { + const [gate, setGate] = useState('loading') + const [username, setUsername] = useState('') + + const sync = useCallback(async () => { + try { + const status = await fetchAuthStatus() + if (status.setup_required) { + setGate('setup') + return + } + if (status.authenticated && status.username) { + setUsername(status.username) + setGate('ready') + return + } + // Access token expired but a valid refresh cookie may survive — try to + // resume the session before showing the login form. + try { + const session = await rotateSession() + setUsername(session.username) + setGate('ready') + } catch { + setGate('login') + } + } catch { + setGate('login') + } + }, []) + + useEffect(() => { + void sync() + + const onGateLost = () => void sync() + window.addEventListener('maestro:gate-lost', onGateLost) + return () => window.removeEventListener('maestro:gate-lost', onGateLost) + }, [sync]) + + const markReady = useCallback((user: string) => { + setUsername(user) + setGate('ready') + }, []) + + const signOut = useCallback(async () => { + try { + await logoutOwner() + } finally { + setGate('login') + setUsername('') + } + }, []) + + return { gate, username, markReady, signOut } +} diff --git a/src/frontend/src/lib/hooks/useContainerStore.ts b/src/frontend/src/lib/hooks/useContainerStore.ts index a5a3743..e0faa02 100644 --- a/src/frontend/src/lib/hooks/useContainerStore.ts +++ b/src/frontend/src/lib/hooks/useContainerStore.ts @@ -17,20 +17,25 @@ import { useEffect, useState } from 'preact/hooks' import { store, type ContainerStoreState } from '../ws/registry' -import { connectWebSocket } from '../ws/client' +import { connectWebSocket, disconnectWebSocket } from '../ws/client' /** * Subscribes the component to the live container store and owns the single - * WebSocket connection for the app. + * WebSocket connection for the app. While `enabled` is false (owner not yet + * authenticated) no connection is attempted and any existing one is torn down. */ -export function useContainerStore(): ContainerStoreState { +export function useContainerStore(enabled = true): ContainerStoreState { const [state, setState] = useState(() => store.getState()) useEffect(() => { + if (!enabled) { + disconnectWebSocket() + return + } connectWebSocket() const unsubscribe = store.subscribe(() => setState(store.getState())) return unsubscribe - }, []) + }, [enabled]) return state } diff --git a/src/frontend/src/lib/hooks/useMasterRegistry.ts b/src/frontend/src/lib/hooks/useMasterRegistry.ts index 64ddae6..a19bd62 100644 --- a/src/frontend/src/lib/hooks/useMasterRegistry.ts +++ b/src/frontend/src/lib/hooks/useMasterRegistry.ts @@ -24,7 +24,7 @@ export type RegistryLoadState = | { kind: 'ok'; items: RegistryItem[] } | { kind: 'error'; message: string } -export function useMasterRegistry() { +export function useMasterRegistry(enabled = true) { const [state, setState] = useState({ kind: 'loading' }) const load = useCallback(async () => { @@ -38,8 +38,9 @@ export function useMasterRegistry() { }, []) useEffect(() => { + if (!enabled) return void load() - }, [load]) + }, [enabled, load]) return { state, retry: load } } diff --git a/src/frontend/src/lib/hooks/useTrustStore.ts b/src/frontend/src/lib/hooks/useTrustStore.ts index 168bee1..f5958a3 100644 --- a/src/frontend/src/lib/hooks/useTrustStore.ts +++ b/src/frontend/src/lib/hooks/useTrustStore.ts @@ -36,7 +36,7 @@ function errorMessage(error: unknown): string { * mutation never flashes a loading spinner over live rows; mutations also * trigger an immediate refresh through the same hook. */ -export function useTrustStore(open: boolean) { +export function useTrustStore(open: boolean, enabled = true) { const [load, setLoad] = useState({ kind: 'loading' }) const refresh = useCallback(async () => { @@ -49,15 +49,17 @@ export function useTrustStore(open: boolean) { }, []) useEffect(() => { + if (!enabled) return void refresh() - }, [refresh]) + }, [enabled, refresh]) useEffect(() => { + if (!enabled) return const interval = window.setInterval(() => { void refresh() }, open ? 10_000 : 120_000) return () => window.clearInterval(interval) - }, [open, refresh]) + }, [open, enabled, refresh]) return { load, refresh } } diff --git a/src/frontend/src/lib/types.ts b/src/frontend/src/lib/types.ts index 15be551..43ab31f 100644 --- a/src/frontend/src/lib/types.ts +++ b/src/frontend/src/lib/types.ts @@ -134,3 +134,17 @@ export interface PeerAttestation { kid: string issued_at: string } + +/** The instance-owner gate state served by GET /api/auth/status (onboarding M1). */ +export interface AuthStatus { + setup_required: boolean + authenticated: boolean + username: string +} + +/** Login/setup/rotation result; tokens ride as HttpOnly cookies. */ +export interface SessionResponse { + username: string + role: string + access_ttl_s: number +} diff --git a/src/frontend/src/lib/ws/client.ts b/src/frontend/src/lib/ws/client.ts index 802b7b9..3098e8b 100644 --- a/src/frontend/src/lib/ws/client.ts +++ b/src/frontend/src/lib/ws/client.ts @@ -18,12 +18,17 @@ import { store } from './registry' import type { Container } from '../types' -const URL = 'ws://127.0.0.1:8080/api/ws' const RECONNECT_DELAY = 3000 let socket: WebSocket | null = null let reconnectTimer: number | undefined +/** Derives the same-origin WebSocket URL (wss:// behind a TLS proxy). */ +function wsURL(): string { + const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + return `${scheme}//${window.location.host}/api/ws` +} + export function connectWebSocket(): void { if ( socket && @@ -32,7 +37,7 @@ export function connectWebSocket(): void { return } - const ws = new WebSocket(URL) + const ws = new WebSocket(wsURL()) socket = ws store.setStatus('connecting') @@ -57,6 +62,17 @@ export function connectWebSocket(): void { } } +/** Tears down the socket and stops the reconnect loop (auth gate lost). */ +export function disconnectWebSocket(): void { + window.clearTimeout(reconnectTimer) + if (socket) { + socket.onclose = null + socket.close() + socket = null + } + store.setStatus('closed') +} + function scheduleReconnect(): void { window.clearTimeout(reconnectTimer) reconnectTimer = window.setTimeout(connectWebSocket, RECONNECT_DELAY) diff --git a/src/handlers/auth/auth.go b/src/handlers/auth/auth.go new file mode 100644 index 0000000..52bdf98 --- /dev/null +++ b/src/handlers/auth/auth.go @@ -0,0 +1,386 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package auth implements Maestro's instance-owner gate (onboarding theme M1): +// /api/auth/* endpoints plus the RequireSession middleware that guards every +// other /api/* route and the WebSocket handshake. Deliberately independent of +// the fleet — the owner store lives on the local data root. +package auth + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/owner" +) + +const ( + accessCookie = "maestro_access" + refreshCookie = "maestro_refresh" +) + +// errorBody is the uniform API error shape. +type errorBody struct { + Error struct { + Code string `json:"code"` + } `json:"error"` +} + +func writeError(w http.ResponseWriter, status int, code string) { + var body errorBody + body.Error.Code = code + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// rateLimited emits the 429 with a Retry-After hint from the throttler. +func (s *Service) rateLimited(w http.ResponseWriter, identifier string) { + if wait := s.throttle.RetryAfter(identifier); wait > 0 { + w.Header().Set("Retry-After", strconv.Itoa(int((wait+time.Second-1)/time.Second))) + } + writeError(w, http.StatusTooManyRequests, "ERR_RATE_LIMITED") +} + +// Service wires the owner gate together. +type Service struct { + owners owner.Store + tokens *owner.TokenService + sessions *owner.FileSessionStore + throttle *owner.Throttler + + secureCookies bool +} + +// New builds the auth service from its stores. +func New(owners owner.Store, tokens *owner.TokenService, sessions *owner.FileSessionStore) *Service { + return &Service{ + owners: owners, + tokens: tokens, + sessions: sessions, + throttle: owner.NewThrottler(), + secureCookies: os.Getenv("COOKIE_SECURE") == "true", + } +} + +type credentials struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type passwordChange struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} + +// SetupRequired reports whether the instance still needs its first owner. +func (s *Service) SetupRequired(ctx context.Context) bool { + count, err := s.owners.Count(ctx) + return err != nil || count == 0 +} + +// Status serves GET /api/auth/status — public; the SPA bootstraps from it. +func (s *Service) Status(w http.ResponseWriter, r *http.Request) { + setupRequired := s.SetupRequired(r.Context()) + username := "" + if !setupRequired { + if c, err := r.Cookie(accessCookie); err == nil { + if sub, _, err := s.tokens.VerifyAccess(c.Value); err == nil { + username = sub + } + } + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "setup_required": setupRequired, + "authenticated": username != "", + "username": username, + }) +} + +// Setup serves POST /api/auth/setup — creates the first owner (admin). In +// setup mode only this and status respond anywhere in /api. +func (s *Service) Setup(w http.ResponseWriter, r *http.Request) { + var creds credentials + if err := json.NewDecoder(r.Body).Decode(&creds); err != nil { + writeError(w, http.StatusBadRequest, "ERR_INVALID_REQUEST") + return + } + if count, _ := s.owners.Count(r.Context()); count > 0 { + writeError(w, http.StatusConflict, "ERR_SETUP_COMPLETED") + return + } + if !s.throttle.Allow(creds.Username) { + s.rateLimited(w, creds.Username) + return + } + o, err := s.owners.Create(r.Context(), creds.Username, creds.Password, true) + if err != nil { + s.throttle.Fail(creds.Username) + writeError(w, http.StatusBadRequest, "ERR_INVALID_CREDENTIALS") + return + } + logger.Info("Owner account created: %s", o.Username) + s.issueSession(w, r, o.Username, o.Role()) +} + +// Login serves POST /api/auth/login. +func (s *Service) Login(w http.ResponseWriter, r *http.Request) { + var creds credentials + if err := json.NewDecoder(r.Body).Decode(&creds); err != nil { + writeError(w, http.StatusBadRequest, "ERR_INVALID_REQUEST") + return + } + if !s.throttle.Allow(creds.Username) { + s.rateLimited(w, creds.Username) + return + } + o, err := s.owners.Verify(r.Context(), creds.Username, creds.Password) + if err != nil || o == nil { + s.throttle.Fail(creds.Username) + writeError(w, http.StatusUnauthorized, "ERR_INVALID_CREDENTIALS") + return + } + s.throttle.Reset(creds.Username) + s.issueSession(w, r, o.Username, o.Role()) +} + +// issueSession mints the pair, persists the refresh row and sets both cookies. +func (s *Service) issueSession(w http.ResponseWriter, r *http.Request, username, role string) { + pair, err := s.tokens.MintPair(username, role) + if err != nil { + logger.Error("Failed to mint session pair: %v", err) + writeError(w, http.StatusInternalServerError, "ERR_INTERNAL") + return + } + if err := s.sessions.Store(username, pair.RefreshJTI, time.Now().Add(pair.RefreshTTL)); err != nil { + logger.Error("Failed to persist session: %v", err) + writeError(w, http.StatusInternalServerError, "ERR_INTERNAL") + return + } + http.SetCookie(w, s.cookie(accessCookie, pair.Access, pair.AccessTTL)) + http.SetCookie(w, s.cookie(refreshCookie, pair.Refresh, pair.RefreshTTL)) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "username": pair.Username, + "role": pair.Role, + "access_ttl_s": int(pair.AccessTTL.Seconds()), + }) +} + +func (s *Service) cookie(name, value string, ttl time.Duration) *http.Cookie { + return &http.Cookie{ + Name: name, + Value: value, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: s.secureCookies, + MaxAge: int(ttl.Seconds()), + } +} + +// Refresh serves POST /api/auth/refresh — rotates the pair. Reuse of an +// already-rotated (or unknown/revoked) jti revokes every session of the user. +func (s *Service) Refresh(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(refreshCookie) + if err != nil || c.Value == "" { + writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED") + return + } + sub, jti, err := s.tokens.VerifyRefresh(c.Value) + if err != nil { + clearCookies(w) + writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED") + return + } + if !s.sessions.Valid(jti) { + // Reuse of a rotated/revoked jti is a theft signal: burn everything. + _ = s.sessions.RevokeAllForUser(sub) + clearCookies(w) + writeError(w, http.StatusUnauthorized, "ERR_SESSION_REVOKED") + return + } + + // The owner's role may have changed since login; re-read it. + role := "member" + if o, err := s.owners.Get(r.Context(), sub); err == nil && o != nil { + role = o.Role() + } + + pair, err := s.tokens.MintPair(sub, role) + if err != nil { + writeError(w, http.StatusInternalServerError, "ERR_INTERNAL") + return + } + if err := s.sessions.Store(sub, pair.RefreshJTI, time.Now().Add(pair.RefreshTTL)); err != nil { + writeError(w, http.StatusInternalServerError, "ERR_INTERNAL") + return + } + if err := s.sessions.Revoke(jti); err != nil { + writeError(w, http.StatusInternalServerError, "ERR_INTERNAL") + return + } + http.SetCookie(w, s.cookie(accessCookie, pair.Access, pair.AccessTTL)) + http.SetCookie(w, s.cookie(refreshCookie, pair.Refresh, pair.RefreshTTL)) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "username": sub, + "role": role, + "access_ttl_s": int(pair.AccessTTL.Seconds()), + }) +} + +// Logout serves POST /api/auth/logout — revokes the refresh jti, clears both. +func (s *Service) Logout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(refreshCookie); err == nil && c.Value != "" { + if _, jti, err := s.tokens.VerifyRefresh(c.Value); err == nil { + if err := s.sessions.Revoke(jti); err != nil { + logger.Warn("Logout revoke failed: %v", err) + } + } + } + clearCookies(w) + writeJSON(w, http.StatusOK, map[string]string{"status": "logged-out"}) +} + +func clearCookies(w http.ResponseWriter) { + for _, name := range []string{accessCookie, refreshCookie} { + http.SetCookie(w, &http.Cookie{ + Name: name, Value: "", Path: "/", HttpOnly: true, + SameSite: http.SameSiteLaxMode, MaxAge: -1, + }) + } +} + +// ChangePassword serves POST /api/auth/change-password — verifies the current +// password, rehashes, then rotates all of the user's sessions. +func (s *Service) ChangePassword(w http.ResponseWriter, r *http.Request) { + sub := r.Header.Get("X-Maestro-User") + if sub == "" { + writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED") + return + } + var change passwordChange + if err := json.NewDecoder(r.Body).Decode(&change); err != nil { + writeError(w, http.StatusBadRequest, "ERR_INVALID_REQUEST") + return + } + store, ok := s.owners.(*owner.FileOwnerStore) + if !ok { + writeError(w, http.StatusInternalServerError, "ERR_INTERNAL") + return + } + if !s.throttle.Allow(sub) { + s.rateLimited(w, sub) + return + } + if _, err := store.ChangePassword(r.Context(), sub, change.CurrentPassword, change.NewPassword); err != nil { + s.throttle.Fail(sub) + writeError(w, http.StatusBadRequest, "ERR_INVALID_CREDENTIALS") + return + } + // Every existing session dies: the password changed under them. + if err := s.sessions.RevokeAllForUser(sub); err != nil { + logger.Warn("Session revoke after password change failed: %v", err) + } + clearCookies(w) + writeJSON(w, http.StatusOK, map[string]string{"status": "password-changed"}) +} + +// RequireSession gates every /api/* route except the /api/auth/* endpoints. +// Setup mode short-circuits everything else with 428; unauthenticated calls +// get the uniform 401; mutating requests pass the CSRF origin check. +func (s *Service) RequireSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.SetupRequired(r.Context()) { + writeError(w, http.StatusPreconditionRequired, "ERR_SETUP_REQUIRED") + return + } + c, err := r.Cookie(accessCookie) + if err != nil || c.Value == "" { + writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED") + return + } + sub, _, err := s.tokens.VerifyAccess(c.Value) + if err != nil { + writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED") + return + } + r.Header.Set("X-Maestro-User", sub) + if mutating(r.Method) && !sameOriginOK(r) { + writeError(w, http.StatusForbidden, "ERR_CSRF") + return + } + next.ServeHTTP(w, r) + }) +} + +// GateWebSocket validates the access cookie before an upgrade is attempted; +// rejected handshakes close with 401 instead of switching protocols. +func (s *Service) GateWebSocket(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.SetupRequired(r.Context()) { + writeError(w, http.StatusPreconditionRequired, "ERR_SETUP_REQUIRED") + return + } + c, err := r.Cookie(accessCookie) + if err != nil || c.Value == "" { + writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED") + return + } + if _, _, err := s.tokens.VerifyAccess(c.Value); err != nil { + writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED") + return + } + next.ServeHTTP(w, r) + }) +} + +func mutating(method string) bool { + switch method { + case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + return true + } + return false +} + +// sameOriginOK enforces D5's second line: on mutating requests, when Origin or +// Referer is present, its host must equal the request host. Non-browser +// clients omit both and pass (cookies are SameSite=Lax regardless). +func sameOriginOK(r *http.Request) bool { + header := r.Header.Get("Origin") + if header == "" { + header = r.Header.Get("Referer") + } + if header == "" { + return true + } + u, err := url.Parse(header) + if err != nil || u.Host == "" { + return false + } + return strings.EqualFold(u.Host, r.Host) +} diff --git a/src/handlers/auth/auth_test.go b/src/handlers/auth/auth_test.go new file mode 100644 index 0000000..2d1bcb6 --- /dev/null +++ b/src/handlers/auth/auth_test.go @@ -0,0 +1,396 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package auth + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.dcntrlized.org/Dcntrlized/Maestro/lib/owner" +) + +// statusBody mirrors GET /api/auth/status's snake_case response. +type statusBody struct { + SetupRequired bool `json:"setup_required"` + Authenticated bool `json:"authenticated"` + Username string `json:"username"` +} + +func newTestServer(t *testing.T) (*Service, http.Handler) { + t.Helper() + dir := t.TempDir() + owners, err := owner.NewFileOwnerStore(dir) + if err != nil { + t.Fatal(err) + } + tokens, err := owner.NewTokenService(dir) + if err != nil { + t.Fatal(err) + } + sessions, err := owner.NewFileSessionStore(dir) + if err != nil { + t.Fatal(err) + } + s := New(owners, tokens, sessions) + + mux := http.NewServeMux() + mux.HandleFunc("/api/auth/status", s.Status) + mux.HandleFunc("/api/auth/setup", s.Setup) + mux.HandleFunc("/api/auth/login", s.Login) + mux.HandleFunc("/api/auth/refresh", s.Refresh) + mux.HandleFunc("/api/auth/logout", s.Logout) + mux.Handle("/api/auth/change-password", s.RequireSession(http.HandlerFunc(s.ChangePassword))) + mux.Handle("/api/gated", s.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }))) + return s, mux +} + +func do(t *testing.T, mux http.Handler, method, target, body string, hdr map[string]string) *httptest.ResponseRecorder { + t.Helper() + var req *http.Request + if body == "" { + req = httptest.NewRequest(method, target, nil) + } else { + req = httptest.NewRequest(method, target, strings.NewReader(body)) + } + for k, v := range hdr { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec +} + +func cookieValue(t *testing.T, rec *httptest.ResponseRecorder, name string) string { + t.Helper() + for _, c := range rec.Result().Cookies() { + if c.Name == name && c.Value != "" { + return c.Value + } + } + t.Fatalf("cookie %q not set", name) + return "" +} + +func TestStatusSetupModeAndAfterSetup(t *testing.T) { + s, h := newTestServer(t) + + rec := do(t, h, http.MethodGet, "/api/auth/status", "", nil) + if rec.Code != 200 { + t.Fatalf("status pre-setup: %d", rec.Code) + } + var st statusBody + if err := json.Unmarshal(rec.Body.Bytes(), &st); err != nil { + t.Fatal(err) + } + if !st.SetupRequired || st.Authenticated { + t.Fatalf("pre-setup status wrong: %+v", st) + } + + // Gated route in setup mode → 428 ERR_SETUP_REQUIRED. + gate := s.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodGet, "/api/containers", nil) + rec2 := httptest.NewRecorder() + gate.ServeHTTP(rec2, req) + if rec2.Code != 428 { + t.Fatalf("gated pre-setup = %d, want 428", rec2.Code) + } + var e errorBody + if err := json.Unmarshal(rec2.Body.Bytes(), &e); err == nil && e.Error.Code != "ERR_SETUP_REQUIRED" { + t.Fatalf("code = %q", e.Error.Code) + } + + // Run setup through the API, then log in to obtain a session. + setupRec := do(t, h, http.MethodPost, "/api/auth/setup", + `{"username":"admin","password":"setup-password"}`, nil) + if setupRec.Code != 200 { + t.Fatalf("post-gate setup = %d", setupRec.Code) + } + loginRec := do(t, h, http.MethodPost, "/api/auth/login", + `{"username":"admin","password":"setup-password"}`, nil) + if loginRec.Code != 200 { + t.Fatalf("post-setup login = %d", loginRec.Code) + } + rec3 := do(t, h, http.MethodGet, "/api/auth/status", "", map[string]string{ + "Cookie": "maestro_access=" + cookieValue(t, loginRec, accessCookie), + }) + var st2 statusBody + if err := json.Unmarshal(rec3.Body.Bytes(), &st2); err != nil { + t.Fatal(err) + } + if st2.SetupRequired || !st2.Authenticated || st2.Username != "admin" { + t.Fatalf("post-setup status: %+v", st2) + } +} + +func TestSetupFlow(t *testing.T) { + _, h := newTestServer(t) + + rec := do(t, h, http.MethodPost, "/api/auth/setup", + `{"username":"admin","password":"first-owner-pass"}`, nil) + if rec.Code != 200 { + t.Fatalf("setup = %d: %s", rec.Code, rec.Body.String()) + } + if c := cookieValue(t, rec, accessCookie); c == "" { + t.Fatal("no session after setup") + } + + // Second setup attempt → 409. + rec2 := do(t, h, http.MethodPost, "/api/auth/setup", + `{"username":"evil","password":"hijack-attempt"}`, nil) + if rec2.Code != http.StatusConflict { + t.Fatalf("second setup = %d, want 409", rec2.Code) + } +} + +func TestLoginLogoutAndGatedAccess(t *testing.T) { + s, h := newTestServer(t) + do(t, h, http.MethodPost, "/api/auth/setup", + `{"username":"admin","password":"first-owner-pass"}`, nil) + + // Bad password. + rec := do(t, h, http.MethodPost, "/api/auth/login", + `{"username":"admin","password":"wrong-password"}`, nil) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("bad login = %d", rec.Code) + } + + // Good login. + rec = do(t, h, http.MethodPost, "/api/auth/login", + `{"username":"admin","password":"first-owner-pass"}`, nil) + if rec.Code != 200 { + t.Fatalf("login = %d: %s", rec.Code, rec.Body.String()) + } + var lr struct { + Username string `json:"username"` + Role string `json:"role"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &lr); err != nil || lr.Role != "admin" || lr.Username != "admin" { + t.Fatalf("login response: %s", rec.Body.String()) + } + access := cookieValue(t, rec, accessCookie) + refresh := cookieValue(t, rec, refreshCookie) + for _, c := range rec.Result().Cookies() { + if !c.HttpOnly || c.SameSite != http.SameSiteLaxMode { + t.Fatalf("cookie %s not HttpOnly/Lax", c.Name) + } + } + + // Gated access with the access token. + gate := s.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(r.Header.Get("X-Maestro-User"))) + })) + req := httptest.NewRequest(http.MethodGet, "/api/containers", nil) + req.AddCookie(&http.Cookie{Name: accessCookie, Value: access}) + rec2 := httptest.NewRecorder() + gate.ServeHTTP(rec2, req) + if rec2.Code != 200 || rec2.Body.String() != "admin" { + t.Fatalf("gated with token = %d %q", rec2.Code, rec2.Body.String()) + } + + // Logout revokes and clears both cookies. + rec3 := do(t, h, http.MethodPost, "/api/auth/logout", + "", map[string]string{"Cookie": "maestro_refresh=" + refresh}) + if rec3.Code != 200 { + t.Fatalf("logout = %d", rec3.Code) + } + for _, c := range rec3.Result().Cookies() { + if c.MaxAge >= 0 { + t.Fatalf("cookie %s not cleared (max-age=%d)", c.Name, c.MaxAge) + } + } + + // Refresh token is dead post-logout. + rec4 := do(t, h, http.MethodPost, "/api/auth/refresh", + "", map[string]string{"Cookie": "maestro_refresh=" + refresh}) + if rec4.Code != http.StatusUnauthorized { + t.Fatalf("refresh after logout = %d", rec4.Code) + } +} + +func TestRefreshRotationAndReuseDetection(t *testing.T) { + _, h := newTestServer(t) + setupRec := do(t, h, http.MethodPost, "/api/auth/setup", + `{"username":"admin","password":"first-owner-pass"}`, nil) + firstRefresh := cookieValue(t, setupRec, refreshCookie) + + // Rotate. + rec := do(t, h, http.MethodPost, "/api/auth/refresh", + "", map[string]string{"Cookie": "maestro_refresh=" + firstRefresh}) + if rec.Code != 200 { + t.Fatalf("refresh = %d: %s", rec.Code, rec.Body.String()) + } + secondRefresh := cookieValue(t, rec, refreshCookie) + if secondRefresh == firstRefresh { + t.Fatal("refresh token not rotated") + } + + // Replay of the old token → 401 AND kill-switch revokes everything, + // including the session minted during rotation. + replay := do(t, h, http.MethodPost, "/api/auth/refresh", + "", map[string]string{"Cookie": "maestro_refresh=" + firstRefresh}) + if replay.Code != http.StatusUnauthorized { + t.Fatalf("replay = %d, want 401", replay.Code) + } + rec2 := do(t, h, http.MethodPost, "/api/auth/refresh", + "", map[string]string{"Cookie": "maestro_refresh=" + secondRefresh}) + if rec2.Code != http.StatusUnauthorized { + t.Fatalf("post-replay rotation = %d, want 401 (kill switch)", rec2.Code) + } +} + +func TestCSRFOriginCheck(t *testing.T) { + s, h := newTestServer(t) + do(t, h, http.MethodPost, "/api/auth/setup", + `{"username":"admin","password":"first-owner-pass"}`, nil) + loginRec := do(t, h, http.MethodPost, "/api/auth/login", + `{"username":"admin","password":"first-owner-pass"}`, nil) + access := cookieValue(t, loginRec, accessCookie) + + handler := s.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(200) + })) + + // POST from a foreign origin without cookies → still 401 (auth first). + req := httptest.NewRequest(http.MethodPost, "/api/containers/install", nil) + req.Header.Set("Origin", "https://evil.example") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated foreign POST = %d, want 401", rec.Code) + } + + // Authenticated but foreign origin → 403. + req = httptest.NewRequest(http.MethodDelete, "/api/containers/uninstall", nil) + req.AddCookie(&http.Cookie{Name: accessCookie, Value: access}) + req.Header.Set("Origin", "https://evil.example") + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("foreign-origin POST = %d, want 403", rec.Code) + } + + // Same host, no Origin header (curl-style) → allowed. + req = httptest.NewRequest(http.MethodPost, "/api/x", nil) + req.Host = "localhost:8080" + req.AddCookie(&http.Cookie{Name: accessCookie, Value: access}) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("same-host POST = %d, want 200", rec.Code) + } + + // GET is exempt. + req = httptest.NewRequest(http.MethodGet, "/api/x", nil) + req.Header.Set("Origin", "https://evil.example") + req.AddCookie(&http.Cookie{Name: accessCookie, Value: access}) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("GET exempt = %d, want 200", rec.Code) + } +} + +func TestThrottleLockout(t *testing.T) { + _, h := newTestServer(t) + do(t, h, http.MethodPost, "/api/auth/setup", + `{"username":"admin","password":"first-owner-pass"}`, nil) + + for i := 0; i < 6; i++ { // 5 free + 1 that crosses the throttle threshold + rec := do(t, h, http.MethodPost, "/api/auth/login", + `{"username":"admin","password":"wrong-password"}`, nil) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d = %d", i+1, rec.Code) + } + } + rec := do(t, h, http.MethodPost, "/api/auth/login", + `{"username":"admin","password":"first-owner-pass"}`, nil) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("locked-out login = %d, want 429", rec.Code) + } + if got := rec.Header().Get("Retry-After"); got == "" { + t.Fatal("no Retry-After header on 429") + } +} + +func TestChangePasswordRevokesSessions(t *testing.T) { + _, h := newTestServer(t) + do(t, h, http.MethodPost, "/api/auth/setup", + `{"username":"admin","password":"first-owner-pass"}`, nil) + loginRec := do(t, h, http.MethodPost, "/api/auth/login", + `{"username":"admin","password":"first-owner-pass"}`, nil) + access := cookieValue(t, loginRec, accessCookie) + refresh := cookieValue(t, loginRec, refreshCookie) + + // Missing current password → 401. + rec := do(t, h, http.MethodPost, "/api/auth/change-password", + `{"new_password":"rotated-password"}`, + map[string]string{"Cookie": "maestro_access=" + access}) + if rec.Code != http.StatusBadRequest && rec.Code != http.StatusUnauthorized { + t.Fatalf("missing currentPassword = %d", rec.Code) + } + + rec = do(t, h, http.MethodPost, "/api/auth/change-password", + `{"current_password":"first-owner-pass","new_password":"rotated-password"}`, + map[string]string{"Cookie": "maestro_access=" + access}) + if rec.Code != 200 { + t.Fatalf("change-password = %d: %s", rec.Code, rec.Body.String()) + } + + // All sessions revoked — including the caller's. + rec2 := do(t, h, http.MethodPost, "/api/auth/refresh", + "", map[string]string{"Cookie": "maestro_refresh=" + refresh}) + if rec2.Code != http.StatusUnauthorized { + t.Fatalf("refresh after password change = %d", rec2.Code) + } + + // Old password dead, new one works. + rec3 := do(t, h, http.MethodPost, "/api/auth/login", + `{"username":"admin","password":"first-owner-pass"}`, nil) + if rec3.Code != http.StatusUnauthorized { + t.Fatalf("old password login = %d", rec3.Code) + } + rec4 := do(t, h, http.MethodPost, "/api/auth/login", + `{"username":"admin","password":"rotated-password"}`, nil) + if rec4.Code != 200 { + t.Fatalf("new password login = %d: %s", rec4.Code, rec4.Body.String()) + } +} + +func TestGateWebSocketRejectsAnonymousUpgrade(t *testing.T) { + s, h := newTestServer(t) + do(t, h, http.MethodPost, "/api/auth/setup", + `{"username":"admin","password":"first-owner-pass"}`, nil) + + handler := s.GateWebSocket(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Error("upgrade handler reached without credentials") + })) + req := httptest.NewRequest(http.MethodGet, "/api/ws", nil) + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("anonymous ws = %d, want 401", rec.Code) + } +} diff --git a/src/lib/db/dbctl_integration_test.go b/src/lib/db/dbctl_integration_test.go index aeff1ed..a6c6fdf 100644 --- a/src/lib/db/dbctl_integration_test.go +++ b/src/lib/db/dbctl_integration_test.go @@ -81,4 +81,4 @@ func TestSyncAuthorizationIntegration(t *testing.T) { assert(t, `SELECT has_database_privilege('keepnote','dcntrlized','CONNECT')`, "t") // Armed database: PUBLIC grant removed. assert(t, `SELECT has_database_privilege('public','dcntrlized','CONNECT')`, "f") -} \ No newline at end of file +} diff --git a/src/lib/owner/owner.go b/src/lib/owner/owner.go new file mode 100644 index 0000000..4ecc1e8 --- /dev/null +++ b/src/lib/owner/owner.go @@ -0,0 +1,274 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package owner is Maestro's self-contained instance-owner identity store +// (onboarding theme M1, D1/D2): argon2id password hashes in a JSON file at the +// data root, deliberately independent of the fleet database and auth service — +// logging into the orchestrator must work exactly when everything else is +// down. The Store interface keeps the middleware swappable for a DB- or +// LDAP-backed implementation later. +package owner + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "golang.org/x/crypto/argon2" +) + +// Owner is one dashboard user. The setup wizard creates the first admin; +// further owners seed through the same store/API path (no management UI yet). +type Owner struct { + Username string `json:"username"` + PasswordHash string `json:"password_hash"` + IsAdmin bool `json:"is_admin"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Role renders the JWT role claim: admins gatekeep trust decisions. +func (o *Owner) Role() string { + if o.IsAdmin { + return "admin" + } + return "member" +} + +// Store persists owners. FileOwnerStore is the on-disk implementation; a +// DB-backed store can slot in later without touching the middleware. +type Store interface { + Count(ctx context.Context) (int, error) + Verify(ctx context.Context, username, password string) (*Owner, error) + Create(ctx context.Context, username, password string, isAdmin bool) (*Owner, error) + // Get looks a user up without credentials (role refresh during token + // rotation). + Get(ctx context.Context, username string) (*Owner, error) +} + +var ( + usernameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{2,63}$`) + // Mirrors the dcntrlized-auth conventions. + MinPasswordLen = 10 + + ErrInvalidUsername = fmt.Errorf("username must match %s", usernameRe.String()) + ErrWeakPassword = fmt.Errorf("password must be at least %d characters", MinPasswordLen) +) + +// ValidateCredentials enforces the shared-auth conventions: lowercase +// usernames of 3–64 chars, passwords of at least MinPasswordLen chars. +func ValidateCredentials(username, password string) error { + if !usernameRe.MatchString(username) { + return ErrInvalidUsername + } + if len(password) < MinPasswordLen { + return ErrWeakPassword + } + return nil +} + +const ( + argonTime = 3 + argonMemory = 64 * 1024 + argonThreads = 2 + argonKeyLen = 32 +) + +// HashPassword derives an argon2id PHC-style hash string. +func HashPassword(password string) (string, error) { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("read salt: %w", err) + } + key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, argonMemory, argonTime, argonThreads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(key)), nil +} + +// VerifyPassword compares a password against a PHC-style argon2id hash in +// constant time. +func VerifyPassword(hash, password string) bool { + parts := strings.Split(hash, "$") + // "", "argon2id", "v=19", "m=…,t=…,p=…", salt, key + if len(parts) != 6 || parts[1] != "argon2id" { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false + } + want, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false + } + got := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1 +} + +// ChangePassword rehashes the owner's password and bumps updated_at. +func (s *FileOwnerStore) ChangePassword(ctx context.Context, username, currentPassword, newPassword string) (*Owner, error) { + s.mu.Lock() + defer s.mu.Unlock() + + file, owners, err := s.read() + if err != nil { + return nil, err + } + for i := range owners.Owners { + o := &owners.Owners[i] + if o.Username != username { + continue + } + if !VerifyPassword(o.PasswordHash, currentPassword) { + return nil, os.ErrPermission + } + hash, err := HashPassword(newPassword) + if err != nil { + return nil, err + } + o.PasswordHash = hash + o.UpdatedAt = time.Now().UTC() + return o, s.write(file, owners) + } + return nil, os.ErrNotExist +} + +type ownerFile struct { + Version int `json:"version"` + Owners []Owner `json:"owners"` +} + +// FileOwnerStore persists owners to /maestro/owners.json with +// atomic writes and chmod 600. +type FileOwnerStore struct { + path string + mu sync.Mutex +} + +// NewFileOwnerStore targets dir/owners.json (dir = /maestro). +func NewFileOwnerStore(dir string) (*FileOwnerStore, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("create owner store directory: %w", err) + } + return &FileOwnerStore{path: filepath.Join(dir, "owners.json")}, nil +} + +func (s *FileOwnerStore) read() (string, *ownerFile, error) { + file := ownerFile{Version: 1} + data, err := os.ReadFile(s.path) + if os.IsNotExist(err) { + return s.path, &file, nil + } + if err != nil { + return s.path, nil, fmt.Errorf("read owners file: %w", err) + } + if err := json.Unmarshal(data, &file); err != nil { + return s.path, nil, fmt.Errorf("parse owners file: %w", err) + } + return s.path, &file, nil +} + +// write atomically replaces the owners file; the mutex serializes writers and +// concurrent setups end up idempotent through Count's check in the caller. +func (s *FileOwnerStore) write(path string, owners *ownerFile) error { + data, err := json.MarshalIndent(owners, "", " ") + if err != nil { + return fmt.Errorf("encode owners file: %w", err) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("write owners file: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("replace owners file: %w", err) + } + return nil +} + +func (s *FileOwnerStore) Count(_ context.Context) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + _, owners, err := s.read() + if err != nil { + return 0, err + } + return len(owners.Owners), nil +} + +func (s *FileOwnerStore) Create(_ context.Context, username, password string, isAdmin bool) (*Owner, error) { + if err := ValidateCredentials(username, password); err != nil { + return nil, err + } + s.mu.Lock() + defer s.mu.Unlock() + + file, owners, err := s.read() + if err != nil { + return nil, err + } + for _, o := range owners.Owners { + if o.Username == username { + return nil, os.ErrExist + } + } + hash, err := HashPassword(password) + if err != nil { + return nil, err + } + now := time.Now().UTC() + owner := Owner{Username: username, PasswordHash: hash, IsAdmin: isAdmin, CreatedAt: now, UpdatedAt: now} + owners.Owners = append(owners.Owners, owner) + if err := s.write(file, owners); err != nil { + return nil, err + } + return &owner, nil +} + +func (s *FileOwnerStore) Verify(_ context.Context, username, password string) (*Owner, error) { + o, err := s.Get(context.Background(), username) + if err != nil || o == nil { + return nil, os.ErrNotExist + } + if VerifyPassword(o.PasswordHash, password) { + return o, nil + } + return nil, os.ErrNotExist +} + +func (s *FileOwnerStore) Get(_ context.Context, username string) (*Owner, error) { + s.mu.Lock() + defer s.mu.Unlock() + _, owners, err := s.read() + if err != nil { + return nil, err + } + for _, o := range owners.Owners { + if o.Username == username { + return &o, nil + } + } + return nil, os.ErrNotExist +} diff --git a/src/lib/owner/owner_test.go b/src/lib/owner/owner_test.go new file mode 100644 index 0000000..c89a754 --- /dev/null +++ b/src/lib/owner/owner_test.go @@ -0,0 +1,168 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package owner + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +func newTestStore(t *testing.T) *FileOwnerStore { + t.Helper() + s, err := NewFileOwnerStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return s +} + +func TestHashAndVerifyPassword(t *testing.T) { + hash, err := HashPassword("correct horse battery") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(hash, "$argon2id$v=19$m=65536,t=3,p=2$") { + t.Fatalf("hash missing argon2id PHC prefix: %s", hash) + } + if !VerifyPassword(hash, "correct horse battery") { + t.Fatal("correct password rejected") + } + if VerifyPassword(hash, "wrong password!") { + t.Fatal("wrong password accepted") + } + if VerifyPassword("garbage", "x") { + t.Fatal("garbage hash accepted") + } +} + +func TestValidateCredentials(t *testing.T) { + good := [][2]string{{"admin", "longenough123"}, {"a.b-c_d1", "0123456789"}} + for _, c := range good { + if err := ValidateCredentials(c[0], c[1]); err != nil { + t.Errorf("%q/%q rejected: %v", c[0], c[1], err) + } + } + bad := [][2]string{ + {"Admin", "0123456789"}, // uppercase + {"-lead", "0123456789"}, // bad first char + {"ab", "0123456789"}, // too short + {"okname", "short"}, // weak password + } + for _, c := range bad { + if err := ValidateCredentials(c[0], c[1]); err == nil { + t.Errorf("%q/%q accepted", c[0], c[1]) + } + } +} + +func TestStoreRoundtripAndMultiUser(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + if n, _ := s.Count(ctx); n != 0 { + t.Fatalf("fresh store Count = %d", n) + } + first, err := s.Create(ctx, "admin", "first-owner-pass", true) + if err != nil { + t.Fatal(err) + } + if !first.IsAdmin || first.Role() != "admin" { + t.Fatalf("first owner should be admin, got %+v", first) + } + second, err := s.Create(ctx, "member", "second-owner-pass", false) + if err != nil { + t.Fatal(err) + } + if second.Role() != "member" { + t.Fatalf("second owner role = %q", second.Role()) + } + if n, _ := s.Count(ctx); n != 2 { + t.Fatalf("Count = %d, want 2", n) + } + + if got, err := s.Verify(ctx, "admin", "first-owner-pass"); err != nil || got.Username != "admin" { + t.Fatalf("Verify admin: %v %+v", err, got) + } + if _, err := s.Verify(ctx, "admin", "wrong"); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("wrong password: %v", err) + } + if _, err := s.Create(ctx, "admin", "whatever-123", false); !errors.Is(err, os.ErrExist) { + t.Fatalf("duplicate create: %v", err) + } + + changed, err := s.ChangePassword(ctx, "admin", "first-owner-pass", "rotated-password") + if err != nil { + t.Fatal(err) + } + if changed.UpdatedAt.Equal(changed.CreatedAt) { + t.Fatal("updated_at not bumped") + } + if _, err := s.Verify(ctx, "admin", "first-owner-pass"); err == nil { + t.Fatal("old password still valid") + } + if _, err := s.Verify(ctx, "admin", "rotated-password"); err != nil { + t.Fatalf("new password rejected: %v", err) + } +} + +func TestConcurrentCreateIsIdempotent(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + var wg sync.WaitGroup + errs := make(chan error, 16) + for i := 0; i < 16; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := s.Create(ctx, "wizard", "setup-password", true); err != nil && !errors.Is(err, os.ErrExist) { + errs <- err + } + }() + } + wg.Wait() + close(errs) + for err := range errs { + t.Fatalf("unexpected error: %v", err) + } + if n, _ := s.Count(ctx); n != 1 { + t.Fatalf("Count after concurrent setup = %d, want 1", n) + } +} + +func TestOwnerFilePermissions(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + s, err := NewFileOwnerStore(dir) + if err != nil { + t.Fatal(err) + } + if _, err := s.Create(ctx, "admin", "perm-check-pass", true); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(dir, "owners.json")) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("owners.json mode = %o, want 600", perm) + } +} diff --git a/src/lib/owner/sessions.go b/src/lib/owner/sessions.go new file mode 100644 index 0000000..e3864a2 --- /dev/null +++ b/src/lib/owner/sessions.go @@ -0,0 +1,171 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package owner + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// Session is one persisted refresh-token record. Only the SHA-256 hash of the +// jti is stored — never the raw value, mirroring the auth service's +// token_hash convention. +type Session struct { + Username string `json:"username"` + JTIHash string `json:"jti_hash"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + RevokedAt *time.Time `json:"revoked_at"` +} + +type sessionFile struct { + Version int `json:"version"` + Sessions []Session `json:"sessions"` +} + +// HashJTI renders the stored digest of a raw refresh jti. +func HashJTI(jti string) string { + sum := sha256.Sum256([]byte(jti)) + return hex.EncodeToString(sum[:]) +} + +// FileSessionStore persists refresh-token state to +// /maestro/sessions.json so sessions survive restarts, can be +// revoked on logout, and support rotation + reuse detection. +type FileSessionStore struct { + path string + mu sync.Mutex +} + +// NewFileSessionStore targets dir/sessions.json. +func NewFileSessionStore(dir string) (*FileSessionStore, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("create session store directory: %w", err) + } + return &FileSessionStore{path: filepath.Join(dir, "sessions.json")}, nil +} + +func (s *FileSessionStore) load() (*sessionFile, error) { + file := sessionFile{Version: 1} + data, err := os.ReadFile(s.path) + if os.IsNotExist(err) { + return &file, nil + } + if err != nil { + return nil, fmt.Errorf("read sessions file: %w", err) + } + if err := json.Unmarshal(data, &file); err != nil { + return nil, fmt.Errorf("parse sessions file: %w", err) + } + return &file, nil +} + +// save atomically replaces the file after pruning expired/revoked rows. +func (s *FileSessionStore) save(file *sessionFile) error { + now := time.Now() + live := file.Sessions[:0] + for _, s := range file.Sessions { + if s.RevokedAt == nil && now.Before(s.ExpiresAt) { + live = append(live, s) + } + } + file.Sessions = live + + data, err := json.MarshalIndent(file, "", " ") + if err != nil { + return fmt.Errorf("encode sessions file: %w", err) + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("write sessions file: %w", err) + } + return os.Rename(tmp, s.path) +} + +// Store records a freshly minted refresh token. +func (s *FileSessionStore) Store(username, jti string, expiresAt time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + file, err := s.load() + if err != nil { + return err + } + file.Sessions = append(file.Sessions, Session{ + Username: username, + JTIHash: HashJTI(jti), + CreatedAt: time.Now().UTC(), + ExpiresAt: expiresAt.UTC(), + }) + return s.save(file) +} + +// Valid reports whether the jti maps to a live (stored, unrevoked, unexpired) +// session row. +func (s *FileSessionStore) Valid(jti string) bool { + s.mu.Lock() + defer s.mu.Unlock() + file, err := s.load() + if err != nil { + return false + } + hash := HashJTI(jti) + now := time.Now() + for _, sess := range file.Sessions { + if sess.JTIHash == hash { + return sess.RevokedAt == nil && now.Before(sess.ExpiresAt) + } + } + return false +} + +// Revoke marks the jti's row revoked (logout / rotation). +func (s *FileSessionStore) Revoke(jti string) error { + return s.revokeWhere(func(sess Session) bool { return sess.JTIHash == HashJTI(jti) }) +} + +// RevokeAllForUser kills every live session row of the user — the response to +// refresh-token reuse (theft signal). +func (s *FileSessionStore) RevokeAllForUser(username string) error { + return s.revokeWhere(func(sess Session) bool { return sess.Username == username }) +} + +func (s *FileSessionStore) revokeWhere(match func(Session) bool) error { + s.mu.Lock() + defer s.mu.Unlock() + file, err := s.load() + if err != nil { + return err + } + now := time.Now().UTC() + changed := false + for i := range file.Sessions { + if match(file.Sessions[i]) && file.Sessions[i].RevokedAt == nil { + t := now + file.Sessions[i].RevokedAt = &t + changed = true + } + } + if !changed { + return nil + } + return s.save(file) +} diff --git a/src/lib/owner/sessions_test.go b/src/lib/owner/sessions_test.go new file mode 100644 index 0000000..36fc401 --- /dev/null +++ b/src/lib/owner/sessions_test.go @@ -0,0 +1,111 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package owner + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + "time" +) + +func TestSessionsStoreValidRevoke(t *testing.T) { + s, err := NewFileSessionStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + + jti := "jti-1" + if err := s.Store("admin", jti, time.Now().Add(time.Hour)); err != nil { + t.Fatal(err) + } + if !s.Valid(jti) { + t.Fatal("fresh session invalid") + } + if s.Valid("missing") { + t.Fatal("unknown session valid") + } + + if err := s.Revoke(jti); err != nil { + t.Fatal(err) + } + if s.Valid(jti) { + t.Fatal("revoked session still valid") + } + // Re-revoke must not fail (logout idempotence). + if err := s.Revoke(jti); err != nil { + t.Fatalf("double revoke: %v", err) + } +} + +func TestRevokeAllForUser(t *testing.T) { + s, _ := NewFileSessionStore(t.TempDir()) + + for _, j := range []string{"a", "b", "c"} { + if err := s.Store("admin", j, time.Now().Add(time.Hour)); err != nil { + t.Fatal(err) + } + } + if err := s.Store("other", "d", time.Now().Add(time.Hour)); err != nil { + t.Fatal(err) + } + + if err := s.RevokeAllForUser("admin"); err != nil { + t.Fatal(err) + } + for _, j := range []string{"a", "b", "c"} { + if s.Valid(j) { + t.Fatalf("session %q survived revoke-all", j) + } + } + if !s.Valid("d") { + t.Fatal("other user's session revoked") + } +} + +func TestExpiredSessionsPrunedOnSave(t *testing.T) { + dir := t.TempDir() + s, _ := NewFileSessionStore(dir) + + // Expired session: stored but never valid. + expired := sha256.Sum256([]byte("expired")) + jti := hex.EncodeToString(expired[:]) + if err := s.Store("admin", jti, time.Now().Add(-time.Minute)); err != nil { + t.Fatal(err) + } + if s.Valid(jti) { + t.Fatal("expired session valid") + } + + // Prune happens on save: force a write and confirm the expired entry + // disappears from disk. + if err := s.Store("admin", "fresh", time.Now().Add(time.Hour)); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(dir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(data, []byte(HashJTI(jti))) { + t.Fatal("expired session not pruned from disk") + } + if !bytes.Contains(data, []byte(HashJTI("fresh"))) { + t.Fatal("live session missing from disk") + } +} diff --git a/src/lib/owner/throttle.go b/src/lib/owner/throttle.go new file mode 100644 index 0000000..18f6ee0 --- /dev/null +++ b/src/lib/owner/throttle.go @@ -0,0 +1,118 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package owner + +import ( + "sync" + "time" +) + +// In-process throttling for login, setup and password change, keyed by +// username — mirrors dcntrlized-auth's handlers/auth/throttle.go: the first +// throttleMaxAttempts failures inside the window pass freely (humans mistype), +// beyond that each further failure multiplies the wait (5s×4^n, capped at +// 24h). Maestro is single-instance, so process-local state suffices. + +const ( + throttleMaxAttempts = 5 + throttleWindow = 15 * time.Minute + throttleBackoffBase = 5 * time.Second + throttleMultiplier = 4 + throttleMaxWait = 24 * time.Hour +) + +type throttleAttempt struct { + count int + last time.Time +} + +type Throttler struct { + mu sync.Mutex + state map[string]*throttleAttempt + nowFn func() time.Time +} + +// NewThrottler builds an empty throttler. +func NewThrottler() *Throttler { + return &Throttler{state: map[string]*throttleAttempt{}, nowFn: time.Now} +} + +func backoffFor(count int) time.Duration { + delay := throttleBackoffBase + for i := 1; i < count-throttleMaxAttempts && delay < throttleMaxWait; i++ { + delay *= throttleMultiplier + } + if delay > throttleMaxWait { + delay = throttleMaxWait + } + return delay +} + +// Allow reports whether an attempt for identifier may proceed right now. +func (t *Throttler) Allow(identifier string) bool { + t.mu.Lock() + defer t.mu.Unlock() + a, ok := t.state[identifier] + if !ok || a.count <= throttleMaxAttempts { + return true + } + if t.nowFn().Sub(a.last) > throttleWindow { + delete(t.state, identifier) + return true + } + return t.nowFn().Sub(a.last) >= backoffFor(a.count) +} + +// Fail records a failed attempt. +func (t *Throttler) Fail(identifier string) { + t.mu.Lock() + defer t.mu.Unlock() + a, ok := t.state[identifier] + now := t.nowFn() + if !ok || now.Sub(a.last) > throttleWindow { + t.state[identifier] = &throttleAttempt{count: 1, last: now} + return + } + a.count++ + a.last = now +} + +// Reset clears the identifier after a success. +func (t *Throttler) Reset(identifier string) { + t.mu.Lock() + defer t.mu.Unlock() + delete(t.state, identifier) +} + +// RetryAfter reports how long the identifier must wait before the next +// attempt is permitted; 0 when attempts are currently allowed. Used for the +// 429 Retry-After header. +func (t *Throttler) RetryAfter(identifier string) time.Duration { + t.mu.Lock() + defer t.mu.Unlock() + a, ok := t.state[identifier] + if !ok || a.count <= throttleMaxAttempts { + return 0 + } + now := t.nowFn() + if now.Sub(a.last) > throttleWindow { + return 0 + } + if wait := backoffFor(a.count) - now.Sub(a.last); wait > 0 { + return wait + } + return 0 +} diff --git a/src/lib/owner/tokens.go b/src/lib/owner/tokens.go new file mode 100644 index 0000000..36591b4 --- /dev/null +++ b/src/lib/owner/tokens.go @@ -0,0 +1,207 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package owner + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + // AccessTokenTTL is the stateless access-token lifetime. + AccessTokenTTL = 15 * time.Minute + // RefreshTokenTTL is the persisted refresh-token lifetime. + RefreshTokenTTL = 7 * 24 * time.Hour + // Issuer tags every minted token; verification rejects others. + Issuer = "maestro" + + claimType = "type" + claimKid = "kid" + claimJti = "jti" + + typeAccess = "access" + typeRefresh = "refresh" +) + +var ( + ErrInvalidToken = errors.New("invalid token") + ErrWrongType = errors.New("token has the wrong type") +) + +// TokenPair is one login/rotation result: the cookie payloads plus the refresh +// jti (its raw value lives only in the cookies and the caller's response). +type TokenPair struct { + Access string + Refresh string + RefreshJTI string + AccessTTL time.Duration + RefreshTTL time.Duration + Username string + Role string +} + +// TokenService mints and verifies the HS256 access/refresh session pair. The +// signing secret is a per-instance random key on disk (`session.key`, chmod +// 600); kid = sha256(secret)[:16] so a rotated secret keeps old access tokens +// valid through their remaining TTL (grace window). +type TokenService struct { + dir string + + key []byte + kid string +} + +// NewTokenService loads or creates dir/session.key. +func NewTokenService(dir string) (*TokenService, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("create token directory: %w", err) + } + path := filepath.Join(dir, "session.key") + key, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + key = make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("generate session key: %w", err) + } + if err := os.WriteFile(path, key, 0o600); err != nil { + return nil, fmt.Errorf("persist session key: %w", err) + } + } else if err != nil { + return nil, fmt.Errorf("read session key: %w", err) + } else if len(key) < 32 { + return nil, fmt.Errorf("session key at %s is too short", path) + } + + sum := sha256.Sum256(key) + return &TokenService{dir: dir, key: key, kid: hex.EncodeToString(sum[:])[:16]}, nil +} + +// Kid returns the active signing-key identifier. +func (t *TokenService) Kid() string { return t.kid } + +func (t *TokenService) claims(sub, typ, role string, ttl time.Duration, jti string) jwt.MapClaims { + now := time.Now() + c := jwt.MapClaims{ + "sub": sub, + "iss": Issuer, + "iat": now.Unix(), + "exp": now.Add(ttl).Unix(), + claimType: typ, + claimKid: t.kid, + } + if role != "" { + c["role"] = role + } + if jti != "" { + c[claimJti] = jti + } + return c +} + +func (t *TokenService) sign(c jwt.MapClaims) (string, error) { + return jwt.NewWithClaims(jwt.SigningMethodHS256, c).SignedString(t.key) +} + +// MintPair issues a fresh access/refresh pair for the owner. The refresh jti +// must be recorded by the session store before the cookie is served. +func (t *TokenService) MintPair(username, role string) (*TokenPair, error) { + jti := make([]byte, 16) + if _, err := rand.Read(jti); err != nil { + return nil, fmt.Errorf("generate jti: %w", err) + } + raw := hex.EncodeToString(jti) + + access, err := t.sign(t.claims(username, typeAccess, role, AccessTokenTTL, "")) + if err != nil { + return nil, fmt.Errorf("sign access token: %w", err) + } + refresh, err := t.sign(t.claims(username, typeRefresh, "", RefreshTokenTTL, raw)) + if err != nil { + return nil, fmt.Errorf("sign refresh token: %w", err) + } + return &TokenPair{ + Access: access, Refresh: refresh, RefreshJTI: raw, + AccessTTL: AccessTokenTTL, RefreshTTL: RefreshTokenTTL, + Username: username, Role: role, + }, nil +} + +// parse validates signature (constant-time HMAC compare inside the library), +// issuer and expiry, returning the claims. +func (t *TokenService) parse(token string) (jwt.MapClaims, error) { + parsed, err := jwt.Parse(token, func(tk *jwt.Token) (interface{}, error) { + if _, ok := tk.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, ErrInvalidToken + } + return t.key, nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithIssuer(Issuer), jwt.WithExpirationRequired()) + if err != nil || !parsed.Valid { + return nil, ErrInvalidToken + } + claims, ok := parsed.Claims.(jwt.MapClaims) + if !ok { + return nil, ErrInvalidToken + } + if kid, _ := claims[claimKid].(string); kid != "" && kid != t.kid && !hmac.Equal([]byte(kid), []byte(t.kid)) { + // A different kid means an unknown secret generation — refuse. The + // same-kid path is the normal case; empty kid predates kid tagging. + return nil, ErrInvalidToken + } + return claims, nil +} + +// VerifyAccess validates an access token and returns the username + role. +func (t *TokenService) VerifyAccess(token string) (username, role string, err error) { + claims, err := t.parse(token) + if err != nil { + return "", "", err + } + if typ, _ := claims[claimType].(string); typ != typeAccess { + return "", "", ErrWrongType + } + sub, _ := claims["sub"].(string) + role, _ = claims["role"].(string) + if sub == "" { + return "", "", ErrInvalidToken + } + return sub, role, nil +} + +// VerifyRefresh validates a refresh token and returns username + jti. +func (t *TokenService) VerifyRefresh(token string) (username, jti string, err error) { + claims, err := t.parse(token) + if err != nil { + return "", "", err + } + if typ, _ := claims[claimType].(string); typ != typeRefresh { + return "", "", ErrWrongType + } + sub, _ := claims["sub"].(string) + jti, _ = claims[claimJti].(string) + if sub == "" || jti == "" { + return "", "", ErrInvalidToken + } + return sub, jti, nil +} diff --git a/src/lib/owner/tokens_test.go b/src/lib/owner/tokens_test.go new file mode 100644 index 0000000..58e49e1 --- /dev/null +++ b/src/lib/owner/tokens_test.go @@ -0,0 +1,224 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package owner + +import ( + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func newTestTokenService(t *testing.T) *TokenService { + t.Helper() + s, err := NewTokenService(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return s +} + +func TestMintAndVerifyPair(t *testing.T) { + s := newTestTokenService(t) + + pair, err := s.MintPair("admin", "admin") + if err != nil { + t.Fatal(err) + } + if pair.Access == "" || pair.Refresh == "" || pair.RefreshJTI == "" { + t.Fatal("empty field in pair") + } + if pair.Username != "admin" || pair.Role != "admin" { + t.Fatalf("pair identity wrong: %+v", pair) + } + if pair.AccessTTL != AccessTokenTTL || pair.RefreshTTL != RefreshTokenTTL { + t.Fatalf("pair TTLs wrong: %+v", pair) + } + for _, tok := range []string{pair.Access, pair.Refresh} { + parts := strings.Split(tok, ".") + if len(parts) != 3 { + t.Fatalf("token not JWS compact: %q", tok) + } + } + + username, role, err := s.VerifyAccess(pair.Access) + if err != nil { + t.Fatalf("VerifyAccess: %v", err) + } + if username != "admin" || role != "admin" { + t.Fatalf("access claims: %q/%q", username, role) + } + rUser, rJTI, err := s.VerifyRefresh(pair.Refresh) + if err != nil { + t.Fatalf("VerifyRefresh: %v", err) + } + if rUser != "admin" || rJTI != pair.RefreshJTI { + t.Fatalf("refresh claims: %q/%q", rUser, rJTI) + } +} + +func TestTokenTypeIsEnforced(t *testing.T) { + s := newTestTokenService(t) + pair, _ := s.MintPair("admin", "admin") + + if _, _, err := s.VerifyAccess(pair.Refresh); err == nil { + t.Fatal("refresh token accepted as access") + } + if _, _, err := s.VerifyRefresh(pair.Access); err == nil { + t.Fatal("access token accepted as refresh") + } +} + +func craftToken(t *testing.T, s *TokenService, claims jwt.MapClaims) string { + t.Helper() + tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.key) + if err != nil { + t.Fatal(err) + } + return tok +} + +func TestExpiredTokensRejected(t *testing.T) { + s := newTestTokenService(t) + + mk := func(typ string, ttl time.Duration) string { + now := time.Now() + return craftToken(t, s, jwt.MapClaims{ + "iss": Issuer, "sub": "admin", "type": typ, "kid": s.kid, + "iat": now.Add(-2 * ttl).Unix(), "exp": now.Add(-ttl).Unix(), + "jti": "expired-" + typ, + }) + } + + if _, _, err := s.VerifyAccess(mk(typeAccess, AccessTokenTTL)); err == nil { + t.Fatal("expired access accepted") + } + if _, _, err := s.VerifyRefresh(mk(typeRefresh, RefreshTokenTTL)); err == nil { + t.Fatal("expired refresh accepted") + } +} + +func TestForgedTokensRejected(t *testing.T) { + s := newTestTokenService(t) + pair, _ := s.MintPair("admin", "admin") + + // Signed by a different key. + other := newTestTokenService(t) + forged, _ := other.MintPair("admin", "admin") + if _, _, err := s.VerifyAccess(forged.Access); err == nil { + t.Fatal("foreign-signed access accepted") + } + if _, _, err := s.VerifyRefresh(forged.Refresh); err == nil { + t.Fatal("foreign-signed refresh accepted") + } + + // Tampered payload. + if _, _, err := s.VerifyAccess(strings.TrimSuffix(pair.Access, "x") + "y"); err == nil { + t.Fatal("tampered token accepted") + } + + // Wrong issuer but right key. + now := time.Now() + badIss := craftToken(t, s, jwt.MapClaims{ + "iss": "someone-else", "sub": "admin", "type": typeAccess, "kid": s.kid, + "iat": now.Unix(), "exp": now.Add(time.Hour).Unix(), + }) + if _, _, err := s.VerifyAccess(badIss); err == nil { + t.Fatal("wrong-issuer token accepted") + } + + // Unknown kid (foreign key generation). + unknownKid := craftToken(t, s, jwt.MapClaims{ + "iss": Issuer, "sub": "admin", "type": typeAccess, "kid": "deadbeefdeadbeef", + "iat": now.Unix(), "exp": now.Add(time.Hour).Unix(), + }) + if _, _, err := s.VerifyAccess(unknownKid); err == nil { + t.Fatal("unknown-kid token accepted") + } +} + +func TestKeyPersistsAcrossRestarts(t *testing.T) { + dir := t.TempDir() + s1, err := NewTokenService(dir) + if err != nil { + t.Fatal(err) + } + pair, err := s1.MintPair("admin", "admin") + if err != nil { + t.Fatal(err) + } + s2, err := NewTokenService(dir) + if err != nil { + t.Fatal(err) + } + if s1.Kid() != s2.Kid() { + t.Fatalf("kid changed across restarts: %s vs %s", s1.Kid(), s2.Kid()) + } + if _, _, err := s2.VerifyAccess(pair.Access); err != nil { + t.Fatalf("token invalid after restart: %v", err) + } +} + +func TestThrottlerBackoffAndReset(t *testing.T) { + th := NewThrottler() + id := "admin" + + if !th.Allow(id) { + t.Fatal("fresh identifier blocked") + } + if th.RetryAfter(id) != 0 { + t.Fatal("RetryAfter nonzero without failures") + } + + // The first five failures stay within the free allowance. + for i := 0; i < throttleMaxAttempts; i++ { + th.Fail(id) + } + if !th.Allow(id) { + t.Fatal("attempt inside allowance blocked") + } + if th.RetryAfter(id) != 0 { + t.Fatal("RetryAfter nonzero inside allowance") + } + + // The next failure crosses the threshold: backoff starts at base… + th.Fail(id) + if th.Allow(id) { + t.Fatal("locked-out identifier allowed immediately") + } + if wait := th.RetryAfter(id); wait <= 0 || wait > throttleBackoffBase { + t.Fatalf("RetryAfter = %v, want (0,%v]", wait, throttleBackoffBase) + } + // …and escalates multiplicatively with further failures. + th.Fail(id) + if wait := th.RetryAfter(id); wait <= throttleBackoffBase || wait > throttleBackoffBase*throttleMultiplier { + t.Fatalf("RetryAfter = %v, want escalated", wait) + } + + // Success clears state. + th.Reset(id) + if !th.Allow(id) || th.RetryAfter(id) != 0 { + t.Fatal("reset did not clear throttler") + } + + // Window expiry also clears state. + th.Fail(id) + th.nowFn = func() time.Time { return time.Now().Add(2 * throttleWindow) } + if !th.Allow(id) { + t.Fatal("identifier not forgiven after window") + } +} diff --git a/src/main.go b/src/main.go index 2787919..b7c625c 100644 --- a/src/main.go +++ b/src/main.go @@ -22,8 +22,10 @@ import ( "log" "net/http" "os" + "path/filepath" "git.dcntrlized.org/Dcntrlized/Maestro/handlers/actions" + "git.dcntrlized.org/Dcntrlized/Maestro/handlers/auth" "git.dcntrlized.org/Dcntrlized/Maestro/handlers/containers" "git.dcntrlized.org/Dcntrlized/Maestro/handlers/peers" handlers "git.dcntrlized.org/Dcntrlized/Maestro/handlers/registry" @@ -31,18 +33,62 @@ import ( "git.dcntrlized.org/Dcntrlized/Maestro/lib/devboot" "git.dcntrlized.org/Dcntrlized/Maestro/lib/docker" "git.dcntrlized.org/Dcntrlized/Maestro/lib/env" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/owner" "git.dcntrlized.org/Dcntrlized/Maestro/lib/pki" ) //go:embed all:frontend/dist var embeddedFrontend embed.FS +// resetOwner implements --reset-owner: documented headless lockout recovery. +// It removes the owner store, signing key and sessions, then exits — the next +// boot runs the setup wizard again. +func resetOwner() { + dataRoot, err := libcontainers.DataRoot() + if err != nil { + log.Fatalf("Failed to resolve data root: %v", err) + } + dir := filepath.Join(dataRoot, "maestro") + for _, f := range []string{"owners.json", "session.key", "sessions.json"} { + if err := os.Remove(filepath.Join(dir, f)); err != nil && !os.IsNotExist(err) { + log.Fatalf("Failed to remove %s: %v", f, err) + } + } + log.Printf("Owner identity reset (%s); restart Maestro to run the setup wizard.", dir) +} + func main() { + if len(os.Args) > 1 && os.Args[1] == "--reset-owner" { + resetOwner() + return + } + ctx, cancel := context.WithCancel(context.Background()) defer cancel() cache := libcontainers.NewContainerCache() + // Instance-owner gate (onboarding theme M1): self-contained credential + + // session stores at the data root, independent of the fleet. + dataRoot, err := libcontainers.DataRoot() + if err != nil { + log.Fatalf("Failed to resolve data root: %v", err) + } + maestroDir := filepath.Join(dataRoot, "maestro") + ownerStore, err := owner.NewFileOwnerStore(maestroDir) + if err != nil { + log.Fatalf("Failed to open owner store: %v", err) + } + tokenService, err := owner.NewTokenService(maestroDir) + if err != nil { + log.Fatalf("Failed to initialize session signing key: %v", err) + } + sessionStore, err := owner.NewFileSessionStore(maestroDir) + if err != nil { + log.Fatalf("Failed to open session store: %v", err) + } + authService := auth.New(ownerStore, tokenService, sessionStore) + if os.Getenv("DEV_MODE_STATIC_SERVICES") == "true" { // DEV MODE: Load from file, skip Docker client cache.LoadDevServices("services.json") @@ -59,10 +105,6 @@ func main() { // Provision the instance CA on first run (security theme M1). The CA is // the trust anchor for every peer edge on dcntrlized_net; services get // their identity certs from it at start/install. - dataRoot, err := libcontainers.DataRoot() - if err != nil { - log.Fatalf("Failed to resolve data root: %v", err) - } if _, err := pki.EnsureCA(dataRoot); err != nil { log.Fatalf("Failed to provision instance CA: %v", err) } @@ -94,27 +136,39 @@ func main() { } wsHub := containers.NewWebSocketHub(cache) + gate := authService.RequireSession mux := http.NewServeMux() - mux.HandleFunc("/api/containers", containers.NewListContainersHandler(cache)) - mux.HandleFunc("/api/ws", wsHub.HandleWS) - mux.HandleFunc("/api/containers/install", containers.InstallContainerHandler(cache)) - mux.HandleFunc("/api/containers/uninstall", containers.UninstallContainerHandler(cache)) - mux.HandleFunc("/api/containers/start", containers.StartContainerHandler(cache)) - mux.HandleFunc("/api/containers/stop", containers.StopContainerHandler(cache)) - mux.HandleFunc("/api/actions/{containerId}/{actionName}", actions.RunAction) - mux.HandleFunc("/api/registry", handlers.GetRegistry) + + // Owner gate endpoints (onboarding theme M1). Status/setup/login/refresh/ + // logout stay public — they are how a session is obtained; change-password + // rides behind the session gate like every other route. + mux.HandleFunc("/api/auth/status", authService.Status) + mux.HandleFunc("/api/auth/setup", authService.Setup) + mux.HandleFunc("/api/auth/login", authService.Login) + mux.HandleFunc("/api/auth/refresh", authService.Refresh) + mux.HandleFunc("/api/auth/logout", authService.Logout) + mux.Handle("/api/auth/change-password", gate(http.HandlerFunc(authService.ChangePassword))) + + mux.Handle("/api/ws", authService.GateWebSocket(http.HandlerFunc(wsHub.HandleWS))) + mux.Handle("/api/containers", gate(containers.NewListContainersHandler(cache))) + mux.Handle("/api/containers/install", gate(containers.InstallContainerHandler(cache))) + mux.Handle("/api/containers/uninstall", gate(containers.UninstallContainerHandler(cache))) + mux.Handle("/api/containers/start", gate(containers.StartContainerHandler(cache))) + mux.Handle("/api/containers/stop", gate(containers.StopContainerHandler(cache))) + mux.Handle("/api/actions/{containerId}/{actionName}", gate(http.HandlerFunc(actions.RunAction))) + mux.Handle("/api/registry", gate(http.HandlerFunc(handlers.GetRegistry))) // Trusted-services surface (security theme M2 §3): pending first-contact // authorizations and the owner's approve/revoke actions, forwarded to the // auth service's own trust store. peersHandler := peers.NewHandler() - mux.HandleFunc("/api/peers", peersHandler.List) - mux.HandleFunc("/api/peers/attestation", peersHandler.Attestation) - mux.HandleFunc("/api/peers/approve", peersHandler.Approve) - mux.HandleFunc("/api/peers/revoke", peersHandler.Revoke) - mux.HandleFunc("/api/peers/reject", peersHandler.Reject) - mux.HandleFunc("/api/peers/hide", peersHandler.Hide) + mux.Handle("/api/peers", gate(http.HandlerFunc(peersHandler.List))) + mux.Handle("/api/peers/attestation", gate(http.HandlerFunc(peersHandler.Attestation))) + mux.Handle("/api/peers/approve", gate(http.HandlerFunc(peersHandler.Approve))) + mux.Handle("/api/peers/revoke", gate(http.HandlerFunc(peersHandler.Revoke))) + mux.Handle("/api/peers/reject", gate(http.HandlerFunc(peersHandler.Reject))) + mux.Handle("/api/peers/hide", gate(http.HandlerFunc(peersHandler.Hide))) frontendDir, err := fs.Sub(embeddedFrontend, "frontend/dist") if err != nil {