Merge pull request 'Add session abstraction, mobile bearer auth, and pluggable SSO (Google/Discord/OIDC)' (#24) from feature/auth-session-abstraction into main

Reviewed-on: UOM/website#24
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-07-03 15:35:44 +00:00
46 changed files with 3169 additions and 177 deletions

View File

@@ -3,7 +3,7 @@
Public site, wiki, and protected admin panel for the **UOMysticmoon** private Ultima Online Public site, wiki, and protected admin panel for the **UOMysticmoon** private Ultima Online
shard — a full-stack app in one repo: shard — a full-stack app in one repo:
- **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, JWT-in-cookie auth. - **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO).
- **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia). - **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
- **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production. - **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production.
@@ -35,7 +35,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
| Layer | Tech | | Layer | Tech |
|---|---| |---|---|
| Backend | Node.js 20+, Express 4, `mariadb` driver (parameterized SQL, no ORM) | | Backend | Node.js 20+, Express 4, `mariadb` driver (parameterized SQL, no ORM) |
| Auth | JWT in an httpOnly cookie, bcrypt password hashing, optional TOTP 2FA (`speakeasy` + `qrcode`) | | Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
| Database | MariaDB 11 (own container) | | Database | MariaDB 11 (own container) |
| Frontend | React 18, Vite 5, React Router 6 | | Frontend | React 18, Vite 5, React Router 6 |
| Email | Nodemailer (SMTP) with a `mailto:` fallback | | Email | Nodemailer (SMTP) with a `mailto:` fallback |
@@ -51,18 +51,19 @@ UOMSITE/
│ ├─ src/ │ ├─ src/
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0) │ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
│ │ ├─ app.js middleware + static SPA + routes │ │ ├─ app.js middleware + static SPA + routes
│ │ ├─ router/v1/ auth / public / admin route groups │ │ ├─ auth/ session layer: session.service · token (JWT/cookies) · session.middleware · ssoState (PKCE/CSRF) · providers/ (base · oauth2 · google · discord · genericOidc · registry)
│ │ ├─ model/ users · posts · wiki · settings · activity (.model + .db) │ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin route groups
│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities (.model + .db)
│ │ ├─ middleware/ siteMode · noindex · rateLimit · loginProtection · botScore · validate │ │ ├─ middleware/ siteMode · noindex · rateLimit · loginProtection · botScore · validate
│ │ └─ utils/ auth (JWT/cookies/roles) · totp (2FA) · db (pool) · mailer · logger │ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger
│ ├─ db/ schema.sql + seed.js │ ├─ db/ schema.sql + seed.js
│ └─ .env.example │ └─ .env.example
├─ client/ React + Vite SPA ├─ client/ React + Vite SPA
│ ├─ src/ │ ├─ src/
│ │ ├─ routes/public/ Portal, Website, News, Screenshots, FiveOnFriday, Newsletter(+Issue), Status, About, Maintenance │ │ ├─ routes/public/ Portal, Website, News, Screenshots, FiveOnFriday, Newsletter(+Issue), Status, About, Maintenance
│ │ ├─ routes/wiki/ Wiki landing + WikiArticle │ │ ├─ routes/wiki/ Wiki landing + WikiArticle
│ │ ├─ routes/admin/ AdminLogin, AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Bot Activity, Users, Account) + editors │ │ ├─ routes/admin/ AdminLogin (password + TOTP + SSO buttons), AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Bot Activity, Authentication, Users, Account) + editors
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, … │ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, ProviderIcon (inline SSO SVGs),
│ │ ├─ contexts/ AuthContext, SiteContext │ │ ├─ contexts/ AuthContext, SiteContext
│ │ ├─ api/client.js fetch wrapper (sends cookies) │ │ ├─ api/client.js fetch wrapper (sends cookies)
│ │ └─ styles/theme.css design tokens │ │ └─ styles/theme.css design tokens
@@ -188,8 +189,9 @@ npm start # node server → serves API + SPA at http://localhost:3
| `/admin/settings` | Site settings | | `/admin/settings` | Site settings |
| `/admin/activity` | Activity log | | `/admin/activity` | Activity log |
| `/admin/bot-activity` | Bot activity — banned IPs + recent scoring events, emergency unban (admin only) | | `/admin/bot-activity` | Bot activity — banned IPs + recent scoring events, emergency unban (admin only) |
| `/admin/auth-providers` | Authentication — enable/configure SSO providers: built-in Google & Discord + custom OIDC/OAuth2 (admin only) |
| `/admin/users` | User management | | `/admin/users` | User management |
| `/admin/account` | Account security (self-service TOTP two-factor) | | `/admin/account` | Account security (self-service TOTP two-factor + linked SSO accounts) |
--- ---
@@ -197,11 +199,14 @@ npm start # node server → serves API + SPA at http://localhost:3
| Group | Base | Auth | | Group | Base | Auth |
|---|---|---| |---|---|---|
| Auth | `/api/v1/auth` (`login`, `login/totp`, `logout`, `me`) | cookie | | Auth (web) | `/api/v1/auth` (`login`, `login/totp`, `logout`, `me`) | cookie |
| Auth (mobile) | `/api/v1/auth/mobile` (`login`, `refresh`, `logout`) | bearer (access + refresh tokens) |
| SSO | `/api/v1/auth` (`providers` — public discovery; `sso/:provider/start`, `sso/:provider/link`, `sso/:provider/callback`) | redirect flow |
| Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none | | Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none |
| Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `bot-activity`, `bot-activity/unban`, `users`, `account`, `account/totp/*`) | cookie (admin) | | Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `bot-activity`, `bot-activity/unban`, `auth/providers` (CRUD), `users`, `account`, `account/totp/*`, `account/identities`) | cookie (admin) |
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`. Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the full contract. See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the full contract.
--- ---
@@ -218,10 +223,14 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
| `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev | | `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev |
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `uomysticmoon` / `uomm` / — | app database credentials | | `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `uomysticmoon` / `uomm` / — | app database credentials |
| `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) | | `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) |
| `JWT_SECRET` | — | **required** — long random string | | `JWT_SECRET` | — | **required** — long random string; signs session, mobile, and SSO-flow tokens |
| `JWT_EXPIRES_IN` | `1d` | token + cookie lifetime | | `JWT_EXPIRES_IN` | `1d` | web session token + cookie lifetime |
| `COOKIE_SECURE` | `auto` | `auto` = Secure only over HTTPS (works on LAN HTTP + Pangolin HTTPS) | | `COOKIE_SECURE` | `auto` | `auto` = Secure only over HTTPS (works on LAN HTTP + Pangolin HTTPS) |
| `COOKIE_NAME` | `uomm_token` | | | `COOKIE_NAME` | `uomm_token` | |
| `SECRET_ENC_KEY` | — | **required in prod** — key for AES-256-GCM encryption of stored OAuth client secrets. Dev falls back to a key derived from `JWT_SECRET` (with a warning) |
| `APP_BASE_URL` | — | public base URL, used to build the SSO OAuth `redirect_uri` (`${APP_BASE_URL}/api/v1/auth/sso/:provider/callback`). Set in prod to match what you register with Google/Discord; if unset it is derived from the request (fine for local dev) |
| `MOBILE_ACCESS_TTL` | `15m` | mobile bearer **access** token lifetime (short-lived) |
| `MOBILE_REFRESH_TTL_DAYS` | `30` | mobile **refresh** token lifetime (long-lived, rotated on use) |
| `TRUST_PROXY` | `1` | reverse-proxy trust for correct `req.ip` / `req.secure` (rate limiting, backoff, bot-ban). Pin to the proxy hop's LAN IP in prod. A blanket `true` is rejected (coerced to `1`) to block `X-Forwarded-For` spoofing | | `TRUST_PROXY` | `1` | reverse-proxy trust for correct `req.ip` / `req.secure` (rate limiting, backoff, bot-ban). Pin to the proxy hop's LAN IP in prod. A blanket `true` is rejected (coerced to `1`) to block `X-Forwarded-For` spoofing |
| `DEBUG_TRUST_PROXY` | `0` | `1` logs raw peer address + `X-Forwarded-For` + resolved `req.ip` per request (to verify/refresh the proxy IP). Noisy — leave off | | `DEBUG_TRUST_PROXY` | `0` | `1` logs raw peer address + `X-Forwarded-For` + resolved `req.ip` per request (to verify/refresh the proxy IP). Noisy — leave off |
| `TOTP_ISSUER` | `UOMysticmoon` | label shown in authenticator apps for optional per-user 2FA | | `TOTP_ISSUER` | `UOMysticmoon` | label shown in authenticator apps for optional per-user 2FA |
@@ -239,11 +248,36 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
**Session & authorization** **Session & authorization**
- All auth flows go through one **session service** (`server/src/auth/`): controllers call
`sessionService.createSession(user, authMethod)` and middleware calls `validateSession()`, so web
cookies, mobile bearer tokens, and SSO all produce the *same* authenticated session model.
`utils/auth.js` remains a thin backward-compat facade.
- JWT in an httpOnly, `SameSite=Lax` cookie (`Secure` auto-detected), bcrypt password hashing. - JWT in an httpOnly, `SameSite=Lax` cookie (`Secure` auto-detected), bcrypt password hashing.
- Admin routes are **re-validated against the database on every request**, so a demoted or deleted - Admin routes are **re-validated against the database on every request**, so a demoted or deleted
user loses access immediately instead of keeping their old role until the token expires. user loses access immediately instead of keeping their old role until the token expires.
- **Role-based authorization** — admin-only endpoints (users, site mode, settings) are gated by a - **Role-based authorization** — admin-only endpoints (users, site mode, settings, auth providers)
`requireRole` check, so a lower-privilege editor can't reach them. are gated by a `requireRole` check, so a lower-privilege editor can't reach them.
**Mobile bearer auth**
- Native clients use `/api/v1/auth/mobile/*`: a short-lived **access token** (bearer JWT, validated
by the same middleware as the cookie) plus a long-lived, **server-stored, revocable refresh
token** that is **rotated on every refresh** (a replayed refresh token is single-use). Refresh
tokens are stored **hashed** (never in the clear); logout revokes one or all. Mobile login reuses
the same bot-scoring + backoff defenses as web, with single-request TOTP.
**Single sign-on (OAuth2 / OIDC)**
- Pluggable providers — built-in **Google** and **Discord** (endpoints fixed in code; admins supply
only client id/secret) plus fully-configurable **custom OIDC/OAuth2** providers, managed from the
**Authentication** admin panel. Only `enabled` + fully-configured providers are shown to users.
- **Link-only** by policy: an SSO login succeeds *only* if the external identity is already linked to
an existing account (linked by the user from **Account**). External identities are **never
auto-provisioned** — no one gains access without an account you created.
- The redirect flow is CSRF-protected with a signed, httpOnly, short-lived transaction cookie plus
**PKCE**; OAuth client secrets are **encrypted at rest** (AES-256-GCM) and never returned to any
client. SSO logins go through the same `sessionService`, so login/activity logging, RBAC, and bot
protection are identical to a local login.
**Login hardening** **Login hardening**

View File

@@ -27,6 +27,7 @@ import HeroEditor from './routes/admin/views/HeroEditor.jsx'
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx' import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx' import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx' import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx' import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
@@ -73,6 +74,7 @@ export default function App() {
<Route path="settings" element={<SettingsAdmin />} /> <Route path="settings" element={<SettingsAdmin />} />
<Route path="activity" element={<ActivityAdmin />} /> <Route path="activity" element={<ActivityAdmin />} />
<Route path="bot-activity" element={<BotActivityAdmin />} /> <Route path="bot-activity" element={<BotActivityAdmin />} />
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
<Route path="users" element={<UsersAdmin />} /> <Route path="users" element={<UsersAdmin />} />
<Route path="account" element={<AccountAdmin />} /> <Route path="account" element={<AccountAdmin />} />
<Route path="*" element={<Navigate to="/admin" replace />} /> <Route path="*" element={<Navigate to="/admin" replace />} />

View File

@@ -47,6 +47,8 @@ export const api = {
loginTotp: (challenge, code) => loginTotp: (challenge, code) =>
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }), req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
logout: () => req('/auth/logout', { method: 'POST' }), logout: () => req('/auth/logout', { method: 'POST' }),
// Public SSO provider discovery — drives the login-page provider buttons.
authProviders: () => req('/auth/providers'),
// ----- public ----- // ----- public -----
publicSettings: () => req('/public/settings'), publicSettings: () => req('/public/settings'),
@@ -120,6 +122,16 @@ export const api = {
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }), totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }), totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }),
totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }), totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }),
// ----- linked SSO identities (self-service) -----
linkedIdentities: () => req('/admin/account/identities'),
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
// ----- auth providers / SSO config (admin only) -----
listAuthProviders: () => req('/admin/auth/providers'),
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
updateAuthProvider: (id, data) => req(`/admin/auth/providers/${id}`, { method: 'PUT', body: data }),
deleteAuthProvider: (id) => req(`/admin/auth/providers/${id}`, { method: 'DELETE' }),
}, },
} }

View File

@@ -0,0 +1,37 @@
// Inline SVG brand icons for SSO providers. No binary assets — these scale
// crisply at any size and keep their own brand colors. `icon` matches the
// provider `kind` from the discovery endpoint ('google' | 'discord' | oidc/oauth2).
// Anything unknown falls back to a neutral key glyph in the current text color.
function GoogleMark({ size }) {
return (
<svg width={size} height={size} viewBox="0 0 48 48" aria-hidden="true" focusable="false">
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
</svg>
)
}
function DiscordMark({ size }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="#5865F2" aria-hidden="true" focusable="false">
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
)
}
function GenericMark({ size }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
</svg>
)
}
export default function ProviderIcon({ icon, size = 18 }) {
if (icon === 'google') return <GoogleMark size={size} />
if (icon === 'discord') return <DiscordMark size={size} />
return <GenericMark size={size} />
}

View File

@@ -12,6 +12,7 @@ const NAV = [
{ to: '/admin/settings', label: 'Settings' }, { to: '/admin/settings', label: 'Settings' },
{ to: '/admin/activity', label: 'Activity' }, { to: '/admin/activity', label: 'Activity' },
{ to: '/admin/bot-activity', label: 'Bot Activity' }, { to: '/admin/bot-activity', label: 'Bot Activity' },
{ to: '/admin/auth-providers', label: 'Authentication' },
{ to: '/admin/users', label: 'Users' }, { to: '/admin/users', label: 'Users' },
{ to: '/admin/account', label: 'Account' }, { to: '/admin/account', label: 'Account' },
] ]
@@ -24,6 +25,7 @@ const TITLES = {
'/admin/settings': 'Site Settings', '/admin/settings': 'Site Settings',
'/admin/activity': 'Activity Log', '/admin/activity': 'Activity Log',
'/admin/bot-activity': 'Bot Activity', '/admin/bot-activity': 'Bot Activity',
'/admin/auth-providers': 'Authentication',
'/admin/users': 'Users', '/admin/users': 'Users',
'/admin/account': 'Account Security', '/admin/account': 'Account Security',
} }

View File

@@ -1,7 +1,18 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom' import { Link, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx' import MoonDot from '../../components/MoonDot.jsx'
import ProviderIcon from '../../components/ProviderIcon.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx' import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
// Friendly copy for the ?sso_error codes the SSO callback can redirect back with.
const SSO_ERRORS = {
not_linked: 'That account is not linked to an admin user. Sign in with your password, then link it under Account.',
denied: 'Sign-in was cancelled.',
unavailable: 'That sign-in method is not available right now.',
bad_state: 'Your sign-in session expired. Please try again.',
error: 'Could not complete sign-in. Please try again.',
}
const BG = const BG =
"linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')" "linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')"
@@ -36,11 +47,36 @@ export default function AdminLogin() {
const [challenge, setChallenge] = useState('') const [challenge, setChallenge] = useState('')
const [code, setCode] = useState('') const [code, setCode] = useState('')
// SSO providers to offer (empty if none configured) + any error the callback
// bounced us back with (?sso_error=...).
const [providers, setProviders] = useState([])
const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || ''
// Already signed in → go straight to the panel. // Already signed in → go straight to the panel.
useEffect(() => { useEffect(() => {
if (user) navigate(dest, { replace: true }) if (user) navigate(dest, { replace: true })
}, [user, dest, navigate]) }, [user, dest, navigate])
// Load enabled SSO providers for the buttons. Failure is non-fatal — the page
// still works with password login and simply shows no provider buttons.
useEffect(() => {
let active = true
api
.authProviders()
.then((list) => active && setProviders(Array.isArray(list) ? list : []))
.catch(() => active && setProviders([]))
return () => {
active = false
}
}, [])
// Full-page redirect into the provider's OAuth flow, preserving the intended
// destination so the callback can return the user there.
function startSso(provider) {
const q = dest && dest !== '/admin' ? `?returnTo=${encodeURIComponent(dest)}` : ''
window.location.assign(provider.loginUrl + q)
}
async function onSubmit(e) { async function onSubmit(e) {
e.preventDefault() e.preventDefault()
setError('') setError('')
@@ -174,9 +210,9 @@ export default function AdminLogin() {
</label> </label>
)} )}
{error && ( {(error || (stage === 'creds' && ssoError)) && (
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}> <p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center', lineHeight: 1.5 }}>
{error} {error || ssoError}
</p> </p>
)} )}
@@ -188,6 +224,45 @@ export default function AdminLogin() {
> >
{busy ? 'Signing in…' : stage === 'totp' ? 'Verify' : 'Sign in'} {busy ? 'Signing in…' : stage === 'totp' ? 'Verify' : 'Sign in'}
</button> </button>
{/* SSO providers — only on the credentials step, only if any are enabled. */}
{stage === 'creds' && providers.length > 0 && (
<div style={{ marginTop: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '0 0 16px', color: 'var(--dim)' }}>
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
<span className="sans" style={{ fontSize: '0.72rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>or</span>
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{providers.map((p) => (
<button
key={p.id}
type="button"
onClick={() => startSso(p)}
className="btn"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
width: '100%',
borderRadius: 8,
padding: 11,
border: '1px solid var(--line)',
background: 'rgba(255,255,255,0.04)',
color: 'var(--ink)',
}}
>
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
<ProviderIcon icon={p.icon} size={18} />
</span>
Continue with {p.name}
</button>
))}
</div>
</div>
)}
<p className="sans" style={{ margin: '16px 0 0', textAlign: 'center', color: 'var(--dim)', fontSize: '0.76rem' }}> <p className="sans" style={{ margin: '16px 0 0', textAlign: 'center', color: 'var(--dim)', fontSize: '0.76rem' }}>
Protected area not indexed. Sessions expire after 1 day. Protected area not indexed. Sessions expire after 1 day.
</p> </p>

View File

@@ -1,7 +1,121 @@
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx' import { Loading, ErrorState } from '../../../components/PageState.jsx'
import ProviderIcon from '../../../components/ProviderIcon.jsx'
import { api } from '../../../api/client.js' import { api } from '../../../api/client.js'
// Link/unlink external SSO identities to this account. Linking redirects through
// the provider's OAuth flow (/auth/sso/:id/link) and returns here with ?linked
// or ?link_error. Only providers that are enabled + valid can be linked.
function LinkedAccounts() {
const [linked, setLinked] = useState(null)
const [available, setAvailable] = useState([])
const [error, setError] = useState('')
const banner = (() => {
const q = new URLSearchParams(window.location.search)
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
return null
})()
const load = useCallback(async () => {
try {
const [ids, avail] = await Promise.all([
api.admin.linkedIdentities(),
api.authProviders().catch(() => []),
])
setLinked(ids)
setAvailable(Array.isArray(avail) ? avail : [])
} catch {
setError('Could not load linked accounts.')
}
}, [])
useEffect(() => {
load()
}, [load])
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
async function unlink(provider) {
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
try {
await api.admin.unlinkIdentity(provider)
await load()
} catch (err) {
setError(err.message || 'Could not unlink.')
}
}
if (error) return <ErrorState message={error} />
if (!linked) return null
const linkedIds = new Set(linked.map((i) => i.provider))
const linkable = available.filter((p) => !linkedIds.has(p.id))
return (
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
Linked accounts
</h2>
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
Link a Google, Discord, or other SSO account so you can sign in with it. SSO can only sign in
to an account it is linked to linking here is what grants that access.
</p>
{banner && (
<p className="sans" style={{ color: banner.ok ? '#7fd0a4' : '#d98b84', fontSize: '0.86rem' }}>
{banner.text}
</p>
)}
{linked.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
{linked.map((i) => (
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<span style={{ display: 'inline-flex', width: 20, height: 20 }}>
<ProviderIcon icon={iconFor(i.provider)} size={20} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
</div>
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Unlink
</button>
</div>
))}
</div>
)}
{linkable.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 6 }}>
{linkable.map((p) => (
<button
key={p.id}
onClick={() => window.location.assign(`/api/v1/auth/sso/${p.id}/link`)}
className="btn"
style={{ display: 'flex', alignItems: 'center', gap: 10, justifyContent: 'center', width: '100%', maxWidth: 320, borderRadius: 8, padding: 10, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.04)', color: 'var(--ink)' }}
>
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
<ProviderIcon icon={p.icon} size={18} />
</span>
Link {p.name}
</button>
))}
</div>
)}
{linked.length === 0 && linkable.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.86rem' }}>
No SSO providers are enabled. Configure them under <strong>Authentication</strong>.
</p>
)}
</div>
)
}
// Self-service account security: enable / disable optional TOTP two-factor. // Self-service account security: enable / disable optional TOTP two-factor.
export default function AccountAdmin() { export default function AccountAdmin() {
const [account, setAccount] = useState(null) const [account, setAccount] = useState(null)
@@ -187,6 +301,8 @@ export default function AccountAdmin() {
{msg && <p className="sans" style={{ marginTop: 16, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>} {msg && <p className="sans" style={{ marginTop: 16, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
{error && <p className="sans" style={{ marginTop: 16, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>} {error && <p className="sans" style={{ marginTop: 16, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
<LinkedAccounts />
</section> </section>
) )
} }

View File

@@ -0,0 +1,380 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import ProviderIcon from '../../../components/ProviderIcon.jsx'
import { api } from '../../../api/client.js'
// Admin config for authentication providers. Local password + TOTP is always on
// (informational tab). Google/Discord are built-ins with a fixed config surface
// (Enabled + Client ID + Client Secret). Custom providers use the full OIDC editor.
const TABS = [
{ id: 'local', label: 'Local Accounts' },
{ id: 'google', label: 'Google' },
{ id: 'discord', label: 'Discord' },
{ id: 'custom', label: 'Custom Providers' },
]
// The redirect/callback URL to register with the provider. Mirrors the server's
// redirect_uri (APP_BASE_URL + this path); shown so admins can copy it exactly.
function callbackUrl(id) {
return `${window.location.origin}/api/v1/auth/sso/${id}/callback`
}
function HealthWarning({ provider }) {
if (!provider || !provider.enabled || provider.health.valid) return null
return (
<p className="sans" style={{ margin: '4px 0 0', color: '#e0b070', fontSize: '0.82rem', lineHeight: 1.5 }}>
Enabled but incomplete (missing: {provider.health.missing.join(', ')}). Hidden from the login
page until fully configured.
</p>
)
}
function CallbackHint({ id }) {
return (
<div style={{ marginTop: 4 }}>
<span className="field-label">Redirect / callback URL (register this with the provider)</span>
<code
className="sans"
style={{ display: 'block', padding: '9px 12px', borderRadius: 8, border: '1px solid var(--line)', background: 'var(--bg-deep)', color: 'var(--muted)', fontSize: '0.82rem', wordBreak: 'break-all' }}
>
{callbackUrl(id)}
</code>
</div>
)
}
function Toggle({ checked, onChange, label }) {
return (
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
{label}
</label>
)
}
// ── Built-in (Google / Discord) config form ────────────────────────────────
function BuiltinForm({ provider, onSaved }) {
const [enabled, setEnabled] = useState(provider.enabled)
const [clientId, setClientId] = useState(provider.clientId || '')
const [secret, setSecret] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
// Re-sync when switching between provider tabs.
useEffect(() => {
setEnabled(provider.enabled)
setClientId(provider.clientId || '')
setSecret('')
setMsg('')
setError('')
}, [provider.id]) // eslint-disable-line react-hooks/exhaustive-deps
async function save() {
setBusy(true)
setMsg('')
setError('')
try {
const body = { enabled, clientId }
if (secret) body.secret = secret // only send a new secret when entered
await api.admin.updateAuthProvider(provider.id, body)
setSecret('')
setMsg('Saved.')
await onSaved()
} catch (err) {
setError(err.message || 'Could not save.')
} finally {
setBusy(false)
}
}
return (
<div style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ display: 'inline-flex', width: 26, height: 26 }}>
<ProviderIcon icon={provider.kind} size={26} />
</span>
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
{provider.name}
</h2>
</div>
<Toggle checked={enabled} onChange={setEnabled} label="Enable this sign-in method" />
<HealthWarning provider={provider} />
<label style={{ display: 'block' }}>
<span className="field-label">Client ID</span>
<input type="text" value={clientId} onChange={(e) => setClientId(e.target.value)} className="input" autoComplete="off" />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Client Secret</span>
<input
type="password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
className="input"
autoComplete="new-password"
placeholder={provider.hasSecret ? '•••••••• configured — leave blank to keep' : 'Client secret'}
/>
</label>
<CallbackHint id={provider.id} />
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save changes'}
</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
</div>
)
}
// ── Local accounts (informational) ─────────────────────────────────────────
function LocalInfo() {
return (
<div style={{ maxWidth: 560 }}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
Local accounts
</h2>
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
Username &amp; password sign-in (with optional TOTP two-factor) is always enabled and cannot
be turned off it is how you manage accounts and link SSO identities. Manage users under
<strong> Users</strong>, and your own two-factor under <strong>Account</strong>.
</p>
</div>
)
}
// ── Custom OIDC/OAuth2 providers ────────────────────────────────────────────
const EMPTY_CUSTOM = {
id: '', name: '', kind: 'oidc', enabled: false, clientId: '', secret: '',
authorizeUrl: '', tokenUrl: '', userinfoUrl: '', scopes: 'openid email profile', priority: 100,
}
function CustomEditor({ initial, onDone, onCancel }) {
const isNew = !initial.id
const [f, setF] = useState(isNew ? EMPTY_CUSTOM : { ...initial, secret: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const set = (k) => (e) => setF((prev) => ({ ...prev, [k]: e.target.value }))
async function save() {
setBusy(true)
setError('')
try {
const body = {
name: f.name, kind: f.kind, enabled: f.enabled, clientId: f.clientId,
authorizeUrl: f.authorizeUrl, tokenUrl: f.tokenUrl, userinfoUrl: f.userinfoUrl,
scopes: f.scopes, priority: Number(f.priority) || 100,
}
if (f.secret) body.secret = f.secret
if (isNew) await api.admin.createAuthProvider({ id: f.id, ...body })
else await api.admin.updateAuthProvider(initial.id, body)
await onDone()
} catch (err) {
setError(err.message || 'Could not save provider.')
} finally {
setBusy(false)
}
}
return (
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 20, marginTop: 16, display: 'flex', flexDirection: 'column', gap: 14, maxWidth: 640 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>
{isNew ? 'Add custom provider' : `Edit ${initial.name}`}
</h3>
{isNew && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<label>
<span className="field-label">ID (slug)</span>
<input className="input" value={f.id} onChange={set('id')} placeholder="authentik" />
</label>
<label>
<span className="field-label">Type</span>
<select className="input" value={f.kind} onChange={set('kind')}>
<option value="oidc">OIDC</option>
<option value="oauth2">OAuth2</option>
</select>
</label>
</div>
)}
<label>
<span className="field-label">Display name</span>
<input className="input" value={f.name} onChange={set('name')} placeholder="Authentik" />
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<label>
<span className="field-label">Client ID</span>
<input className="input" value={f.clientId} onChange={set('clientId')} autoComplete="off" />
</label>
<label>
<span className="field-label">Client Secret</span>
<input className="input" type="password" value={f.secret} onChange={set('secret')} autoComplete="new-password" placeholder={!isNew && initial.hasSecret ? '•••• leave blank to keep' : ''} />
</label>
</div>
<label>
<span className="field-label">Authorization URL</span>
<input className="input" value={f.authorizeUrl} onChange={set('authorizeUrl')} placeholder="https://idp.example/application/o/authorize/" />
</label>
<label>
<span className="field-label">Token URL</span>
<input className="input" value={f.tokenUrl} onChange={set('tokenUrl')} placeholder="https://idp.example/application/o/token/" />
</label>
<label>
<span className="field-label">UserInfo URL</span>
<input className="input" value={f.userinfoUrl} onChange={set('userinfoUrl')} placeholder="https://idp.example/application/o/userinfo/" />
</label>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12 }}>
<label>
<span className="field-label">Scopes</span>
<input className="input" value={f.scopes} onChange={set('scopes')} />
</label>
<label>
<span className="field-label">Priority</span>
<input className="input" type="number" value={f.priority} onChange={set('priority')} />
</label>
</div>
<Toggle checked={f.enabled} onChange={(v) => setF((p) => ({ ...p, enabled: v }))} label="Enabled" />
{!isNew && <CallbackHint id={initial.id} />}
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save provider'}
</button>
<button onClick={onCancel} disabled={busy} className="pill">Cancel</button>
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
</div>
)
}
function CustomProviders({ items, onChanged }) {
const [editing, setEditing] = useState(null) // null | 'new' | provider
async function del(p) {
if (!window.confirm(`Delete provider "${p.name}"? This cannot be undone.`)) return
await api.admin.deleteAuthProvider(p.id)
await onChanged()
}
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 12, flexWrap: 'wrap' }}>
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
OAuth2 / OIDC providers (Authentik, Keycloak, Okta, Azure AD, Zitadel, )
</p>
{!editing && (
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">+ Add provider</button>
)}
</div>
{items.length === 0 && !editing && (
<p className="sans dim" style={{ fontSize: '0.88rem' }}>No custom providers yet.</p>
)}
{items.length > 0 && (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Name</th>
<th className="adm-th">Type</th>
<th className="adm-th">Status</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{items.map((p) => (
<tr key={p.id}>
<td className="adm-td" style={{ color: 'var(--head)' }}>{p.name}</td>
<td className="adm-td dim">{p.kind}</td>
<td className="adm-td">
{p.enabled && p.health.valid ? (
<span className="sans" style={{ color: '#7fd0a4' }}>Live</span>
) : p.enabled ? (
<span className="sans" style={{ color: '#e0b070' }}>Incomplete</span>
) : (
<span className="sans dim">Disabled</span>
)}
</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
<span className="link-accent" onClick={() => setEditing(p)}>Edit</span>
<span className="link-accent" onClick={() => del(p)} style={{ marginLeft: 14, color: '#d98b84' }}>Delete</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{editing && (
<CustomEditor
initial={editing === 'new' ? {} : editing}
onCancel={() => setEditing(null)}
onDone={async () => {
setEditing(null)
await onChanged()
}}
/>
)}
</div>
)
}
export default function AuthProvidersAdmin() {
const [providers, setProviders] = useState(null)
const [error, setError] = useState('')
const [tab, setTab] = useState('local')
const load = useCallback(async () => {
try {
setProviders(await api.admin.listAuthProviders())
} catch {
setError('Could not load authentication providers.')
}
}, [])
useEffect(() => {
load()
}, [load])
if (error) return <ErrorState message={error} />
if (!providers) return <Loading />
const byId = (id) => providers.find((p) => p.id === id)
const customs = providers.filter((p) => !p.builtin)
return (
<section>
<div style={{ display: 'flex', gap: 6, borderBottom: '1px solid var(--line-soft)', marginBottom: 24, flexWrap: 'wrap' }}>
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className="sans"
style={{
padding: '9px 16px',
border: 'none',
background: 'transparent',
cursor: 'pointer',
fontSize: '0.9rem',
color: tab === t.id ? 'var(--head)' : 'var(--muted)',
borderBottom: `2px solid ${tab === t.id ? 'var(--accent)' : 'transparent'}`,
marginBottom: -1,
}}
>
{t.label}
</button>
))}
</div>
{tab === 'local' && <LocalInfo />}
{tab === 'google' && <BuiltinForm provider={byId('google')} onSaved={load} />}
{tab === 'discord' && <BuiltinForm provider={byId('discord')} onSaved={load} />}
{tab === 'custom' && <CustomProviders items={customs} onChanged={load} />}
</section>
)
}

View File

@@ -23,6 +23,21 @@ JWT_EXPIRES_IN=1d
COOKIE_SECURE=auto COOKIE_SECURE=auto
COOKIE_NAME=uomm_token COOKIE_NAME=uomm_token
# Encryption key for secrets stored at rest (OAuth client secrets in auth_providers).
# Any string — hashed to a 256-bit AES-GCM key. REQUIRED in production; in dev an
# insecure key is derived from JWT_SECRET if unset (with a warning).
SECRET_ENC_KEY=dev-only-change-me-too
# Public base URL of this app, used to build the OAuth redirect_uri
# (${APP_BASE_URL}/api/v1/auth/sso/:provider/callback). Set this in production so
# the callback URL matches what you register with Google/Discord. If unset, it is
# derived from the incoming request (fine for local dev).
APP_BASE_URL=http://localhost:5173
# Short-lived mobile access token lifetime + refresh token lifetime (Part 2).
MOBILE_ACCESS_TTL=15m
MOBILE_REFRESH_TTL_DAYS=30
# Reverse-proxy trust. Request path: client -> Pangolin -> newt agent "ptero" # Reverse-proxy trust. Request path: client -> Pangolin -> newt agent "ptero"
# (separate VM) -> this app. ptero is the hop that connects to us, so pin # (separate VM) -> this app. ptero is the hop that connects to us, so pin
# TRUST_PROXY to ptero's LAN IP: Express then honours X-Forwarded-For ONLY on # TRUST_PROXY to ptero's LAN IP: Express then honours X-Forwarded-For ONLY on

View File

@@ -119,6 +119,63 @@ CREATE TABLE IF NOT EXISTS activity_log (
INDEX idx_activity_created (created_at) INDEX idx_activity_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Pluggable SSO / OAuth2 provider configuration. Rows exist for the built-in
-- providers ('google', 'discord') once an admin configures them, plus any custom
-- OIDC/OAuth2 providers (id = a slug). Client secrets are stored ENCRYPTED
-- (client_secret_enc) and are never returned to a client. Built-in providers
-- hardcode their endpoint URLs in code; the *_url columns are used only by
-- custom (oidc/oauth2) providers.
CREATE TABLE IF NOT EXISTS auth_providers (
id VARCHAR(64) PRIMARY KEY, -- 'google' | 'discord' | custom slug
kind ENUM('google','discord','oidc','oauth2') NOT NULL,
name VARCHAR(80) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 0,
client_id VARCHAR(255) NULL,
client_secret_enc TEXT NULL, -- AES-256-GCM ciphertext, never exposed
authorize_url VARCHAR(500) NULL, -- custom providers only
token_url VARCHAR(500) NULL,
userinfo_url VARCHAR(500) NULL,
scopes VARCHAR(500) NULL,
priority INT NOT NULL DEFAULT 100,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Account linking: maps an external SSO identity to an internal user. A login via
-- SSO succeeds only if a matching (provider, subject) row exists (link-only —
-- external identities are never auto-provisioned into accounts). UNIQUE(provider,
-- subject) guarantees one external identity maps to exactly one internal user.
CREATE TABLE IF NOT EXISTS user_identities (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
provider VARCHAR(64) NOT NULL, -- matches auth_providers.id
subject VARCHAR(191) NOT NULL, -- external stable user id (sub / discord id)
email VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_identity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uq_identity_provider_subject (provider, subject),
INDEX idx_identity_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Long-lived, revocable refresh tokens for mobile (Android) bearer-token auth.
-- The opaque refresh token is never stored in the clear — only its sha256 hash —
-- so a DB read does not leak usable tokens. Rows are rotated on every refresh
-- (old row revoked, new row inserted) and revoked on logout. Web cookie sessions
-- do NOT use this table; it is purely for the mobile bearer flow.
CREATE TABLE IF NOT EXISTS mobile_refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque refresh token
device_hash VARCHAR(32) NULL, -- from sessionService.sessionMeta (best-effort)
user_agent VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
revoked_at DATETIME NULL,
CONSTRAINT fk_mrt_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_mrt_user (user_id),
INDEX idx_mrt_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations for databases created before the wiki upgrade. Each statement uses -- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get -- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here. -- these columns from the CREATE TABLE above; existing installs get them here.

View File

@@ -0,0 +1,65 @@
// ── Auth provider contract (base) ──────────────────────────────────────────
//
// The abstract interface every auth provider implements. Concrete providers:
// - local → username/password (LocalProvider, unchanged live flow)
// - google, discord, generic OIDC → OAuth2Provider subclasses
//
// A provider config (a row from auth_providers, or a built-in default) looks like:
// { id, kind, name, enabled, clientId, clientSecret,
// authorizeUrl, tokenUrl, userinfoUrl, scopes, priority }
//
// Interface (per the Part 3 spec). OAuth providers implement the SSO-flow methods;
// LocalProvider implements authenticate(). Anything not applicable stays a throw.
class BaseProvider {
constructor(config = {}) {
this.config = config
this.id = config.id || config.kind || 'base'
this.name = config.name || this.id
this.kind = config.kind || 'base'
this.type = this.kind // legacy alias
}
isEnabled() {
return Boolean(this.config.enabled)
}
// Direct-credential auth (local providers). Resolve to an internal user or null.
// eslint-disable-next-line no-unused-vars
async authenticate(credentials) {
throw new Error(`authenticate() not implemented for provider '${this.id}'`)
}
// Begin an SSO redirect flow: the provider's authorization URL.
// eslint-disable-next-line no-unused-vars
getAuthorizationUrl(state, options) {
throw new Error(`getAuthorizationUrl() not implemented for provider '${this.id}'`)
}
// Complete an SSO redirect flow: exchange the callback code for a normalized
// user profile ({ subject, email, name }).
// eslint-disable-next-line no-unused-vars
async handleCallback(params) {
throw new Error(`handleCallback() not implemented for provider '${this.id}'`)
}
// Fetch the raw external profile using an access token.
// eslint-disable-next-line no-unused-vars
async getUserProfile(accessToken) {
throw new Error(`getUserProfile() not implemented for provider '${this.id}'`)
}
// Normalize a raw external profile to { subject, email, name }.
// eslint-disable-next-line no-unused-vars
mapUser(profile) {
throw new Error(`mapUser() not implemented for provider '${this.id}'`)
}
// Link an external identity to an internal user (shared by OAuth2Provider).
// eslint-disable-next-line no-unused-vars
async linkAccount(user, profile) {
throw new Error(`linkAccount() not implemented for provider '${this.id}'`)
}
}
module.exports = BaseProvider

View File

@@ -0,0 +1,30 @@
// Built-in Discord provider (OAuth2). Endpoints hardcoded — admins configure only
// Enabled + Client ID + Client Secret. `identify` yields the stable user id;
// `email` yields the address. Discord's id is the stable per-user subject.
const OAuth2Provider = require('./oauth2.provider')
class DiscordProvider extends OAuth2Provider {
constructor(config = {}) {
super({ kind: 'discord', name: 'Discord', ...config, id: config.id || 'discord' })
}
authEndpoint() {
return 'https://discord.com/oauth2/authorize'
}
tokenEndpoint() {
return 'https://discord.com/api/oauth2/token'
}
userinfoEndpoint() {
return 'https://discord.com/api/users/@me'
}
scopeString() {
return 'identify email'
}
normalizeProfile(p = {}) {
// global_name is the new display name; fall back to the legacy username.
return { subject: p.id, email: p.email || null, name: p.global_name || p.username || null }
}
}
module.exports = DiscordProvider

View File

@@ -0,0 +1,38 @@
// Generic, fully-configurable OAuth2 / OIDC provider for custom IdPs (Authentik,
// Keycloak, Okta, Azure AD, Zitadel, …). Unlike the built-ins, its endpoints and
// scopes come from the stored config. Profile mapping follows OIDC conventions
// with sensible fallbacks for plain OAuth2 userinfo shapes.
const OAuth2Provider = require('./oauth2.provider')
class GenericOidcProvider extends OAuth2Provider {
constructor(config = {}) {
super({ kind: config.kind || 'oidc', ...config })
this.authorizeUrl = config.authorizeUrl ?? config.authorize_url ?? null
this.tokenUrl = config.tokenUrl ?? config.token_url ?? null
this.userinfoUrl = config.userinfoUrl ?? config.userinfo_url ?? null
this.scopes = config.scopes || 'openid email profile'
}
authEndpoint() {
return this.authorizeUrl
}
tokenEndpoint() {
return this.tokenUrl
}
userinfoEndpoint() {
return this.userinfoUrl
}
scopeString() {
return this.scopes
}
normalizeProfile(p = {}) {
return {
subject: p.sub || p.id || p.user_id || p.uid || null,
email: p.email || null,
name: p.name || p.preferred_username || p.username || p.email || null,
}
}
}
module.exports = GenericOidcProvider

View File

@@ -0,0 +1,34 @@
// Built-in Google provider (OAuth2 / OpenID Connect). Endpoints are hardcoded —
// admins configure only Enabled + Client ID + Client Secret. Uses the OIDC
// userinfo endpoint; `sub` is Google's stable per-user id.
const OAuth2Provider = require('./oauth2.provider')
class GoogleProvider extends OAuth2Provider {
constructor(config = {}) {
super({ kind: 'google', name: 'Google', ...config, id: config.id || 'google' })
}
authEndpoint() {
return 'https://accounts.google.com/o/oauth2/v2/auth'
}
tokenEndpoint() {
return 'https://oauth2.googleapis.com/token'
}
userinfoEndpoint() {
return 'https://openidconnect.googleapis.com/v1/userinfo'
}
scopeString() {
return 'openid email profile'
}
authParams() {
// Online access (no refresh token needed for login), and let the user pick
// an account rather than silently reusing a signed-in one.
return { access_type: 'online', prompt: 'select_account' }
}
normalizeProfile(p = {}) {
return { subject: p.sub, email: p.email || null, name: p.name || p.email || null }
}
}
module.exports = GoogleProvider

View File

@@ -0,0 +1,27 @@
// ── Local (username/password) provider ─────────────────────────────────────
//
// Reference implementation of the BaseProvider contract for local credential
// auth. It delegates to the existing users model, mirroring what auth.controller
// does today — but it is NOT wired into the live login flow. The controller
// keeps its own login logic (honeypot, bot scoring, TOTP staging, backoff) so
// this refactor changes no behavior. This exists so Part 3 can treat "local" as
// just another provider alongside SSO, behind one uniform interface.
const BaseProvider = require('./base.provider')
const users = require('../../model/users/users.model')
class LocalProvider extends BaseProvider {
constructor(config = {}) {
super({ name: 'local', type: 'local', enabled: true, ...config })
}
// Verify username + password. Returns the raw user row on success, else null.
// Callers layer their own throttling/scoring on top (as auth.controller does).
async authenticate({ username, password } = {}) {
const user = await users.getRawByUsername(username)
const ok = user && (await users.validatePassword(user, password))
return ok ? user : null
}
}
module.exports = LocalProvider

View File

@@ -0,0 +1,115 @@
// ── Shared OAuth2 / OIDC provider ──────────────────────────────────────────
//
// Implements the reusable authorization-code + PKCE flow so the concrete
// providers (google, discord, generic OIDC) only supply their endpoints, scope,
// and a normalizeProfile(). Uses Node's global fetch (no new dependency).
//
// Flow:
// getAuthorizationUrl(state, { redirectUri, codeChallenge }) → redirect the browser
// handleCallback({ code, redirectUri, codeVerifier })
// → exchangeCode (POST token endpoint) → getUserProfile (GET userinfo)
// → mapUser → { subject, email, name }
const BaseProvider = require('./base.provider')
const userIdentities = require('../../model/userIdentities/userIdentities.model')
const log = require('../../utils/logger')('sso')
class OAuth2Provider extends BaseProvider {
constructor(config = {}) {
super(config)
this.clientId = config.clientId ?? config.client_id ?? null
this.clientSecret = config.clientSecret ?? config.client_secret ?? null
}
// ── Subclass hooks (endpoints / scope / profile mapping) ──────────────────
authEndpoint() {
throw new Error(`authEndpoint() not set for provider '${this.id}'`)
}
tokenEndpoint() {
throw new Error(`tokenEndpoint() not set for provider '${this.id}'`)
}
userinfoEndpoint() {
throw new Error(`userinfoEndpoint() not set for provider '${this.id}'`)
}
scopeString() {
return 'openid email profile'
}
// Extra provider-specific authorize-URL params (e.g. Google's prompt).
authParams() {
return {}
}
// Map a raw profile → { subject, email, name }. Subclasses must implement.
normalizeProfile(profile) {
throw new Error(`normalizeProfile() not implemented for provider '${this.id}'`)
}
// ── Flow ──────────────────────────────────────────────────────────────────
getAuthorizationUrl(state, { redirectUri, codeChallenge } = {}) {
const params = new URLSearchParams({
client_id: this.clientId || '',
redirect_uri: redirectUri,
response_type: 'code',
scope: this.scopeString(),
state,
})
if (codeChallenge) {
params.set('code_challenge', codeChallenge)
params.set('code_challenge_method', 'S256')
}
for (const [k, v] of Object.entries(this.authParams())) params.set(k, v)
return `${this.authEndpoint()}?${params.toString()}`
}
async handleCallback({ code, redirectUri, codeVerifier } = {}) {
const tokenSet = await this.exchangeCode({ code, redirectUri, codeVerifier })
const profile = await this.getUserProfile(tokenSet.access_token)
return this.mapUser(profile)
}
async exchangeCode({ code, redirectUri, codeVerifier }) {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: this.clientId || '',
client_secret: this.clientSecret || '',
})
if (codeVerifier) body.set('code_verifier', codeVerifier)
const res = await fetch(this.tokenEndpoint(), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body,
})
if (!res.ok) {
const detail = await res.text().catch(() => '')
log.warn('token exchange failed', { provider: this.id, status: res.status })
throw new Error(`token exchange failed (${res.status}): ${detail.slice(0, 200)}`)
}
return res.json()
}
async getUserProfile(accessToken) {
const res = await fetch(this.userinfoEndpoint(), {
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
})
if (!res.ok) {
log.warn('userinfo fetch failed', { provider: this.id, status: res.status })
throw new Error(`userinfo failed (${res.status})`)
}
return res.json()
}
mapUser(profile) {
const mapped = this.normalizeProfile(profile)
if (!mapped || !mapped.subject) throw new Error(`provider '${this.id}' returned no subject`)
return mapped
}
// Persist the external → internal user link. Shared by every OAuth provider.
async linkAccount(user, profile) {
const p = this.mapUser(profile)
return userIdentities.link({ userId: user.id, provider: this.id, subject: p.subject, email: p.email })
}
}
module.exports = OAuth2Provider

View File

@@ -0,0 +1,128 @@
// ── Provider registry ──────────────────────────────────────────────────────
//
// Turns stored auth_providers rows into live provider instances, and owns the
// "which providers are usable" health logic. The authentication layer talks to
// the registry, never to a specific provider class, so adding a provider is just
// a new entry in KINDS.
//
// Built-ins (google, discord) always "exist" as defaults even before an admin
// creates a row, so the admin UI can render their config form. A provider is only
// shown to end users (login page) when it is enabled AND its config validates.
const GoogleProvider = require('./google.provider')
const DiscordProvider = require('./discord.provider')
const GenericOidcProvider = require('./genericOidc.provider')
const authProviders = require('../../model/authProviders/authProviders.model')
// kind → provider class.
const KINDS = {
google: GoogleProvider,
discord: DiscordProvider,
oidc: GenericOidcProvider,
oauth2: GenericOidcProvider,
}
// Built-in providers and their fixed display metadata. Endpoints are in the
// provider classes; only enabled/clientId/secret are admin-configurable.
const BUILTINS = [
{ id: 'google', kind: 'google', name: 'Google', priority: 1 },
{ id: 'discord', kind: 'discord', name: 'Discord', priority: 2 },
]
const BUILTIN_IDS = new Set(BUILTINS.map((b) => b.id))
function isBuiltin(id) {
return BUILTIN_IDS.has(id)
}
// Instantiate a provider from a config row (secret already decrypted by the
// model as `client_secret`). Returns null for an unknown kind.
function instantiate(row) {
const Klass = KINDS[row.kind]
if (!Klass) return null
return new Klass({
id: row.id,
kind: row.kind,
name: row.name,
enabled: row.enabled,
clientId: row.client_id,
clientSecret: row.client_secret, // present only via getWithSecret
authorizeUrl: row.authorize_url,
tokenUrl: row.token_url,
userinfoUrl: row.userinfo_url,
scopes: row.scopes,
priority: row.priority,
})
}
// Load a ready-to-use provider instance (secret decrypted) by id, or null.
async function load(id) {
const row = await authProviders.getWithSecret(id)
if (!row) return null
return instantiate(row)
}
// Validate a config row's completeness. Built-ins need client_id + a secret;
// custom (oidc/oauth2) also need the three endpoint URLs. Returns { valid, missing }.
function validateConfig(row) {
const missing = []
if (!row.client_id) missing.push('client_id')
// A stored secret shows up as client_secret_enc on plain rows, or client_secret
// on decrypted rows — accept either as "has a secret".
if (!row.client_secret_enc && !row.client_secret) missing.push('client_secret')
if (row.kind === 'oidc' || row.kind === 'oauth2') {
if (!row.authorize_url) missing.push('authorize_url')
if (!row.token_url) missing.push('token_url')
if (!row.userinfo_url) missing.push('userinfo_url')
}
return { valid: missing.length === 0, missing }
}
// All configured rows merged with built-in defaults (so google/discord always
// appear for the admin UI even with no row yet). Each entry carries health.
async function listConfigured() {
const rows = await authProviders.list()
const byId = new Map(rows.map((r) => [r.id, r]))
const out = []
// Built-ins first, in their fixed order.
for (const b of BUILTINS) {
const row = byId.get(b.id) || {
id: b.id, kind: b.kind, name: b.name, enabled: 0,
client_id: null, client_secret_enc: null, priority: b.priority,
}
byId.delete(b.id)
out.push({ ...row, builtin: true, health: validateConfig(row) })
}
// Then any custom providers.
for (const row of byId.values()) {
out.push({ ...row, builtin: false, health: validateConfig(row) })
}
return out
}
// Providers that should appear to end users: enabled AND valid. Shaped for the
// public discovery endpoint and sorted by priority.
async function listEnabledValid() {
const configured = await listConfigured()
return configured
.filter((p) => p.enabled && validateConfig(p).valid)
.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100))
.map((p) => ({
id: p.id,
name: p.name,
icon: p.kind, // 'google' | 'discord' | 'oidc' | 'oauth2'
loginUrl: `/api/v1/auth/sso/${p.id}/start`,
priority: p.priority ?? 100,
}))
}
module.exports = {
KINDS,
BUILTINS,
isBuiltin,
instantiate,
load,
validateConfig,
listConfigured,
listEnabledValid,
}

View File

@@ -0,0 +1,65 @@
// ── Session middleware ─────────────────────────────────────────────────────
//
// Express middleware built on the session service. Three pieces:
//
// attachSession — best-effort: decorate the request with session info if a
// valid token is present, but never reject. For routes that
// behave differently for anon vs authed callers.
// requireAuth — the gate for protected routes. Preserves the exact behavior
// of the old isLoggedIn: re-validate the user against the DB on
// every request so a demoted/deleted user loses access
// immediately, and set req.user to the fresh DB row.
// requireRole — role gate factory, unchanged from the original.
const sessionService = require('./session.service')
const users = require('../model/users/users.model')
const log = require('../utils/logger')('session')
// Best-effort: if the request carries a valid session token, attach the decoded
// session (no DB hit), its auth method, and request metadata. Never rejects —
// anonymous requests simply pass through with req.session undefined.
function attachSession(req, res, next) {
const session = sessionService.validateSession(req)
if (session) {
req.session = session
req.authMethod = session.authMethod
req.sessionMeta = sessionService.sessionMeta(req)
}
return next()
}
// Gate middleware for protected (admin) routes. Re-validates the token against
// the database on every request so a demoted or deleted user loses access
// immediately, instead of keeping their old role (or a working session) until
// the JWT expires. req.user carries the fresh DB row, not the token payload.
async function requireAuth(req, res, next) {
const session = sessionService.validateSession(req)
if (!session) return res.status(401).json({ message: 'Unauthorized' })
try {
const user = await users.getById(session.userId)
if (!user) return res.status(401).json({ message: 'Unauthorized' }) // deleted since token issued
req.user = user
req.session = session
req.authMethod = session.authMethod
return next()
} catch (err) {
log.error('requireAuth', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Gate middleware factory: allow only the listed roles. Assumes requireAuth ran
// first so req.user is populated. Use for admin-only endpoints (users, site
// mode, settings) so a lower-privilege editor cannot reach them.
function requireRole(...roles) {
return (req, res, next) => {
if (roles.includes(req.user?.role)) return next()
return res.status(403).json({ message: 'Forbidden' })
}
}
module.exports = {
attachSession,
requireAuth,
requireRole,
}

View File

@@ -0,0 +1,218 @@
// ── Session service ────────────────────────────────────────────────────────
//
// The single seam every caller goes through to issue and validate a session.
// Today a "session" is a signed JWT (cookie for web, or a Bearer token), but
// callers only ever see the abstract Session object below — never the raw token
// shape. That indirection is what lets Part 2 (mobile bearer tokens) and Part 3
// (SSO) add new `authMethod`s without touching controllers or middleware.
//
// A Session object:
// {
// sessionId, // stable id for this session (JWT jti)
// userId, // the user's DB id
// username,
// role,
// authMethod, // 'local' | 'totp' | 'mobile' | 'sso'
// createdAt, // ms epoch the token was issued (JWT iat)
// lastSeenAt, // ms epoch this session was last validated
// }
//
// NOTE: revocation/invalidation are stubs. JWTs are stateless, so there is no
// server-side session store yet — these are documented hook points for a future
// store (e.g. a denylist of jti, or mobile refresh-token records).
const crypto = require('crypto')
const token = require('./token')
const log = require('../utils/logger')('session')
// Valid authentication methods. 'local'/'totp' are the web flows; 'mobile' is the
// bearer flow (Part 2); 'google'/'discord'/'oidc' are SSO providers and 'sso' is
// the generic fallback label (Part 3). Sessions are tagged by how they were
// authenticated without changing this module per provider.
const AUTH_METHODS = ['local', 'totp', 'mobile', 'google', 'discord', 'oidc', 'sso']
// Build a Session object from a decoded JWT payload. Returns null for anything
// that is not a full session (e.g. a stage-tagged TOTP challenge token).
function sessionFromDecoded(decoded, now = Date.now()) {
if (!decoded || decoded.stage) return null
return {
sessionId: decoded.jti || null,
userId: decoded.id,
username: decoded.username,
role: decoded.role,
authMethod: decoded.authMethod || 'local',
createdAt: decoded.iat ? decoded.iat * 1000 : null,
lastSeenAt: now,
}
}
// Issue a real session for a fully-authenticated user. Signs a JWT carrying the
// identity claims plus authMethod + a fresh session id (jti), and returns both
// the raw token (the caller sets the cookie or returns it as a bearer token)
// and the decoded Session object. Does NOT touch cookies or the DB — issuing the
// cookie and recording the login stay in the controller so its bot-scoring /
// backoff / activity-log orchestration is unchanged.
function createSession(user, authMethod = 'local') {
const method = AUTH_METHODS.includes(authMethod) ? authMethod : 'local'
const sessionId = crypto.randomUUID()
const raw = token.signToken(user, { authMethod: method, jti: sessionId })
const session = sessionFromDecoded(token.verifyToken(raw))
log.info('session created', { userId: user.id, username: user.username, authMethod: method, sessionId })
return { token: raw, session }
}
// Issue the short-lived "password verified, awaiting TOTP" challenge. This is
// deliberately NOT a session — validateSession rejects it — so a half-completed
// login can never be presented as a full one.
function createPartialSession(user) {
log.info('partial (TOTP) session issued', { userId: user.id, username: user.username })
return token.signTotpChallenge(user)
}
// Complete the TOTP step: verify the challenge token and return the decoded
// identity ({ id, stage }) so the caller can load the user and createSession().
// Returns null for an expired/invalid/non-challenge token.
function upgradeSessionAfterTotp(challengeToken) {
const decoded = token.verifyTotpChallenge(challengeToken)
if (!decoded) {
log.warn('TOTP challenge rejected (expired or invalid)')
return null
}
return decoded
}
// Validate the session on an incoming request WITHOUT hitting the DB — pure
// token verification + identity decode. Returns a Session object or null.
// Stage-tagged tokens (the TOTP challenge) are explicitly not sessions.
// DB re-validation of the user is a middleware concern (requireAuth), kept
// separate so a demoted/deleted user still loses access on the next request.
function validateSession(req, now = Date.now()) {
const raw = token.extractToken(req)
if (!raw) return null
return sessionFromDecoded(token.verifyToken(raw), now)
}
// Decode a raw token string into a Session object (or null). Used where the
// token is already in hand rather than on a request.
function decodeIdentity(rawToken, now = Date.now()) {
if (!rawToken) return null
return sessionFromDecoded(token.verifyToken(rawToken), now)
}
// ── Mobile (bearer) sessions ───────────────────────────────────────────────
// Native clients get a short-lived JWT access token (validated on every request
// exactly like a cookie session) plus a long-lived opaque refresh token. The
// refresh token is random and never a JWT: it is stored server-side by hash and
// is the only revocable half, which is what makes mobile logout meaningful.
//
// These functions are intentionally pure — they mint and hash but do NOT touch
// the database. The controller persists the returned refreshHash via the
// mobileSessions model, keeping this module DB-free and unit-testable.
const MOBILE_ACCESS_TTL = process.env.MOBILE_ACCESS_TTL || '15m'
const MOBILE_REFRESH_TTL_DAYS = Number(process.env.MOBILE_REFRESH_TTL_DAYS) || 30
// Hash a raw refresh token to the value stored in the DB. Exported so the
// controller and model agree on the exact representation.
function hashRefreshToken(raw) {
return crypto.createHash('sha256').update(String(raw)).digest('hex')
}
// Mint a fresh access + refresh pair for a user. `now` is injectable for tests.
function mintMobileTokens(user, meta = {}, now = Date.now()) {
const sessionId = crypto.randomUUID()
const accessToken = token.signToken(
user,
{ authMethod: 'mobile', jti: sessionId },
{ expiresIn: MOBILE_ACCESS_TTL },
)
// 256 bits of entropy, url-safe. Opaque — carries no claims.
const refreshToken = crypto.randomBytes(32).toString('base64url')
const refreshExpiresAt = new Date(now + MOBILE_REFRESH_TTL_DAYS * 24 * 60 * 60 * 1000)
return {
accessToken,
refreshToken,
refreshHash: hashRefreshToken(refreshToken),
refreshExpiresAt,
expiresIn: MOBILE_ACCESS_TTL,
deviceHash: meta.deviceHash || null,
userAgent: meta.userAgent || null,
session: sessionFromDecoded(token.verifyToken(accessToken), now),
}
}
// Issue a mobile session at login.
function createMobileSession(user, meta = {}, now = Date.now()) {
const out = mintMobileTokens(user, meta, now)
log.info('mobile session created', { userId: user.id, username: user.username, sessionId: out.session.sessionId })
return out
}
// Rotate a mobile session on refresh — same shape as createMobileSession. The
// caller is responsible for having validated + revoked the presented refresh
// token before calling this (rotation), and for persisting the new refreshHash.
function refreshMobileSession(user, meta = {}, now = Date.now()) {
const out = mintMobileTokens(user, meta, now)
log.info('mobile session refreshed', { userId: user.id, sessionId: out.session.sessionId })
return out
}
// Validate a raw bearer access token → Session object or null. Rejects
// stage-tagged tokens (a TOTP challenge is not a bearer session).
function validateBearerToken(rawToken, now = Date.now()) {
if (!rawToken) return null
return sessionFromDecoded(token.verifyToken(rawToken), now)
}
// Optional per-session metadata derived from the request. Attached to the
// session object by middleware for logging/auditing; NOT baked into the token
// (keeps tokens small and avoids trusting client-supplied device data as a claim).
function sessionMeta(req) {
const ip = req.ip || null
const userAgent = (req.headers && req.headers['user-agent']) || null
const deviceHash = crypto
.createHash('sha256')
.update(`${userAgent || ''}|${ip || ''}`)
.digest('hex')
.slice(0, 16)
return { ip, userAgent, deviceHash }
}
// ── Revocation / invalidation (stubs) ──────────────────────────────────────
// JWTs are stateless: there is no store to revoke against yet. These are the
// hook points a future session store (jti denylist, mobile refresh records)
// will implement. They log and report success so callers can wire them in now.
function revokeSession(sessionId) {
log.info('revokeSession (stub — no session store yet)', { sessionId })
return true
}
function invalidateSession(sessionId) {
log.info('invalidateSession (stub — no session store yet)', { sessionId })
return true
}
function invalidateAllUserSessions(userId) {
log.info('invalidateAllUserSessions (stub — no session store yet)', { userId })
return true
}
module.exports = {
AUTH_METHODS,
createSession,
createPartialSession,
upgradeSessionAfterTotp,
validateSession,
decodeIdentity,
sessionMeta,
revokeSession,
invalidateSession,
invalidateAllUserSessions,
// Mobile bearer sessions.
createMobileSession,
refreshMobileSession,
validateBearerToken,
hashRefreshToken,
}

View File

@@ -0,0 +1,59 @@
// ── SSO transaction state (CSRF + PKCE) ────────────────────────────────────
//
// An OAuth redirect flow spans two requests (start → callback) with a hop to the
// IdP in between, so we must carry state across it safely:
//
// - CSRF: an attacker must not be able to forge a callback. We bind the flow to
// the user's browser with a short-lived, signed, httpOnly cookie (sso_tx) and
// put only an opaque `nonce` in the URL `state` param. The callback requires
// state === cookie.nonce, so a callback not initiated by this browser fails.
// - PKCE: the code_verifier is generated at start, kept ONLY in the httpOnly
// cookie (never in the URL/logs), and sent to the token endpoint at callback.
//
// The cookie is a signed JWT (reusing the app's JWT signing) with a tight TTL, so
// it cannot be tampered with and expires quickly if a flow is abandoned.
const crypto = require('crypto')
const token = require('./token')
const TX_COOKIE = 'sso_tx'
const TX_TTL = '10m' // a login round-trip is quick; abandon after 10 minutes
// base64url of random bytes — used for the nonce and the PKCE verifier.
function randomUrlSafe(bytes = 32) {
return crypto.randomBytes(bytes).toString('base64url')
}
// PKCE S256 challenge for a given verifier.
function codeChallengeFor(verifier) {
return crypto.createHash('sha256').update(verifier).digest('base64url')
}
// Create a transaction: returns { nonce, verifier, codeChallenge, txToken }.
// `data` = { provider, mode ('login'|'link'), linkUserId?, returnTo? }.
function createTx(data) {
const nonce = randomUrlSafe(16)
const verifier = randomUrlSafe(32)
const codeChallenge = codeChallengeFor(verifier)
const txToken = token.signToken(
{ id: 'sso' }, // subject is irrelevant; this is a flow token, not a session
{ nonce, verifier, ...data, kind: 'sso_tx' },
{ expiresIn: TX_TTL },
)
return { nonce, verifier, codeChallenge, txToken }
}
// Verify a tx cookie against the state param. Returns the tx payload
// ({ nonce, verifier, provider, mode, ... }) or null if missing/expired/mismatched.
function verifyTx(txToken, stateNonce) {
if (!txToken || !stateNonce) return null
const decoded = token.verifyToken(txToken)
if (!decoded || decoded.kind !== 'sso_tx') return null
// Constant-time compare so a mismatch can't be timed.
const a = Buffer.from(String(decoded.nonce))
const b = Buffer.from(String(stateNonce))
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null
return decoded
}
module.exports = { TX_COOKIE, TX_TTL, createTx, verifyTx, codeChallengeFor, randomUrlSafe }

131
server/src/auth/token.js Normal file
View File

@@ -0,0 +1,131 @@
// ── Low-level auth token primitives ───────────────────────────────────────
//
// JWT signing/verification, the staged TOTP challenge token, request token
// extraction, and cookie helpers. This module is intentionally the *bottom* of
// the auth stack: it depends only on jsonwebtoken + the logger, and knows
// nothing about sessions, providers, or the database. The session service and
// middleware build on top of it, and utils/auth.js re-exports it for backward
// compatibility. Keeping these primitives here (rather than in the session
// service) avoids a require cycle: session.service → token, never the reverse.
const jwt = require('jsonwebtoken')
require('dotenv').config()
const log = require('../utils/logger')('auth')
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d'
const COOKIE_NAME = process.env.COOKIE_NAME || 'uomm_token'
// Lifetime of the short-lived "password verified, awaiting TOTP" token.
const TOTP_CHALLENGE_TTL = process.env.TOTP_CHALLENGE_TTL || '5m'
// Resolve the signing secret. Without one, jwt.sign/verify can't produce or
// validate a usable token, so every login is silently broken. Fail fast in
// production rather than booting into that state; in dev fall back to a known
// insecure secret so login still works locally (with a loud warning).
function resolveJwtSecret() {
const secret = process.env.JWT_SECRET
if (secret) return secret
if (process.env.NODE_ENV === 'production') {
throw new Error('JWT_SECRET must be set in production')
}
log.warn('JWT_SECRET is not set — using an insecure development fallback. Set JWT_SECRET in .env before deploying.')
return 'dev-insecure-jwt-secret-do-not-use-in-production'
}
const JWT_SECRET = resolveJwtSecret()
// Sign a session token. `extraClaims` lets the session service add fields
// (authMethod, jti) on top of the identity claims without this module needing
// to know what they mean. Extra claims are additive: an older verifier that
// only reads { id, username, role } ignores them, so tokens stay compatible.
// `options.expiresIn` overrides the default lifetime (used by short-lived mobile
// access tokens); omitting it keeps the historical JWT_EXPIRES_IN behavior.
function signToken(user, extraClaims = {}, { expiresIn = JWT_EXPIRES_IN } = {}) {
const payload = { id: user.id, username: user.username, role: user.role, ...extraClaims }
return jwt.sign(payload, JWT_SECRET, { expiresIn })
}
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET)
} catch (err) {
return null
}
}
// Short-lived token issued after the password step for users with TOTP enabled.
// It is NOT a session: it carries stage:'totp' so session validation rejects it,
// and it is only accepted by verifyTotpChallenge to gate the second factor.
function signTotpChallenge(user) {
return jwt.sign({ id: user.id, stage: 'totp' }, JWT_SECRET, { expiresIn: TOTP_CHALLENGE_TTL })
}
function verifyTotpChallenge(token) {
const decoded = verifyToken(token)
if (!decoded || decoded.stage !== 'totp') return null
return decoded
}
// Rough max-age (ms) for the cookie, parsed from JWT_EXPIRES_IN (e.g. 1d, 12h, 30m).
function cookieMaxAge() {
const m = /^(\d+)([dhms])$/.exec(String(JWT_EXPIRES_IN).trim())
if (!m) return 24 * 60 * 60 * 1000
const n = Number(m[1])
const unit = { d: 86400000, h: 3600000, m: 60000, s: 1000 }[m[2]]
return n * unit
}
/**
* Decide the cookie Secure flag. COOKIE_SECURE=auto (default) uses req.secure,
* which is true behind Pangolin (HTTPS, X-Forwarded-Proto) and false over plain
* HTTP on the LAN IP — so login works in both. Requires app.set('trust proxy').
*/
function cookieSecure(req) {
const mode = (process.env.COOKIE_SECURE || 'auto').toLowerCase()
if (mode === 'true') return true
if (mode === 'false') return false
return Boolean(req.secure)
}
function cookieOptions(req) {
return {
httpOnly: true,
sameSite: 'lax',
secure: cookieSecure(req),
path: '/',
}
}
function setAuthCookie(req, res, token) {
res.cookie(COOKIE_NAME, token, { ...cookieOptions(req), maxAge: cookieMaxAge() })
}
function clearAuthCookie(req, res) {
res.clearCookie(COOKIE_NAME, cookieOptions(req))
}
// Extract a token from the cookie or an Authorization: Bearer header. Supporting
// both here is what lets future bearer-token (mobile) clients reuse the exact
// same validation path as cookie-based web sessions.
function extractToken(req) {
if (req.cookies && req.cookies[COOKIE_NAME]) return req.cookies[COOKIE_NAME]
const header = req.headers && req.headers.authorization
if (header && header.startsWith('Bearer ')) return header.substring(7)
return null
}
module.exports = {
COOKIE_NAME,
JWT_EXPIRES_IN,
resolveJwtSecret,
signToken,
verifyToken,
signTotpChallenge,
verifyTotpChallenge,
cookieMaxAge,
cookieSecure,
cookieOptions,
setAuthCookie,
clearAuthCookie,
extractToken,
}

View File

@@ -32,4 +32,23 @@ const contactLimiter = makeLimiter({
message: 'Too many messages sent. Please try again later.', message: 'Too many messages sent. Please try again later.',
}) })
module.exports = { loginLimiter, contactLimiter } // Cap mobile refresh-token exchanges per IP. Legitimate apps refresh at most a
// handful of times per window; a flood is either a bug or an attempt to brute
// the refresh endpoint.
const mobileRefreshLimiter = makeLimiter({
windowMs: 15 * 60 * 1000,
max: 30,
label: 'mobile-refresh',
message: 'Too many refresh attempts. Please try again later.',
})
// Throttle SSO redirect starts per IP — cheap to trigger, and a flood is either a
// bug or an attempt to spin the OAuth flow. Generous enough for real users.
const ssoStartLimiter = makeLimiter({
windowMs: 15 * 60 * 1000,
max: 30,
label: 'sso-start',
message: 'Too many sign-in attempts. Please try again later.',
})
module.exports = { loginLimiter, contactLimiter, mobileRefreshLimiter, ssoStartLimiter }

View File

@@ -0,0 +1,36 @@
const { query } = require('../../utils/db')
const COLS =
'id, kind, name, enabled, client_id, client_secret_enc, authorize_url, token_url, userinfo_url, scopes, priority, created_at, updated_at'
async function list() {
return query(`SELECT ${COLS} FROM auth_providers ORDER BY priority ASC, id ASC`)
}
async function get(id) {
const rows = await query(`SELECT ${COLS} FROM auth_providers WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
// Upsert a provider row. `fields` are column values already prepared by the model
// (secret pre-encrypted). Only the provided columns are written/updated.
async function upsert(id, fields) {
const cols = Object.keys(fields)
const vals = cols.map((c) => fields[c])
const insertCols = ['id', ...cols].map((c) => `\`${c}\``).join(', ')
const placeholders = ['?', ...cols.map(() => '?')].join(', ')
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
await query(
`INSERT INTO auth_providers (${insertCols}) VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updates}`,
[id, ...vals],
)
return get(id)
}
async function remove(id) {
const res = await query('DELETE FROM auth_providers WHERE id = ?', [id])
return Number(res.affectedRows || 0)
}
module.exports = { list, get, upsert, remove }

View File

@@ -0,0 +1,50 @@
// Auth-provider config store. Thin logic layer over authProviders.db, mirroring
// the users model split. Owns encryption of the client secret at the boundary so
// the DB layer only ever sees ciphertext and callers only ever see the decrypted
// secret when they explicitly ask (getWithSecret) — the plain list/get paths
// never surface it.
const db = require('./authProviders.db')
const secretBox = require('../../utils/secretBox')
// All configured provider rows (secret column left as ciphertext; callers that
// need the secret use getWithSecret).
async function list() {
return db.list()
}
async function get(id) {
return db.get(id)
}
// Provider row with the client secret decrypted (server-side only — used by the
// registry at token-exchange time). Returns null if the provider does not exist.
async function getWithSecret(id) {
const row = await db.get(id)
if (!row) return null
return { ...row, client_secret: row.client_secret_enc ? secretBox.decrypt(row.client_secret_enc) : null }
}
// Create/update a provider. `secret` (raw) is encrypted here; pass secret ===
// undefined to leave an existing secret untouched, or '' to keep it unchanged as
// well (blank means "no change" from the admin UI). Returns the stored row.
async function save(id, { kind, name, enabled, clientId, secret, authorizeUrl, tokenUrl, userinfoUrl, scopes, priority }) {
const fields = {}
if (kind !== undefined) fields.kind = kind
if (name !== undefined) fields.name = name
if (enabled !== undefined) fields.enabled = enabled ? 1 : 0
if (clientId !== undefined) fields.client_id = clientId
if (secret) fields.client_secret_enc = secretBox.encrypt(secret) // only when a new secret is given
if (authorizeUrl !== undefined) fields.authorize_url = authorizeUrl
if (tokenUrl !== undefined) fields.token_url = tokenUrl
if (userinfoUrl !== undefined) fields.userinfo_url = userinfoUrl
if (scopes !== undefined) fields.scopes = scopes
if (priority !== undefined) fields.priority = priority
return db.upsert(id, fields)
}
async function remove(id) {
return db.remove(id)
}
module.exports = { list, get, getWithSecret, save, remove }

View File

@@ -0,0 +1,62 @@
const { query } = require('../../utils/db')
// SQL for the mobile_refresh_tokens table. Tokens are stored only as sha256
// hashes (token_hash); the raw refresh token never touches the database.
// Insert a new refresh-token row. expiresAt is a JS Date (or ms epoch).
async function insert({ userId, tokenHash, deviceHash = null, userAgent = null, expiresAt }) {
const res = await query(
`INSERT INTO mobile_refresh_tokens (user_id, token_hash, device_hash, user_agent, expires_at)
VALUES (?, ?, ?, ?, ?)`,
[userId, tokenHash, deviceHash, userAgent, new Date(expiresAt)],
)
return res.insertId
}
// Look up a token by hash only if it is still usable: not revoked and not past
// its expiry. Returns the row (incl. user_id) or null.
async function findValidByHash(tokenHash) {
const rows = await query(
`SELECT * FROM mobile_refresh_tokens
WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > NOW()
LIMIT 1`,
[tokenHash],
)
return rows[0] || null
}
// Mark a single token revoked (idempotent — only affects a not-yet-revoked row).
// Returns the number of rows changed.
async function revokeByHash(tokenHash) {
const res = await query(
'UPDATE mobile_refresh_tokens SET revoked_at = NOW() WHERE token_hash = ? AND revoked_at IS NULL',
[tokenHash],
)
return Number(res.affectedRows || 0)
}
// Revoke every active token for a user (logout-everywhere). Returns rows changed.
async function revokeAllForUser(userId) {
const res = await query(
'UPDATE mobile_refresh_tokens SET revoked_at = NOW() WHERE user_id = ? AND revoked_at IS NULL',
[userId],
)
return Number(res.affectedRows || 0)
}
// Housekeeping: delete rows that are long dead (expired or revoked). Keeps the
// table from growing without bound. Returns rows removed.
async function pruneExpired() {
const res = await query(
'DELETE FROM mobile_refresh_tokens WHERE expires_at < NOW() OR revoked_at IS NOT NULL',
)
return Number(res.affectedRows || 0)
}
module.exports = {
insert,
findValidByHash,
revokeByHash,
revokeAllForUser,
pruneExpired,
}

View File

@@ -0,0 +1,40 @@
// Mobile refresh-token store. Thin logic layer over mobileSessions.db — mirrors
// the users model split (.db = SQL, .model = the API the rest of the app calls).
// The refresh token itself is opaque and lives client-side; only its hash is
// persisted (hashing is done by the session service so caller + store agree).
const db = require('./mobileSessions.db')
// Persist a newly issued refresh token (by hash). Returns the row id.
async function store({ userId, tokenHash, deviceHash, userAgent, expiresAt }) {
return db.insert({ userId, tokenHash, deviceHash, userAgent, expiresAt })
}
// Return the stored row for a still-valid (unrevoked, unexpired) token, else null.
async function findValidByHash(tokenHash) {
return db.findValidByHash(tokenHash)
}
// Revoke one refresh token (logout / rotation). Returns rows changed (0 if it was
// already gone/revoked — callers treat this idempotently).
async function revokeByHash(tokenHash) {
return db.revokeByHash(tokenHash)
}
// Revoke all of a user's refresh tokens (logout everywhere).
async function revokeAllForUser(userId) {
return db.revokeAllForUser(userId)
}
// Drop expired/revoked rows.
async function pruneExpired() {
return db.pruneExpired()
}
module.exports = {
store,
findValidByHash,
revokeByHash,
revokeAllForUser,
pruneExpired,
}

View File

@@ -0,0 +1,38 @@
const { query } = require('../../utils/db')
// Find the identity row for an external (provider, subject) pair. This is the
// link-only login lookup: no row → no account → login refused.
async function findByProviderSubject(provider, subject) {
const rows = await query(
'SELECT * FROM user_identities WHERE provider = ? AND subject = ? LIMIT 1',
[provider, subject],
)
return rows[0] || null
}
// All identities linked to a given internal user (for the Account page).
async function listForUser(userId) {
return query(
'SELECT id, provider, subject, email, created_at FROM user_identities WHERE user_id = ? ORDER BY provider',
[userId],
)
}
async function insert({ userId, provider, subject, email = null }) {
const res = await query(
'INSERT INTO user_identities (user_id, provider, subject, email) VALUES (?, ?, ?, ?)',
[userId, provider, subject, email],
)
return res.insertId
}
// Remove a user's link to a provider. Returns rows deleted.
async function deleteForUserProvider(userId, provider) {
const res = await query(
'DELETE FROM user_identities WHERE user_id = ? AND provider = ?',
[userId, provider],
)
return Number(res.affectedRows || 0)
}
module.exports = { findByProviderSubject, listForUser, insert, deleteForUserProvider }

View File

@@ -0,0 +1,27 @@
// Account-linking store: maps external SSO identities to internal users. Thin
// logic layer over userIdentities.db (mirrors the users model split).
const db = require('./userIdentities.db')
// The link-only login lookup. Returns the identity row (with user_id) or null.
async function findByProviderSubject(provider, subject) {
return db.findByProviderSubject(provider, subject)
}
// Identities linked to a user (Account page).
async function listForUser(userId) {
return db.listForUser(userId)
}
// Link an external identity to an internal user. Returns the new row id. The
// (provider, subject) UNIQUE constraint enforces one-identity-one-user at the DB.
async function link({ userId, provider, subject, email }) {
return db.insert({ userId, provider, subject, email })
}
// Unlink a provider from a user. Returns rows removed (0 if nothing was linked).
async function unlink(userId, provider) {
return db.deleteForUserProvider(userId, provider)
}
module.exports = { findByProviderSubject, listForUser, link, unlink }

View File

@@ -4,6 +4,7 @@
const users = require('../../../model/users/users.model') const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model') const activity = require('../../../model/activity/activity.model')
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
const totp = require('../../../utils/totp') const totp = require('../../../utils/totp')
const log = require('../../../utils/logger')('account') const log = require('../../../utils/logger')('account')
@@ -81,4 +82,32 @@ async function totpDisable(req, res) {
} }
} }
module.exports = { getAccount, totpSetup, totpEnable, totpDisable } // ── Linked SSO identities (self-service) ──────────────────────────────────
// List the external accounts (Google/Discord/…) linked to the current user.
// Linking itself happens via the SSO redirect flow (/auth/sso/:provider/link).
async function listIdentities(req, res) {
try {
const rows = await userIdentities.listForUser(req.user.id)
return res.json(rows.map((r) => ({ provider: r.provider, email: r.email, linked_at: r.created_at })))
} catch (err) {
log.error('listIdentities', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Remove a linked SSO identity from the current user's account.
async function unlinkIdentity(req, res) {
const { provider } = req.params
try {
const removed = await userIdentities.unlink(req.user.id, provider)
if (!removed) return res.status(404).json({ message: 'No linked account for that provider.' })
await activity.log({ req, action: 'auth.sso.unlink', detail: { provider } })
log.info('sso identity unlinked', { provider, id: req.user.id })
return res.json({ unlinked: true })
} catch (err) {
log.error('unlinkIdentity', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { getAccount, totpSetup, totpEnable, totpDisable, listIdentities, unlinkIdentity }

View File

@@ -8,6 +8,7 @@ const { body, param } = require('express-validator')
const ctrl = require('./admin.controller') const ctrl = require('./admin.controller')
const account = require('./account.controller') const account = require('./account.controller')
const botActivity = require('./botActivity.controller') const botActivity = require('./botActivity.controller')
const authProviders = require('./authProviders.controller')
const { isLoggedIn, requireRole } = require('../../../utils/auth') const { isLoggedIn, requireRole } = require('../../../utils/auth')
const noindex = require('../../../middleware/noindex') const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate') const validate = require('../../../middleware/validate')
@@ -38,6 +39,15 @@ adminRouter.post(
account.totpDisable, account.totpDisable,
) )
// Linked SSO identities (self-service — any logged-in role manages their own).
adminRouter.get('/account/identities', account.listIdentities)
adminRouter.delete(
'/account/identities/:provider',
param('provider').matches(/^[a-z0-9-]+$/),
validate,
account.unlinkIdentity,
)
// ── Image uploads (screenshots/gallery) ─────────────────────────────── // ── Image uploads (screenshots/gallery) ───────────────────────────────
const UPLOAD_DIR = const UPLOAD_DIR =
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads') process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads')
@@ -191,6 +201,49 @@ adminRouter.post(
botActivity.unbanIp, botActivity.unbanIp,
) )
// ── Authentication providers / SSO (admin only) ───────────────────────
adminRouter.get('/auth/providers', adminOnly, authProviders.list)
adminRouter.post(
'/auth/providers',
adminOnly,
body('id').matches(/^[a-z0-9-]+$/),
body('kind').isIn(['oidc', 'oauth2']),
body('name').isString().trim().notEmpty().isLength({ max: 80 }),
body('enabled').optional().isBoolean(),
body('clientId').optional({ values: 'falsy' }).isString(),
body('secret').optional({ values: 'falsy' }).isString(),
body('authorizeUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
body('tokenUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
body('userinfoUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
body('scopes').optional({ values: 'falsy' }).isString().isLength({ max: 500 }),
body('priority').optional().isInt(),
validate,
authProviders.create,
)
adminRouter.put(
'/auth/providers/:id',
adminOnly,
param('id').matches(/^[a-z0-9-]+$/),
body('name').optional().isString().trim().notEmpty().isLength({ max: 80 }),
body('enabled').optional().isBoolean(),
body('clientId').optional({ values: 'falsy' }).isString(),
body('secret').optional({ values: 'falsy' }).isString(),
body('authorizeUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
body('tokenUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
body('userinfoUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
body('scopes').optional({ values: 'falsy' }).isString().isLength({ max: 500 }),
body('priority').optional().isInt(),
validate,
authProviders.update,
)
adminRouter.delete(
'/auth/providers/:id',
adminOnly,
param('id').matches(/^[a-z0-9-]+$/),
validate,
authProviders.remove,
)
// ── User management (admin only) ────────────────────────────────────── // ── User management (admin only) ──────────────────────────────────────
adminRouter.use('/users', adminOnly) adminRouter.use('/users', adminOnly)
adminRouter.get('/users', ctrl.listUsers) adminRouter.get('/users', ctrl.listUsers)

View File

@@ -0,0 +1,121 @@
// ── Admin: auth provider configuration ─────────────────────────────────────
//
// CRUD for SSO providers. Built-ins (google, discord) are configured here too but
// can only be enabled/disabled and given a client id/secret — their kind, name,
// and endpoints are fixed in code and cannot be edited or deleted. Custom
// (oidc/oauth2) providers are fully editable.
//
// SECURITY: the client secret is write-only over this API. It is stored encrypted
// and NEVER returned — responses expose only `hasSecret`. A blank `secret` on
// update means "leave the existing secret unchanged".
const authProviders = require('../../../model/authProviders/authProviders.model')
const registry = require('../../../auth/providers/registry')
const activity = require('../../../model/activity/activity.model')
const log = require('../../../utils/logger')('admin')
// Shape a provider row for the admin UI — no secret material, ever.
function toSafe(p) {
return {
id: p.id,
kind: p.kind,
name: p.name,
enabled: Boolean(p.enabled),
clientId: p.client_id || '',
hasSecret: Boolean(p.client_secret_enc),
authorizeUrl: p.authorize_url || '',
tokenUrl: p.token_url || '',
userinfoUrl: p.userinfo_url || '',
scopes: p.scopes || '',
priority: p.priority ?? 100,
builtin: registry.isBuiltin(p.id),
health: p.health || registry.validateConfig(p),
}
}
// GET /admin/auth/providers — all providers (built-ins always present) + health.
async function list(req, res) {
try {
const rows = await registry.listConfigured()
return res.json(rows.map(toSafe))
} catch (err) {
log.error('authProviders.list', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /admin/auth/providers — create a custom (oidc/oauth2) provider.
async function create(req, res) {
const { id, kind, name, enabled, clientId, secret, authorizeUrl, tokenUrl, userinfoUrl, scopes, priority } = req.body
try {
if (registry.isBuiltin(id)) {
return res.status(400).json({ message: 'Built-in provider — configure it via PUT, not create.' })
}
if (!['oidc', 'oauth2'].includes(kind)) {
return res.status(400).json({ message: 'Custom providers must be of kind oidc or oauth2.' })
}
if (await authProviders.get(id)) {
return res.status(409).json({ message: 'A provider with that id already exists.' })
}
const saved = await authProviders.save(id, {
kind, name, enabled, clientId, secret, authorizeUrl, tokenUrl, userinfoUrl, scopes, priority,
})
await activity.log({ req, action: 'auth.provider.create', detail: { id } })
log.info('auth provider created', { id, kind, by: req.user.username })
return res.status(201).json(toSafe(saved))
} catch (err) {
log.error('authProviders.create', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// PUT /admin/auth/providers/:id — update a built-in or custom provider.
async function update(req, res) {
const { id } = req.params
const b = req.body
try {
let fields
if (registry.isBuiltin(id)) {
// Built-ins: kind/name/priority are fixed; only enable + credentials change.
const meta = registry.BUILTINS.find((x) => x.id === id)
fields = { kind: meta.kind, name: meta.name, priority: meta.priority, enabled: b.enabled, clientId: b.clientId, secret: b.secret }
} else {
if (!(await authProviders.get(id))) {
return res.status(404).json({ message: 'Provider not found.' })
}
fields = {
name: b.name, enabled: b.enabled, clientId: b.clientId, secret: b.secret,
authorizeUrl: b.authorizeUrl, tokenUrl: b.tokenUrl, userinfoUrl: b.userinfoUrl,
scopes: b.scopes, priority: b.priority,
}
}
const saved = await authProviders.save(id, fields)
await activity.log({ req, action: 'auth.provider.update', detail: { id } })
log.info('auth provider updated', { id, by: req.user.username })
return res.json(toSafe(saved))
} catch (err) {
log.error('authProviders.update', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// DELETE /admin/auth/providers/:id — custom providers only.
async function remove(req, res) {
const { id } = req.params
try {
if (registry.isBuiltin(id)) {
return res.status(400).json({ message: 'Built-in providers cannot be deleted — disable them instead.' })
}
const n = await authProviders.remove(id)
if (!n) return res.status(404).json({ message: 'Provider not found.' })
await activity.log({ req, action: 'auth.provider.delete', detail: { id } })
log.info('auth provider deleted', { id, by: req.user.username })
return res.json({ deleted: true })
} catch (err) {
log.error('authProviders.remove', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { list, create, update, remove, toSafe }

View File

@@ -1,12 +1,7 @@
const users = require('../../../model/users/users.model') const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model') const activity = require('../../../model/activity/activity.model')
const { const { setAuthCookie, clearAuthCookie } = require('../../../auth/token')
signToken, const sessionService = require('../../../auth/session.service')
setAuthCookie,
clearAuthCookie,
signTotpChallenge,
verifyTotpChallenge,
} = require('../../../utils/auth')
const totp = require('../../../utils/totp') const totp = require('../../../utils/totp')
const botScore = require('../../../middleware/botScore') const botScore = require('../../../middleware/botScore')
const loginProtection = require('../../../middleware/loginProtection') const loginProtection = require('../../../middleware/loginProtection')
@@ -26,15 +21,17 @@ function needsTotp(user) {
return Boolean(user && user.totp_enabled) return Boolean(user && user.totp_enabled)
} }
// Issue the real session: sign the JWT, set the cookie, clear the IP's failure // Issue the real session: create the session token via the session service, set
// backoff, and record the login. // the cookie, clear the IP's failure backoff, and record the login. authMethod
async function issueSession(req, res, user) { // records how this session was authenticated ('local' password, or 'totp' after
// the second factor) — carried in the session token for downstream visibility.
async function issueSession(req, res, user, authMethod = 'local') {
loginProtection.recordSuccess(req.ip) loginProtection.recordSuccess(req.ip)
await users.recordLogin(user.id) await users.recordLogin(user.id)
const token = signToken(user) const { token } = sessionService.createSession(user, authMethod)
setAuthCookie(req, res, token) setAuthCookie(req, res, token)
await activity.log({ req, userId: user.id, action: 'auth.login' }) await activity.log({ req, userId: user.id, action: 'auth.login' })
log.info('login success', { username: user.username, id: user.id, ip: req.ip }) log.info('login success', { username: user.username, id: user.id, ip: req.ip, authMethod })
return res.json({ user: { id: user.id, username: user.username, role: user.role } }) return res.json({ user: { id: user.id, username: user.username, role: user.role } })
} }
@@ -64,12 +61,12 @@ async function login(req, res) {
// hand back a short-lived, signed "password verified" challenge and require // hand back a short-lived, signed "password verified" challenge and require
// the code. If TOTP is off, log them straight in. // the code. If TOTP is off, log them straight in.
if (needsTotp(user)) { if (needsTotp(user)) {
const challenge = signTotpChallenge(user) const challenge = sessionService.createPartialSession(user)
log.info('password ok, awaiting TOTP', { username: user.username, id: user.id, ip: req.ip }) log.info('password ok, awaiting TOTP', { username: user.username, id: user.id, ip: req.ip })
return res.json({ totpRequired: true, challenge }) return res.json({ totpRequired: true, challenge })
} }
return issueSession(req, res, user) return issueSession(req, res, user, 'local')
} catch (err) { } catch (err) {
log.error('login error', err) log.error('login error', err)
return res.status(500).json({ message: 'Internal Server Error' }) return res.status(500).json({ message: 'Internal Server Error' })
@@ -80,7 +77,7 @@ async function login(req, res) {
// session. A wrong code counts as a failed attempt (backoff + bot score). // session. A wrong code counts as a failed attempt (backoff + bot score).
async function loginTotp(req, res) { async function loginTotp(req, res) {
const { challenge, code } = req.body const { challenge, code } = req.body
const decoded = verifyTotpChallenge(challenge) const decoded = sessionService.upgradeSessionAfterTotp(challenge)
if (!decoded) { if (!decoded) {
return res.status(401).json({ message: 'Your verification session expired. Please sign in again.' }) return res.status(401).json({ message: 'Your verification session expired. Please sign in again.' })
} }
@@ -92,7 +89,7 @@ async function loginTotp(req, res) {
log.warn('TOTP verify failed', { id: decoded.id, ip: req.ip }) log.warn('TOTP verify failed', { id: decoded.id, ip: req.ip })
return res.status(401).json({ message: 'Invalid verification code.' }) return res.status(401).json({ message: 'Invalid verification code.' })
} }
return issueSession(req, res, user) return issueSession(req, res, user, 'totp')
} catch (err) { } catch (err) {
log.error('loginTotp error', err) log.error('loginTotp error', err)
return res.status(500).json({ message: 'Internal Server Error' }) return res.status(500).json({ message: 'Internal Server Error' })

View File

@@ -6,9 +6,18 @@ const { isLoggedIn } = require('../../../utils/auth')
const { loginLimiter } = require('../../../middleware/rateLimit') const { loginLimiter } = require('../../../middleware/rateLimit')
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection') const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
const validate = require('../../../middleware/validate') const validate = require('../../../middleware/validate')
const mobileRouter = require('./mobile.routes')
const ssoRouter = require('./sso.routes')
const authRouter = express.Router() const authRouter = express.Router()
// Native/Android bearer-token auth. Additive alongside the web cookie flow below.
authRouter.use('/mobile', mobileRouter)
// SSO discovery + OAuth redirect flow (/auth/providers, /auth/sso/:provider/*).
// Additive; the web cookie + TOTP flow below is unchanged.
authRouter.use(ssoRouter)
// Login protection order (cheapest rejection first): // Login protection order (cheapest rejection first):
// backoffGuard → per-IP exponential lockout on repeated failures // backoffGuard → per-IP exponential lockout on repeated failures
// slowLogin → progressive per-request delay within the window // slowLogin → progressive per-request delay within the window

View File

@@ -0,0 +1,143 @@
// ── Mobile (Android) bearer-token auth ─────────────────────────────────────
//
// Purely additive alongside the web cookie flow. Native clients POST credentials
// here and receive a short-lived access token (a normal session JWT, validated
// on every route by the shared requireAuth middleware) plus a long-lived,
// server-stored, revocable refresh token. This controller reuses the exact same
// brute-force defenses as web login (bot scoring + login backoff), and handles
// TOTP in a single stateless request: if 2FA is on and no/invalid code is given,
// it replies { totpRequired: true } and the app retries with the code.
//
// It does NOT touch the web login/loginTotp handlers or the TOTP staged-challenge
// flow — those are unchanged.
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
const sessionService = require('../../../auth/session.service')
const totp = require('../../../utils/totp')
const botScore = require('../../../middleware/botScore')
const loginProtection = require('../../../middleware/loginProtection')
const log = require('../../../utils/logger')('auth-mobile')
// Same generic failure text as web — never reveals which credential was wrong.
const GENERIC_FAIL = { message: 'Incorrect username or password.' }
// Shape returned to the client on a successful login/refresh. Access + refresh
// tokens, the access lifetime, and the safe (secret-stripped) user.
function tokenResponse(out, user) {
return {
accessToken: out.accessToken,
refreshToken: out.refreshToken,
expiresIn: out.expiresIn,
user: { id: user.id, username: user.username, role: user.role },
}
}
// Persist a freshly minted refresh token (by hash) and record the login. Shared
// by login and refresh so the storage/side-effect logic lives in one place.
async function persistAndFinish(req, user, out, action) {
await mobileSessions.store({
userId: user.id,
tokenHash: out.refreshHash,
deviceHash: out.deviceHash,
userAgent: out.userAgent,
expiresAt: out.refreshExpiresAt,
})
await users.recordLogin(user.id)
await activity.log({ req, userId: user.id, action })
}
// POST /auth/mobile/login { username, password, code? }
async function login(req, res) {
const { username, password, code } = req.body
try {
const user = await users.getRawByUsername(username)
const ok = user && (await users.validatePassword(user, password))
if (!ok) {
botScore.recordLoginFailure(req.ip)
loginProtection.recordFailure(req.ip)
log.warn('mobile login failed', { username, ip: req.ip })
return res.status(401).json(GENERIC_FAIL)
}
// Second factor, single-request style: if 2FA is enabled, a valid code must
// accompany this request. Missing or wrong → tell the app to prompt + retry.
// A wrong code is a real failed attempt (scored + backed off like web).
if (user.totp_enabled) {
if (!code || !totp.verifyCode(user.totp_secret, code)) {
if (code) {
botScore.recordLoginFailure(req.ip)
loginProtection.recordFailure(req.ip)
log.warn('mobile TOTP verify failed', { id: user.id, ip: req.ip })
}
return res.status(401).json({ totpRequired: true, message: 'A verification code is required.' })
}
}
loginProtection.recordSuccess(req.ip)
const meta = sessionService.sessionMeta(req)
const out = sessionService.createMobileSession(user, meta)
await persistAndFinish(req, user, out, 'auth.mobile.login')
log.info('mobile login success', { username: user.username, id: user.id, ip: req.ip })
return res.json(tokenResponse(out, user))
} catch (err) {
log.error('mobile login error', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /auth/mobile/refresh { refreshToken }
// Validates the presented refresh token, rotates it (revoke old + issue new),
// and returns a fresh access + refresh pair. Rotation means a stolen-and-used
// refresh token is single-use: the legitimate client's next refresh fails and
// surfaces the compromise.
async function refresh(req, res) {
const { refreshToken } = req.body
try {
const hash = sessionService.hashRefreshToken(refreshToken)
const row = await mobileSessions.findValidByHash(hash)
if (!row) {
log.warn('mobile refresh rejected (unknown/expired/revoked)', { ip: req.ip })
return res.status(401).json({ message: 'Invalid or expired session. Please sign in again.' })
}
const user = await users.getById(row.user_id) // fresh row; 401 if user gone
if (!user) {
await mobileSessions.revokeByHash(hash)
return res.status(401).json({ message: 'Invalid or expired session. Please sign in again.' })
}
await mobileSessions.revokeByHash(hash) // rotate: old token is now dead
const meta = sessionService.sessionMeta(req)
const out = sessionService.refreshMobileSession(user, meta)
await persistAndFinish(req, user, out, 'auth.mobile.refresh')
log.info('mobile session refreshed', { id: user.id, ip: req.ip })
return res.json(tokenResponse(out, user))
} catch (err) {
log.error('mobile refresh error', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /auth/mobile/logout { refreshToken?, all? }
// Runs behind requireAuth (bearer), so req.user is the caller. Revokes the given
// refresh token, or every token for the user when { all: true }. Idempotent.
async function logout(req, res) {
const { refreshToken, all } = req.body
try {
if (all) {
const n = await mobileSessions.revokeAllForUser(req.user.id)
log.info('mobile logout (all devices)', { id: req.user.id, revoked: n })
} else if (refreshToken) {
await mobileSessions.revokeByHash(sessionService.hashRefreshToken(refreshToken))
}
await activity.log({ req, userId: req.user.id, action: 'auth.mobile.logout' })
return res.json({ message: 'Logged out.' })
} catch (err) {
log.error('mobile logout error', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { login, refresh, logout }

View File

@@ -0,0 +1,48 @@
const express = require('express')
const { body } = require('express-validator')
const { login, refresh, logout } = require('./mobile.controller')
const { requireAuth } = require('../../../auth/session.middleware')
const { loginLimiter, mobileRefreshLimiter } = require('../../../middleware/rateLimit')
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
const validate = require('../../../middleware/validate')
const mobileRouter = express.Router()
// Mobile login is a credential surface too, so it sits behind the SAME guards as
// web login (cheapest rejection first): per-IP backoff → progressive slowdown →
// hard rate cap.
const loginGuards = [backoffGuard, slowLogin, loginLimiter]
// POST /auth/mobile/login — { username, password, code? }
mobileRouter.post(
'/login',
...loginGuards,
body('username').isString().trim().notEmpty(),
body('password').isString().notEmpty(),
// Optional TOTP code (single-request 2FA); only checked when the account has 2FA on.
body('code').optional().isString().trim().isLength({ min: 6, max: 8 }),
validate,
login,
)
// POST /auth/mobile/refresh — { refreshToken }
mobileRouter.post(
'/refresh',
mobileRefreshLimiter,
body('refreshToken').isString().notEmpty(),
validate,
refresh,
)
// POST /auth/mobile/logout — { refreshToken?, all? } — requires a valid bearer.
mobileRouter.post(
'/logout',
requireAuth,
body('refreshToken').optional().isString(),
body('all').optional().isBoolean(),
validate,
logout,
)
module.exports = mobileRouter

View File

@@ -0,0 +1,176 @@
// ── SSO (OAuth2 / OIDC) controller ─────────────────────────────────────────
//
// Drives the redirect flow for built-in (Google, Discord) and custom providers:
// GET /auth/providers → public discovery (enabled + valid providers)
// GET /auth/sso/:provider/start → begin login (redirect to the IdP)
// GET /auth/sso/:provider/link → begin account linking (requireAuth)
// GET /auth/sso/:provider/callback → exchange code, then log in OR link
//
// LINK-ONLY policy: a login succeeds only if the external identity is already
// linked to an internal account. Unknown identities are refused, never
// auto-provisioned. Every successful login goes through sessionService, so the
// resulting session is identical to a local login (same cookie, logging, RBAC).
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const authProviders = require('../../../model/authProviders/authProviders.model')
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
const registry = require('../../../auth/providers/registry')
const sessionService = require('../../../auth/session.service')
const ssoState = require('../../../auth/ssoState')
const token = require('../../../auth/token')
const log = require('../../../utils/logger')('sso')
const PROVIDER_ID_RE = /^[a-z0-9-]+$/
// Redirect targets (front-end routes). Errors surface as a query param the login
// / account pages can render.
const loginError = (code) => `/admin/login?sso_error=${code}`
const accountError = (code) => `/admin/account?link_error=${code}`
// Only allow returning to an internal /admin path (prevents open redirect).
function sanitizeReturn(returnTo) {
if (typeof returnTo === 'string' && /^\/admin(?:[/?]|$)/.test(returnTo) && !returnTo.startsWith('//')) {
return returnTo
}
return null
}
// Public base URL used to build the OAuth redirect_uri. Prefer APP_BASE_URL;
// fall back to the request's own origin with a warning if it is unset.
function appBaseUrl(req) {
const configured = process.env.APP_BASE_URL
if (configured) return configured.replace(/\/+$/, '')
const derived = `${req.protocol}://${req.get('host')}`
log.warn('APP_BASE_URL not set — deriving redirect_uri from the request', { derived })
return derived
}
function redirectUriFor(req, providerId) {
return `${appBaseUrl(req)}/api/v1/auth/sso/${providerId}/callback`
}
// httpOnly cookie carrying the signed tx (nonce + PKCE verifier + mode). Reuse the
// app's standard cookie options (httpOnly, sameSite=lax, secure=auto) + a TTL.
function txCookieOptions(req) {
return { ...token.cookieOptions(req), maxAge: 10 * 60 * 1000 }
}
// GET /auth/providers — public discovery. Never touches secrets.
async function listProviders(req, res) {
try {
return res.json(await registry.listEnabledValid())
} catch (err) {
log.error('listProviders', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Shared start for both login and link. `mode` ∈ 'login' | 'link'. For link,
// requireAuth has already run so req.user is the account to attach the identity to.
async function beginFlow(req, res, mode) {
const providerId = req.params.provider
const failUrl = mode === 'link' ? accountError('error') : loginError('error')
try {
if (!PROVIDER_ID_RE.test(providerId)) return res.redirect(failUrl)
const row = await authProviders.getWithSecret(providerId)
if (!row || !row.enabled || !registry.validateConfig(row).valid) {
log.warn('sso start: provider unavailable', { provider: providerId, mode })
return res.redirect(mode === 'link' ? accountError('unavailable') : loginError('unavailable'))
}
const provider = registry.instantiate(row)
const tx = ssoState.createTx({
provider: providerId,
mode,
linkUserId: mode === 'link' ? req.user.id : undefined,
returnTo: sanitizeReturn(req.query.returnTo) || undefined,
})
res.cookie(ssoState.TX_COOKIE, tx.txToken, txCookieOptions(req))
const url = provider.getAuthorizationUrl(tx.nonce, {
redirectUri: redirectUriFor(req, providerId),
codeChallenge: tx.codeChallenge,
})
return res.redirect(url)
} catch (err) {
log.error('sso start', err)
return res.redirect(failUrl)
}
}
const start = (req, res) => beginFlow(req, res, 'login')
const linkStart = (req, res) => beginFlow(req, res, 'link')
// GET /auth/sso/:provider/callback
async function callback(req, res) {
const providerId = req.params.provider
const txToken = req.cookies && req.cookies[ssoState.TX_COOKIE]
const { code, state, error: oauthError } = req.query
// The tx cookie is single-use — clear it no matter the outcome.
res.clearCookie(ssoState.TX_COOKIE, token.cookieOptions(req))
if (oauthError) {
log.warn('sso callback: provider returned error', { provider: providerId, error: String(oauthError).slice(0, 60) })
return res.redirect(loginError('denied'))
}
const tx = ssoState.verifyTx(txToken, state)
if (!tx || tx.provider !== providerId || !code) {
log.warn('sso callback: bad state', { provider: providerId })
return res.redirect(loginError('bad_state'))
}
try {
const row = await authProviders.getWithSecret(providerId)
if (!row || !row.enabled || !registry.validateConfig(row).valid) {
return res.redirect(loginError('unavailable'))
}
const provider = registry.instantiate(row)
const profile = await provider.handleCallback({
code,
redirectUri: redirectUriFor(req, providerId),
codeVerifier: tx.verifier,
})
if (tx.mode === 'link') return finishLink(req, res, providerId, tx, profile)
return finishLogin(req, res, providerId, row.kind, tx, profile)
} catch (err) {
log.error('sso callback', err)
return res.redirect(loginError('error'))
}
}
// Link-only login: require an existing (provider, subject) identity → session.
async function finishLogin(req, res, providerId, kind, tx, profile) {
const identity = await userIdentities.findByProviderSubject(providerId, profile.subject)
if (!identity) {
log.warn('sso login refused: no linked account', { provider: providerId })
return res.redirect(loginError('not_linked'))
}
const user = await users.getById(identity.user_id)
if (!user) return res.redirect(loginError('not_linked'))
const authMethod = sessionService.AUTH_METHODS.includes(kind) ? kind : 'sso'
const { token: sessionToken } = sessionService.createSession(user, authMethod)
token.setAuthCookie(req, res, sessionToken)
await users.recordLogin(user.id)
await activity.log({ req, userId: user.id, action: 'auth.sso.login', detail: { provider: providerId } })
log.info('sso login success', { provider: providerId, id: user.id, ip: req.ip })
return res.redirect(sanitizeReturn(tx.returnTo) || '/admin')
}
// Attach the external identity to the account that initiated linking (tx.linkUserId
// was captured behind requireAuth at /link start, so the signed tx authorizes it).
async function finishLink(req, res, providerId, tx, profile) {
const userId = tx.linkUserId
if (!userId) return res.redirect(loginError('error'))
const existing = await userIdentities.findByProviderSubject(providerId, profile.subject)
if (existing && existing.user_id !== userId) {
return res.redirect(accountError('in_use')) // that external identity belongs to another account
}
if (!existing) {
await userIdentities.link({ userId, provider: providerId, subject: profile.subject, email: profile.email })
await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } })
log.info('sso account linked', { provider: providerId, userId })
}
return res.redirect(`/admin/account?linked=${providerId}`)
}
module.exports = { listProviders, start, linkStart, callback, beginFlow, finishLogin, finishLink }

View File

@@ -0,0 +1,22 @@
const express = require('express')
const ctrl = require('./sso.controller')
const { requireAuth } = require('../../../auth/session.middleware')
const { ssoStartLimiter } = require('../../../middleware/rateLimit')
const ssoRouter = express.Router()
// Public discovery — the login page reads this to render provider buttons.
ssoRouter.get('/providers', ctrl.listProviders)
// Begin login (public) — redirects to the IdP.
ssoRouter.get('/sso/:provider/start', ssoStartLimiter, ctrl.start)
// Begin account linking (must be signed in — the tx captures the acting user).
ssoRouter.get('/sso/:provider/link', requireAuth, ctrl.linkStart)
// OAuth redirect target — completes login or linking. Not behind requireAuth:
// the signed tx cookie authorizes link mode; login mode is link-only anyway.
ssoRouter.get('/sso/:provider/callback', ctrl.callback)
module.exports = ssoRouter

View File

@@ -1,150 +1,36 @@
const jwt = require('jsonwebtoken') // ── Auth compatibility facade ──────────────────────────────────────────────
require('dotenv').config() //
// The auth logic now lives in server/src/auth/ (token primitives, the session
// service, and session middleware). This module stays as a thin facade so every
// existing import site (auth.routes, admin.routes, siteMode, auth.controller)
// keeps working with the exact same names and behavior — nothing else in the
// codebase needs to change. New code should prefer requiring ../auth/* directly.
const log = require('./logger')('auth') const token = require('../auth/token')
const users = require('../model/users/users.model') const sessionService = require('../auth/session.service')
const { requireAuth, requireRole } = require('../auth/session.middleware')
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d' // Non-rejecting identity check. Returns the decoded token payload (with `.id`)
const COOKIE_NAME = process.env.COOKIE_NAME || 'uomm_token' // or null — same shape callers relied on (siteMode only truthiness-checks it).
// Lifetime of the short-lived "password verified, awaiting TOTP" token. // Backed by the session service so there is a single validation path.
const TOTP_CHALLENGE_TTL = process.env.TOTP_CHALLENGE_TTL || '5m'
// Resolve the signing secret. Without one, jwt.sign/verify can't produce or
// validate a usable token, so every login is silently broken. Fail fast in
// production rather than booting into that state; in dev fall back to a known
// insecure secret so login still works locally (with a loud warning).
function resolveJwtSecret() {
const secret = process.env.JWT_SECRET
if (secret) return secret
if (process.env.NODE_ENV === 'production') {
throw new Error('JWT_SECRET must be set in production')
}
log.warn('JWT_SECRET is not set — using an insecure development fallback. Set JWT_SECRET in .env before deploying.')
return 'dev-insecure-jwt-secret-do-not-use-in-production'
}
const JWT_SECRET = resolveJwtSecret()
function signToken(user) {
const payload = { id: user.id, username: user.username, role: user.role }
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN })
}
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET)
} catch (err) {
return null
}
}
// Short-lived token issued after the password step for users with TOTP enabled.
// It is NOT a session: it carries stage:'totp' so getUserFromRequest rejects it,
// and it is only accepted by verifyTotpChallenge to gate the second factor.
function signTotpChallenge(user) {
return jwt.sign({ id: user.id, stage: 'totp' }, JWT_SECRET, { expiresIn: TOTP_CHALLENGE_TTL })
}
function verifyTotpChallenge(token) {
const decoded = verifyToken(token)
if (!decoded || decoded.stage !== 'totp') return null
return decoded
}
// Rough max-age (ms) for the cookie, parsed from JWT_EXPIRES_IN (e.g. 1d, 12h, 30m).
function cookieMaxAge() {
const m = /^(\d+)([dhms])$/.exec(String(JWT_EXPIRES_IN).trim())
if (!m) return 24 * 60 * 60 * 1000
const n = Number(m[1])
const unit = { d: 86400000, h: 3600000, m: 60000, s: 1000 }[m[2]]
return n * unit
}
/**
* Decide the cookie Secure flag. COOKIE_SECURE=auto (default) uses req.secure,
* which is true behind Pangolin (HTTPS, X-Forwarded-Proto) and false over plain
* HTTP on the LAN IP — so login works in both. Requires app.set('trust proxy').
*/
function cookieSecure(req) {
const mode = (process.env.COOKIE_SECURE || 'auto').toLowerCase()
if (mode === 'true') return true
if (mode === 'false') return false
return Boolean(req.secure)
}
function cookieOptions(req) {
return {
httpOnly: true,
sameSite: 'lax',
secure: cookieSecure(req),
path: '/',
}
}
function setAuthCookie(req, res, token) {
res.cookie(COOKIE_NAME, token, { ...cookieOptions(req), maxAge: cookieMaxAge() })
}
function clearAuthCookie(req, res) {
res.clearCookie(COOKIE_NAME, cookieOptions(req))
}
// Extract a token from the cookie or an Authorization: Bearer header.
function extractToken(req) {
if (req.cookies && req.cookies[COOKIE_NAME]) return req.cookies[COOKIE_NAME]
const header = req.headers.authorization
if (header && header.startsWith('Bearer ')) return header.substring(7)
return null
}
// Returns the decoded user or null without rejecting the request. Stage-tagged
// tokens (e.g. the TOTP challenge) are explicitly NOT sessions, so an attacker
// can't present a half-authenticated challenge token as a full login.
function getUserFromRequest(req) { function getUserFromRequest(req) {
const token = extractToken(req) const session = sessionService.validateSession(req)
if (!token) return null if (!session) return null
const decoded = verifyToken(token) // Preserve the historical payload shape (id/username/role) for callers.
if (!decoded || decoded.stage) return null return { id: session.userId, username: session.username, role: session.role }
return decoded
}
// Gate middleware for protected (admin) routes. Re-validates the token against
// the database on every request so a demoted or deleted user loses access
// immediately, instead of keeping their old role (or a working session) until
// the JWT expires. req.user carries the fresh DB row, not the token payload.
async function isLoggedIn(req, res, next) {
const decoded = getUserFromRequest(req)
if (!decoded) return res.status(401).json({ message: 'Unauthorized' })
try {
const user = await users.getById(decoded.id)
if (!user) return res.status(401).json({ message: 'Unauthorized' }) // deleted since token issued
req.user = user
return next()
} catch (err) {
log.error('isLoggedIn', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Gate middleware factory: allow only the listed roles. Assumes isLoggedIn ran
// first so req.user is populated. Use for admin-only endpoints (users, site
// mode, settings) so a lower-privilege editor cannot reach them.
function requireRole(...roles) {
return (req, res, next) => {
if (roles.includes(req.user?.role)) return next()
return res.status(403).json({ message: 'Forbidden' })
}
} }
module.exports = { module.exports = {
COOKIE_NAME, COOKIE_NAME: token.COOKIE_NAME,
signToken, // Token primitives (re-exported from auth/token.js).
verifyToken, signToken: token.signToken,
signTotpChallenge, verifyToken: token.verifyToken,
verifyTotpChallenge, signTotpChallenge: token.signTotpChallenge,
setAuthCookie, verifyTotpChallenge: token.verifyTotpChallenge,
clearAuthCookie, setAuthCookie: token.setAuthCookie,
clearAuthCookie: token.clearAuthCookie,
// Request helpers / middleware.
getUserFromRequest, getUserFromRequest,
isLoggedIn, isLoggedIn: requireAuth, // old name → new middleware, identical behavior
requireRole, requireRole,
} }

View File

@@ -0,0 +1,55 @@
// ── Secret-at-rest encryption (AES-256-GCM) ────────────────────────────────
//
// Used to encrypt OAuth client secrets before they are written to the DB, so a
// database read alone does not yield usable provider credentials. Output format
// is `iv:tag:ciphertext`, each part base64. GCM provides authenticated
// encryption, so tampering is detected on decrypt.
//
// The key comes from SECRET_ENC_KEY (any string — it is hashed to 32 bytes). In
// development, if unset, we derive a key from JWT_SECRET with a loud warning
// (mirrors token.resolveJwtSecret) so local dev works; production must set a
// dedicated key so rotating JWT_SECRET does not silently orphan stored secrets.
const crypto = require('crypto')
require('dotenv').config()
const log = require('../utils/logger')('secretbox')
const ALGO = 'aes-256-gcm'
function resolveKey() {
const explicit = process.env.SECRET_ENC_KEY
if (explicit) return crypto.createHash('sha256').update(explicit).digest()
if (process.env.NODE_ENV === 'production') {
throw new Error('SECRET_ENC_KEY must be set in production')
}
const jwt = process.env.JWT_SECRET || 'dev-insecure-jwt-secret-do-not-use-in-production'
log.warn('SECRET_ENC_KEY is not set — deriving an insecure key from JWT_SECRET for development. Set SECRET_ENC_KEY before deploying.')
return crypto.createHash('sha256').update(`secretbox:${jwt}`).digest()
}
const KEY = resolveKey()
// Encrypt a UTF-8 string → "iv:tag:ct" (base64 parts). Returns null for empty input.
function encrypt(plaintext) {
if (plaintext == null || plaintext === '') return null
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv(ALGO, KEY, iv)
const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return `${iv.toString('base64')}:${tag.toString('base64')}:${ct.toString('base64')}`
}
// Decrypt a value produced by encrypt(). Returns null for null/blank input;
// throws if the payload is malformed or fails authentication (tampered/wrong key).
function decrypt(payload) {
if (payload == null || payload === '') return null
const parts = String(payload).split(':')
if (parts.length !== 3) throw new Error('secretBox: malformed ciphertext')
const [iv, tag, ct] = parts.map((p) => Buffer.from(p, 'base64'))
const decipher = crypto.createDecipheriv(ALGO, KEY, iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8')
}
module.exports = { encrypt, decrypt }

View File

@@ -0,0 +1,81 @@
// Set before requiring the auth layer (token.js reads JWT_SECRET at load) and
// db.js (pulled in transitively; the pool builds at load). Closed DB port keeps
// idle connections from holding the process open — these tests are DB-free and
// only exercise the pure token/hash logic of the mobile session service.
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after } = require('node:test')
const assert = require('node:assert/strict')
const jwt = require('jsonwebtoken')
const sessionService = require('../src/auth/session.service')
const tokenLib = require('../src/auth/token')
const db = require('../src/utils/db')
after(() => db.close())
const USER = { id: 42, username: 'mobileuser', role: 'admin' }
test('createMobileSession mints a bearer-validatable access token + opaque refresh token', () => {
const out = sessionService.createMobileSession(USER, { deviceHash: 'abc', userAgent: 'Android' })
// Access token validates as a mobile session.
const session = sessionService.validateBearerToken(out.accessToken)
assert.ok(session)
assert.equal(session.userId, USER.id)
assert.equal(session.authMethod, 'mobile')
assert.ok(session.sessionId)
// Refresh token is opaque (not a JWT) and its stored form is the sha256 hash.
assert.equal(typeof out.refreshToken, 'string')
assert.ok(out.refreshToken.length >= 40)
assert.equal(out.refreshHash, sessionService.hashRefreshToken(out.refreshToken))
assert.equal(sessionService.validateBearerToken(out.refreshToken), null, 'refresh token is not a bearer session')
// Metadata + a future expiry are carried through for the controller to persist.
assert.equal(out.deviceHash, 'abc')
assert.equal(out.userAgent, 'Android')
assert.ok(out.refreshExpiresAt instanceof Date)
assert.ok(out.refreshExpiresAt.getTime() > Date.now())
})
test('access token is short-lived (mobile TTL, not the 1d web default)', () => {
const { accessToken } = sessionService.createMobileSession(USER)
const decoded = jwt.decode(accessToken)
const lifetime = decoded.exp - decoded.iat
// Default MOBILE_ACCESS_TTL is 15m — comfortably under the 1d web session.
assert.ok(lifetime <= 15 * 60, `access token lifetime ${lifetime}s should be <= 15m`)
})
test('refreshMobileSession issues a distinct new pair (rotation)', () => {
const a = sessionService.createMobileSession(USER)
const b = sessionService.refreshMobileSession(USER)
assert.notEqual(a.refreshToken, b.refreshToken)
assert.notEqual(a.refreshHash, b.refreshHash)
})
test('hashRefreshToken is stable and deterministic', () => {
assert.equal(sessionService.hashRefreshToken('token-xyz'), sessionService.hashRefreshToken('token-xyz'))
assert.notEqual(sessionService.hashRefreshToken('a'), sessionService.hashRefreshToken('b'))
// sha256 hex is 64 chars.
assert.equal(sessionService.hashRefreshToken('anything').length, 64)
})
test('validateBearerToken rejects a TOTP challenge and garbage', () => {
const challenge = sessionService.createPartialSession(USER)
assert.equal(sessionService.validateBearerToken(challenge), null)
assert.equal(sessionService.validateBearerToken('not-a-jwt'), null)
assert.equal(sessionService.validateBearerToken(null), null)
})
test('token.signToken honors an expiresIn override, else uses the default', () => {
const short = tokenLib.signToken(USER, {}, { expiresIn: '1s' })
const shortDecoded = jwt.decode(short)
assert.equal(shortDecoded.exp - shortDecoded.iat, 1)
// No option → historical default (JWT_EXPIRES_IN, 1d) unchanged.
const dflt = jwt.decode(tokenLib.signToken(USER))
assert.equal(dflt.exp - dflt.iat, 24 * 60 * 60)
})

View File

@@ -0,0 +1,92 @@
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const GoogleProvider = require('../src/auth/providers/google.provider')
const DiscordProvider = require('../src/auth/providers/discord.provider')
const GenericOidcProvider = require('../src/auth/providers/genericOidc.provider')
const db = require('../src/utils/db')
after(() => db.close())
const realFetch = global.fetch
afterEach(() => {
global.fetch = realFetch
})
// Install a fetch stub that answers by URL substring.
function mockFetch(routes) {
global.fetch = async (url) => {
for (const [needle, payload] of Object.entries(routes)) {
if (String(url).includes(needle)) {
return { ok: true, status: 200, json: async () => payload, text: async () => JSON.stringify(payload) }
}
}
return { ok: false, status: 404, text: async () => 'not found' }
}
}
test('Google getAuthorizationUrl includes client_id, redirect_uri, scope, state, PKCE', () => {
const p = new GoogleProvider({ id: 'google', clientId: 'gid', clientSecret: 'gsecret' })
const url = p.getAuthorizationUrl('the-state', { redirectUri: 'https://app/cb', codeChallenge: 'CHAL' })
assert.ok(url.startsWith('https://accounts.google.com/o/oauth2/v2/auth?'))
const q = new URL(url).searchParams
assert.equal(q.get('client_id'), 'gid')
assert.equal(q.get('redirect_uri'), 'https://app/cb')
assert.equal(q.get('response_type'), 'code')
assert.equal(q.get('scope'), 'openid email profile')
assert.equal(q.get('state'), 'the-state')
assert.equal(q.get('code_challenge'), 'CHAL')
assert.equal(q.get('code_challenge_method'), 'S256')
})
test('Google handleCallback exchanges code and normalizes the profile', async () => {
mockFetch({
'oauth2.googleapis.com/token': { access_token: 'AT' },
'openidconnect.googleapis.com/v1/userinfo': { sub: '11550', email: 'alice@example.com', name: 'Alice' },
})
const p = new GoogleProvider({ id: 'google', clientId: 'gid', clientSecret: 'gsecret' })
const profile = await p.handleCallback({ code: 'C', redirectUri: 'https://app/cb', codeVerifier: 'V' })
assert.deepEqual(profile, { subject: '11550', email: 'alice@example.com', name: 'Alice' })
})
test('Discord authorize URL + profile mapping (global_name → name, id → subject)', async () => {
const p = new DiscordProvider({ id: 'discord', clientId: 'did', clientSecret: 'dsecret' })
const url = p.getAuthorizationUrl('s', { redirectUri: 'https://app/cb' })
assert.ok(url.startsWith('https://discord.com/oauth2/authorize?'))
assert.equal(new URL(url).searchParams.get('scope'), 'identify email')
mockFetch({
'discord.com/api/oauth2/token': { access_token: 'AT' },
'discord.com/api/users/@me': { id: '99', username: 'bob', global_name: 'Bob', email: 'bob@x.io' },
})
const profile = await p.handleCallback({ code: 'C', redirectUri: 'https://app/cb' })
assert.deepEqual(profile, { subject: '99', email: 'bob@x.io', name: 'Bob' })
})
test('Generic OIDC provider uses configured endpoints and OIDC profile fields', async () => {
const p = new GenericOidcProvider({
id: 'authentik', kind: 'oidc', clientId: 'cid', clientSecret: 'csec',
authorizeUrl: 'https://idp.example/authorize', tokenUrl: 'https://idp.example/token',
userinfoUrl: 'https://idp.example/userinfo', scopes: 'openid email',
})
const url = p.getAuthorizationUrl('s', { redirectUri: 'https://app/cb', codeChallenge: 'CH' })
assert.ok(url.startsWith('https://idp.example/authorize?'))
assert.equal(new URL(url).searchParams.get('scope'), 'openid email')
mockFetch({
'idp.example/token': { access_token: 'AT' },
'idp.example/userinfo': { sub: 'abc', email: 'c@d.e', preferred_username: 'carol' },
})
const profile = await p.handleCallback({ code: 'C', redirectUri: 'https://app/cb', codeVerifier: 'V' })
assert.deepEqual(profile, { subject: 'abc', email: 'c@d.e', name: 'carol' })
})
test('handleCallback throws when the token exchange fails', async () => {
mockFetch({}) // everything 404s
const p = new GoogleProvider({ id: 'google', clientId: 'gid', clientSecret: 'gsecret' })
await assert.rejects(() => p.handleCallback({ code: 'C', redirectUri: 'https://app/cb', codeVerifier: 'V' }))
})

View File

@@ -0,0 +1,53 @@
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after } = require('node:test')
const assert = require('node:assert/strict')
const registry = require('../src/auth/providers/registry')
const authProvidersModel = require('../src/model/authProviders/authProviders.model')
const db = require('../src/utils/db')
after(() => db.close())
test('validateConfig: built-in needs client_id + secret', () => {
assert.equal(registry.validateConfig({ kind: 'google', client_id: 'x', client_secret_enc: 'e' }).valid, true)
assert.deepEqual(registry.validateConfig({ kind: 'google', client_id: 'x' }).missing, ['client_secret'])
assert.deepEqual(registry.validateConfig({ kind: 'google' }).missing, ['client_id', 'client_secret'])
})
test('validateConfig: custom OIDC also needs the endpoint URLs', () => {
const complete = {
kind: 'oidc', client_id: 'x', client_secret_enc: 'e',
authorize_url: 'a', token_url: 't', userinfo_url: 'u',
}
assert.equal(registry.validateConfig(complete).valid, true)
const noUrls = { kind: 'oidc', client_id: 'x', client_secret_enc: 'e' }
assert.deepEqual(registry.validateConfig(noUrls).missing, ['authorize_url', 'token_url', 'userinfo_url'])
})
test('listConfigured always includes both built-ins with health', async () => {
authProvidersModel.list = async () => [] // no rows yet
const out = await registry.listConfigured()
const ids = out.map((p) => p.id)
assert.deepEqual(ids, ['google', 'discord'])
assert.equal(out[0].builtin, true)
assert.equal(out[0].enabled, 0)
assert.equal(out[0].health.valid, false) // unconfigured
})
test('listEnabledValid returns only enabled+valid, shaped and sorted by priority', async () => {
authProvidersModel.list = async () => [
{ id: 'discord', kind: 'discord', name: 'Discord', enabled: 1, client_id: 'd', client_secret_enc: 'e', priority: 2 },
{ id: 'google', kind: 'google', name: 'Google', enabled: 1, client_id: 'g', client_secret_enc: 'e', priority: 1 },
{ id: 'brokenidp', kind: 'oidc', name: 'Broken', enabled: 1, client_id: 'x', client_secret_enc: 'e', priority: 0 }, // missing URLs → hidden
{ id: 'authentik', kind: 'oidc', name: 'Authentik', enabled: 0, client_id: 'x', client_secret_enc: 'e', authorize_url: 'a', token_url: 't', userinfo_url: 'u', priority: 3 }, // disabled → hidden
]
const out = await registry.listEnabledValid()
assert.deepEqual(out.map((p) => p.id), ['google', 'discord']) // sorted by priority, broken/disabled excluded
assert.deepEqual(out[0], {
id: 'google', name: 'Google', icon: 'google', loginUrl: '/api/v1/auth/sso/google/start', priority: 1,
})
})

View File

@@ -0,0 +1,37 @@
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
const { test } = require('node:test')
const assert = require('node:assert/strict')
const secretBox = require('../src/utils/secretBox')
test('encrypt → decrypt round-trips a secret', () => {
const plain = 'super-secret-oauth-client-secret'
const enc = secretBox.encrypt(plain)
assert.notEqual(enc, plain)
assert.match(enc, /^[^:]+:[^:]+:[^:]+$/) // iv:tag:ct
assert.equal(secretBox.decrypt(enc), plain)
})
test('ciphertext differs each call (random IV) but both decrypt', () => {
const a = secretBox.encrypt('x')
const b = secretBox.encrypt('x')
assert.notEqual(a, b)
assert.equal(secretBox.decrypt(a), 'x')
assert.equal(secretBox.decrypt(b), 'x')
})
test('null/blank round-trips to null', () => {
assert.equal(secretBox.encrypt(''), null)
assert.equal(secretBox.encrypt(null), null)
assert.equal(secretBox.decrypt(null), null)
assert.equal(secretBox.decrypt(''), null)
})
test('tampered ciphertext fails authentication', () => {
const enc = secretBox.encrypt('secret')
const [iv, tag, ct] = enc.split(':')
const tampered = `${iv}:${tag}:${Buffer.from('garbage').toString('base64')}`
assert.throws(() => secretBox.decrypt(tampered))
assert.throws(() => secretBox.decrypt('only:two')) // malformed
})

124
server/test/session.test.js Normal file
View File

@@ -0,0 +1,124 @@
// Set before requiring the auth layer (token.js reads JWT_SECRET at load) and
// db.js (the users model, pulled in via the utils/auth facade, builds the pool
// at load). Pointing the DB at a closed port stops idle connections from keeping
// this process alive — none of these tests touch the database.
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after } = require('node:test')
const assert = require('node:assert/strict')
const sessionService = require('../src/auth/session.service')
const authFacade = require('../src/utils/auth')
const db = require('../src/utils/db')
after(() => db.close())
const USER = { id: 7, username: 'alice', role: 'admin' }
// Build a request double carrying a token, either as a cookie or a Bearer header.
function reqWithCookie(token) {
return { cookies: { [authFacade.COOKIE_NAME]: token }, headers: {} }
}
function reqWithBearer(token) {
return { cookies: {}, headers: { authorization: `Bearer ${token}` } }
}
test('createSession → validateSession round-trips a Session object', () => {
const { token, session } = sessionService.createSession(USER, 'local')
assert.equal(typeof token, 'string')
// The returned session object carries the canonical shape.
assert.equal(session.userId, USER.id)
assert.equal(session.username, USER.username)
assert.equal(session.role, USER.role)
assert.equal(session.authMethod, 'local')
assert.ok(session.sessionId, 'sessionId (jti) is present')
assert.equal(typeof session.createdAt, 'number')
// Validating the same token off a request yields the same identity.
const validated = sessionService.validateSession(reqWithCookie(token))
assert.ok(validated)
assert.equal(validated.userId, USER.id)
assert.equal(validated.username, USER.username)
assert.equal(validated.role, USER.role)
assert.equal(validated.authMethod, 'local')
assert.equal(validated.sessionId, session.sessionId)
})
test('validateSession accepts a Bearer token as well as a cookie', () => {
const { token } = sessionService.createSession(USER, 'mobile')
const validated = sessionService.validateSession(reqWithBearer(token))
assert.ok(validated)
assert.equal(validated.userId, USER.id)
assert.equal(validated.authMethod, 'mobile')
})
test('authMethod defaults to local when an unknown method is passed', () => {
const { session } = sessionService.createSession(USER, 'bogus')
assert.equal(session.authMethod, 'local')
})
test('a partial (TOTP challenge) token is NOT a valid session', () => {
const challenge = sessionService.createPartialSession(USER)
assert.equal(typeof challenge, 'string')
// Stage-tagged tokens must never validate as a full session.
assert.equal(sessionService.validateSession(reqWithCookie(challenge)), null)
assert.equal(sessionService.decodeIdentity(challenge), null)
})
test('upgradeSessionAfterTotp accepts a challenge and rejects a session token', () => {
const challenge = sessionService.createPartialSession(USER)
const decoded = sessionService.upgradeSessionAfterTotp(challenge)
assert.ok(decoded)
assert.equal(decoded.id, USER.id)
assert.equal(decoded.stage, 'totp')
// A normal session token is not a TOTP challenge — must be rejected here.
const { token } = sessionService.createSession(USER, 'local')
assert.equal(sessionService.upgradeSessionAfterTotp(token), null)
})
test('validateSession / decodeIdentity return null for missing or garbage input', () => {
assert.equal(sessionService.validateSession({ cookies: {}, headers: {} }), null)
assert.equal(sessionService.decodeIdentity(null), null)
assert.equal(sessionService.decodeIdentity('not-a-jwt'), null)
})
test('revoke / invalidate stubs report success without throwing', () => {
assert.equal(sessionService.revokeSession('sid-1'), true)
assert.equal(sessionService.invalidateSession('sid-1'), true)
assert.equal(sessionService.invalidateAllUserSessions(USER.id), true)
})
test('sessionMeta derives ip / userAgent / deviceHash from the request', () => {
const meta = sessionService.sessionMeta({ ip: '203.0.113.5', headers: { 'user-agent': 'jest' } })
assert.equal(meta.ip, '203.0.113.5')
assert.equal(meta.userAgent, 'jest')
assert.equal(typeof meta.deviceHash, 'string')
assert.ok(meta.deviceHash.length > 0)
})
test('backward-compat: utils/auth facade still exports the original API', () => {
for (const name of [
'isLoggedIn',
'requireRole',
'signToken',
'verifyToken',
'signTotpChallenge',
'verifyTotpChallenge',
'setAuthCookie',
'clearAuthCookie',
'getUserFromRequest',
]) {
assert.equal(typeof authFacade[name], 'function', `${name} is exported as a function`)
}
assert.equal(typeof authFacade.COOKIE_NAME, 'string')
// getUserFromRequest still returns the historical { id, username, role } shape.
const { token } = sessionService.createSession(USER, 'local')
const decoded = authFacade.getUserFromRequest(reqWithCookie(token))
assert.deepEqual(decoded, { id: USER.id, username: USER.username, role: USER.role })
assert.equal(authFacade.getUserFromRequest({ cookies: {}, headers: {} }), null)
})

View File

@@ -0,0 +1,116 @@
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, after } = require('node:test')
const assert = require('node:assert/strict')
const ssoCtrl = require('../src/router/v1/auth/sso.controller')
const ssoState = require('../src/auth/ssoState')
const token = require('../src/auth/token')
// Modules whose methods we stub (exports are plain objects → mutable in-process).
const users = require('../src/model/users/users.model')
const activity = require('../src/model/activity/activity.model')
const authProviders = require('../src/model/authProviders/authProviders.model')
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
const registry = require('../src/auth/providers/registry')
const db = require('../src/utils/db')
after(() => db.close())
const GOOGLE_ROW = { id: 'google', kind: 'google', name: 'Google', enabled: 1, client_id: 'cid', client_secret_enc: 'enc' }
const PROFILE = { subject: 'sub-1', email: 'alice@example.com', name: 'Alice' }
let logged
beforeEach(() => {
logged = []
activity.log = async (evt) => { logged.push(evt) }
authProviders.getWithSecret = async () => ({ ...GOOGLE_ROW })
// Bypass real OAuth network calls: the provider just yields a fixed profile.
registry.instantiate = () => ({ handleCallback: async () => ({ ...PROFILE }) })
userIdentities.findByProviderSubject = async () => null
userIdentities.link = async () => 1
users.getById = async (id) => ({ id, username: 'alice', role: 'admin' })
users.recordLogin = async () => {} // avoid the real DB on the success path
})
function mockRes() {
return {
statusCode: 200, redirectedTo: null, cookies: {}, cleared: [],
status(c) { this.statusCode = c; return this },
json(b) { this.body = b; return this },
redirect(u) { this.redirectedTo = u; return this },
cookie(n, v) { this.cookies[n] = v; return this },
clearCookie(n) { this.cleared.push(n); return this },
}
}
function makeReq(tx, { state, code = 'auth-code' } = {}) {
return {
params: { provider: 'google' },
cookies: { [ssoState.TX_COOKIE]: tx.txToken },
query: { state: state ?? tx.nonce, code },
ip: '127.0.0.1', protocol: 'http', get: () => 'localhost', headers: {},
}
}
test('linked identity → session cookie set, redirect to /admin, login logged', async () => {
userIdentities.findByProviderSubject = async () => ({ user_id: 7 })
const tx = ssoState.createTx({ provider: 'google', mode: 'login' })
const res = mockRes()
await ssoCtrl.callback(makeReq(tx), res)
assert.ok(res.cookies[token.COOKIE_NAME], 'session cookie was set')
assert.equal(res.redirectedTo, '/admin')
assert.ok(res.cleared.includes(ssoState.TX_COOKIE), 'tx cookie cleared')
assert.equal(logged.at(-1).action, 'auth.sso.login')
})
test('linked identity honors a safe returnTo', async () => {
userIdentities.findByProviderSubject = async () => ({ user_id: 7 })
const tx = ssoState.createTx({ provider: 'google', mode: 'login', returnTo: '/admin/posts' })
const res = mockRes()
await ssoCtrl.callback(makeReq(tx), res)
assert.equal(res.redirectedTo, '/admin/posts')
})
test('UNLINKED identity → no session, redirect to not_linked (link-only policy)', async () => {
userIdentities.findByProviderSubject = async () => null
const tx = ssoState.createTx({ provider: 'google', mode: 'login' })
const res = mockRes()
await ssoCtrl.callback(makeReq(tx), res)
assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'no session cookie')
assert.equal(res.redirectedTo, '/admin/login?sso_error=not_linked')
assert.equal(logged.length, 0)
})
test('link mode → identity linked to the acting user, redirect to account', async () => {
let linkArgs = null
userIdentities.link = async (args) => { linkArgs = args; return 1 }
const tx = ssoState.createTx({ provider: 'google', mode: 'link', linkUserId: 5 })
const res = mockRes()
await ssoCtrl.callback(makeReq(tx), res)
assert.deepEqual(linkArgs, { userId: 5, provider: 'google', subject: 'sub-1', email: 'alice@example.com' })
assert.equal(res.redirectedTo, '/admin/account?linked=google')
assert.equal(logged.at(-1).action, 'auth.sso.link')
assert.equal(res.cookies[token.COOKIE_NAME], undefined, 'linking does not start a session')
})
test('link mode refuses an identity already owned by another user', async () => {
userIdentities.findByProviderSubject = async () => ({ user_id: 999 })
const tx = ssoState.createTx({ provider: 'google', mode: 'link', linkUserId: 5 })
const res = mockRes()
await ssoCtrl.callback(makeReq(tx), res)
assert.equal(res.redirectedTo, '/admin/account?link_error=in_use')
})
test('bad state (CSRF) → rejected before any provider work', async () => {
const tx = ssoState.createTx({ provider: 'google', mode: 'login' })
const res = mockRes()
await ssoCtrl.callback(makeReq(tx, { state: 'tampered-nonce' }), res)
assert.equal(res.redirectedTo, '/admin/login?sso_error=bad_state')
assert.equal(res.cookies[token.COOKIE_NAME], undefined)
})

View File

@@ -0,0 +1,38 @@
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
const { test } = require('node:test')
const assert = require('node:assert/strict')
const crypto = require('crypto')
const ssoState = require('../src/auth/ssoState')
test('createTx → verifyTx round-trips the flow payload', () => {
const tx = ssoState.createTx({ provider: 'google', mode: 'login', returnTo: '/admin/posts' })
assert.ok(tx.nonce && tx.verifier && tx.codeChallenge && tx.txToken)
const payload = ssoState.verifyTx(tx.txToken, tx.nonce)
assert.ok(payload)
assert.equal(payload.provider, 'google')
assert.equal(payload.mode, 'login')
assert.equal(payload.returnTo, '/admin/posts')
assert.equal(payload.verifier, tx.verifier)
})
test('codeChallenge is the S256 hash of the verifier', () => {
const tx = ssoState.createTx({ provider: 'discord', mode: 'login' })
const expected = crypto.createHash('sha256').update(tx.verifier).digest('base64url')
assert.equal(tx.codeChallenge, expected)
})
test('verifyTx rejects a mismatched / tampered nonce', () => {
const tx = ssoState.createTx({ provider: 'google', mode: 'login' })
assert.equal(ssoState.verifyTx(tx.txToken, 'wrong-nonce'), null)
assert.equal(ssoState.verifyTx(tx.txToken, null), null)
assert.equal(ssoState.verifyTx(null, tx.nonce), null)
})
test('verifyTx rejects a non-tx token', () => {
const token = require('../src/auth/token')
const notTx = token.signToken({ id: 1, username: 'a', role: 'admin' })
assert.equal(ssoState.verifyTx(notTx, 'anything'), null)
})