From 31b31c3a1759540961d202d361d1d59a2ef95775 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 10:31:29 -0500 Subject: [PATCH] Add session abstraction, mobile bearer auth, and pluggable SSO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor authentication into a provider-agnostic session layer and build two new auth surfaces on top of it, without changing local password/TOTP behavior. Every flow now issues sessions through sessionService.createSession(user, authMethod). Part 1 — Session abstraction (backward-compatible refactor): - New server/src/auth/: token.js (JWT/cookie primitives), session.service.js (create/validate/partial-TOTP/revoke), session.middleware.js (attachSession/requireAuth/requireRole). utils/auth.js is now a thin compat facade so existing imports are unchanged. Part 2 — Mobile bearer auth (additive): - /api/v1/auth/mobile/{login,refresh,logout}: short-lived access JWT + long-lived refresh token, stored hashed and rotated on use, in a new mobile_refresh_tokens table. Reuses web bot-scoring/backoff; single-request TOTP. token.signToken gains a backward-compatible expiresIn option. Part 3 — Pluggable SSO (Google, Discord, generic OIDC): - OAuth2Provider base + built-in Google/Discord (fixed endpoints) + generic OIDC, a registry with health/validation, PKCE+CSRF transaction state, and discovery (GET /auth/providers), start/link/callback routes. - Link-only policy: SSO signs in only to an already-linked account; external identities are never auto-provisioned. Client secrets encrypted at rest (AES-256-GCM, utils/secretBox.js). Admin CRUD (/admin/auth/providers) and account linking (/admin/account/identities). New auth_providers + user_identities tables. Frontend: - Login page renders provider buttons from /auth/providers (inline SVG icons, graceful with zero providers). New Authentication admin view (Local/Google/Discord/Custom). Account page linked-accounts section. Tests: 83 passing (session, mobile, providers, registry, secretBox, ssoState, ssoCallback) — all DB-free via fetch mocks + model stubs. README + .env.example updated. Co-Authored-By: Claude Opus 4.8 --- README.md | 62 ++- client/src/App.jsx | 2 + client/src/api/client.js | 12 + client/src/components/ProviderIcon.jsx | 37 ++ client/src/routes/admin/AdminLayout.jsx | 2 + client/src/routes/admin/AdminLogin.jsx | 81 +++- .../src/routes/admin/views/AccountAdmin.jsx | 118 +++++- .../routes/admin/views/AuthProvidersAdmin.jsx | 380 ++++++++++++++++++ server/.env.example | 15 + server/db/schema.sql | 57 +++ server/src/auth/providers/base.provider.js | 65 +++ server/src/auth/providers/discord.provider.js | 30 ++ .../auth/providers/genericOidc.provider.js | 38 ++ server/src/auth/providers/google.provider.js | 34 ++ server/src/auth/providers/local.provider.js | 27 ++ server/src/auth/providers/oauth2.provider.js | 115 ++++++ server/src/auth/providers/registry.js | 128 ++++++ server/src/auth/session.middleware.js | 65 +++ server/src/auth/session.service.js | 218 ++++++++++ server/src/auth/ssoState.js | 59 +++ server/src/auth/token.js | 131 ++++++ server/src/middleware/rateLimit.js | 21 +- .../model/authProviders/authProviders.db.js | 36 ++ .../authProviders/authProviders.model.js | 50 +++ .../model/mobileSessions/mobileSessions.db.js | 62 +++ .../mobileSessions/mobileSessions.model.js | 40 ++ .../model/userIdentities/userIdentities.db.js | 38 ++ .../userIdentities/userIdentities.model.js | 27 ++ .../src/router/v1/admin/account.controller.js | 31 +- server/src/router/v1/admin/admin.routes.js | 53 +++ .../v1/admin/authProviders.controller.js | 121 ++++++ server/src/router/v1/auth/auth.controller.js | 29 +- server/src/router/v1/auth/auth.routes.js | 9 + .../src/router/v1/auth/mobile.controller.js | 143 +++++++ server/src/router/v1/auth/mobile.routes.js | 48 +++ server/src/router/v1/auth/sso.controller.js | 176 ++++++++ server/src/router/v1/auth/sso.routes.js | 22 + server/src/utils/auth.js | 168 ++------ server/src/utils/secretBox.js | 55 +++ server/test/mobileSession.test.js | 81 ++++ server/test/providers.test.js | 92 +++++ server/test/registry.test.js | 53 +++ server/test/secretBox.test.js | 37 ++ server/test/session.test.js | 124 ++++++ server/test/ssoCallback.test.js | 116 ++++++ server/test/ssoState.test.js | 38 ++ 46 files changed, 3169 insertions(+), 177 deletions(-) create mode 100644 client/src/components/ProviderIcon.jsx create mode 100644 client/src/routes/admin/views/AuthProvidersAdmin.jsx create mode 100644 server/src/auth/providers/base.provider.js create mode 100644 server/src/auth/providers/discord.provider.js create mode 100644 server/src/auth/providers/genericOidc.provider.js create mode 100644 server/src/auth/providers/google.provider.js create mode 100644 server/src/auth/providers/local.provider.js create mode 100644 server/src/auth/providers/oauth2.provider.js create mode 100644 server/src/auth/providers/registry.js create mode 100644 server/src/auth/session.middleware.js create mode 100644 server/src/auth/session.service.js create mode 100644 server/src/auth/ssoState.js create mode 100644 server/src/auth/token.js create mode 100644 server/src/model/authProviders/authProviders.db.js create mode 100644 server/src/model/authProviders/authProviders.model.js create mode 100644 server/src/model/mobileSessions/mobileSessions.db.js create mode 100644 server/src/model/mobileSessions/mobileSessions.model.js create mode 100644 server/src/model/userIdentities/userIdentities.db.js create mode 100644 server/src/model/userIdentities/userIdentities.model.js create mode 100644 server/src/router/v1/admin/authProviders.controller.js create mode 100644 server/src/router/v1/auth/mobile.controller.js create mode 100644 server/src/router/v1/auth/mobile.routes.js create mode 100644 server/src/router/v1/auth/sso.controller.js create mode 100644 server/src/router/v1/auth/sso.routes.js create mode 100644 server/src/utils/secretBox.js create mode 100644 server/test/mobileSession.test.js create mode 100644 server/test/providers.test.js create mode 100644 server/test/registry.test.js create mode 100644 server/test/secretBox.test.js create mode 100644 server/test/session.test.js create mode 100644 server/test/ssoCallback.test.js create mode 100644 server/test/ssoState.test.js diff --git a/README.md b/README.md index 14dfce5..9ab8dc6 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Public site, wiki, and protected admin panel for the **UOMysticmoon** private Ultima Online 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). - **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 | |---|---| | 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) | | Frontend | React 18, Vite 5, React Router 6 | | Email | Nodemailer (SMTP) with a `mailto:` fallback | @@ -51,18 +51,19 @@ UOMSITE/ │ ├─ src/ │ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0) │ │ ├─ app.js middleware + static SPA + routes -│ │ ├─ router/v1/ auth / public / admin route groups -│ │ ├─ model/ users · posts · wiki · settings · activity (.model + .db) +│ │ ├─ auth/ session layer: session.service · token (JWT/cookies) · session.middleware · ssoState (PKCE/CSRF) · providers/ (base · oauth2 · google · discord · genericOidc · registry) +│ │ ├─ 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 -│ │ └─ 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 │ └─ .env.example ├─ client/ React + Vite SPA │ ├─ src/ │ │ ├─ routes/public/ Portal, Website, News, Screenshots, FiveOnFriday, Newsletter(+Issue), Status, About, Maintenance │ │ ├─ routes/wiki/ Wiki landing + WikiArticle -│ │ ├─ routes/admin/ AdminLogin, AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Bot Activity, Users, Account) + editors -│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, … +│ │ ├─ 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, ProviderIcon (inline SSO SVGs), … │ │ ├─ contexts/ AuthContext, SiteContext │ │ ├─ api/client.js fetch wrapper (sends cookies) │ │ └─ 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/activity` | Activity log | | `/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/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 | |---|---|---| -| 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 | -| 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`. +`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`. 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_NAME` / `DB_USER` / `DB_PASSWORD` | `uomysticmoon` / `uomm` / — | app database credentials | | `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) | -| `JWT_SECRET` | — | **required** — long random string | -| `JWT_EXPIRES_IN` | `1d` | token + cookie lifetime | +| `JWT_SECRET` | — | **required** — long random string; signs session, mobile, and SSO-flow tokens | +| `JWT_EXPIRES_IN` | `1d` | web session token + cookie lifetime | | `COOKIE_SECURE` | `auto` | `auto` = Secure only over HTTPS (works on LAN HTTP + Pangolin HTTPS) | | `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 | | `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 | @@ -239,11 +248,36 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`. **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. - 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. -- **Role-based authorization** — admin-only endpoints (users, site mode, settings) are gated by a - `requireRole` check, so a lower-privilege editor can't reach them. +- **Role-based authorization** — admin-only endpoints (users, site mode, settings, auth providers) + 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** diff --git a/client/src/App.jsx b/client/src/App.jsx index 5e2a0f1..c0af506 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -27,6 +27,7 @@ import HeroEditor from './routes/admin/views/HeroEditor.jsx' import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx' import ActivityAdmin from './routes/admin/views/ActivityAdmin.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 AccountAdmin from './routes/admin/views/AccountAdmin.jsx' @@ -73,6 +74,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index ae8194a..d05a040 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -47,6 +47,8 @@ export const api = { loginTotp: (challenge, code) => req('/auth/login/totp', { method: 'POST', body: { challenge, code } }), logout: () => req('/auth/logout', { method: 'POST' }), + // Public SSO provider discovery — drives the login-page provider buttons. + authProviders: () => req('/auth/providers'), // ----- public ----- publicSettings: () => req('/public/settings'), @@ -120,6 +122,16 @@ export const api = { totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }), totpEnable: (code) => req('/admin/account/totp/enable', { 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' }), }, } diff --git a/client/src/components/ProviderIcon.jsx b/client/src/components/ProviderIcon.jsx new file mode 100644 index 0000000..5b8cd57 --- /dev/null +++ b/client/src/components/ProviderIcon.jsx @@ -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 ( + + ) +} + +function DiscordMark({ size }) { + return ( + + ) +} + +function GenericMark({ size }) { + return ( + + ) +} + +export default function ProviderIcon({ icon, size = 18 }) { + if (icon === 'google') return + if (icon === 'discord') return + return +} diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 7618320..cb921ca 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -12,6 +12,7 @@ const NAV = [ { to: '/admin/settings', label: 'Settings' }, { to: '/admin/activity', label: 'Activity' }, { to: '/admin/bot-activity', label: 'Bot Activity' }, + { to: '/admin/auth-providers', label: 'Authentication' }, { to: '/admin/users', label: 'Users' }, { to: '/admin/account', label: 'Account' }, ] @@ -24,6 +25,7 @@ const TITLES = { '/admin/settings': 'Site Settings', '/admin/activity': 'Activity Log', '/admin/bot-activity': 'Bot Activity', + '/admin/auth-providers': 'Authentication', '/admin/users': 'Users', '/admin/account': 'Account Security', } diff --git a/client/src/routes/admin/AdminLogin.jsx b/client/src/routes/admin/AdminLogin.jsx index 8c35487..0954c8d 100644 --- a/client/src/routes/admin/AdminLogin.jsx +++ b/client/src/routes/admin/AdminLogin.jsx @@ -1,7 +1,18 @@ import { useEffect, useState } from 'react' import { Link, useNavigate, useLocation } from 'react-router-dom' import MoonDot from '../../components/MoonDot.jsx' +import ProviderIcon from '../../components/ProviderIcon.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 = "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 [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. useEffect(() => { if (user) navigate(dest, { replace: true }) }, [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) { e.preventDefault() setError('') @@ -174,9 +210,9 @@ export default function AdminLogin() { )} - {error && ( -

- {error} + {(error || (stage === 'creds' && ssoError)) && ( +

+ {error || ssoError}

)} @@ -188,6 +224,45 @@ export default function AdminLogin() { > {busy ? 'Signing in…' : stage === 'totp' ? 'Verify' : 'Sign in'} + + {/* SSO providers — only on the credentials step, only if any are enabled. */} + {stage === 'creds' && providers.length > 0 && ( +
+
+ + or + +
+
+ {providers.map((p) => ( + + ))} +
+
+ )} +

Protected area — not indexed. Sessions expire after 1 day.

diff --git a/client/src/routes/admin/views/AccountAdmin.jsx b/client/src/routes/admin/views/AccountAdmin.jsx index ab0fbca..ea998cf 100644 --- a/client/src/routes/admin/views/AccountAdmin.jsx +++ b/client/src/routes/admin/views/AccountAdmin.jsx @@ -1,7 +1,121 @@ -import { useEffect, useState } from 'react' +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' +// 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 + if (!linked) return null + + const linkedIds = new Set(linked.map((i) => i.provider)) + const linkable = available.filter((p) => !linkedIds.has(p.id)) + + return ( +
+

+ Linked accounts +

+

+ 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. +

+ + {banner && ( +

+ {banner.text} +

+ )} + + {linked.length > 0 && ( +
+ {linked.map((i) => ( +
+ + + +
+
{nameFor(i.provider)}
+ {i.email &&
{i.email}
} +
+ +
+ ))} +
+ )} + + {linkable.length > 0 && ( +
+ {linkable.map((p) => ( + + ))} +
+ )} + + {linked.length === 0 && linkable.length === 0 && ( +

+ No SSO providers are enabled. Configure them under Authentication. +

+ )} +
+ ) +} + // Self-service account security: enable / disable optional TOTP two-factor. export default function AccountAdmin() { const [account, setAccount] = useState(null) @@ -187,6 +301,8 @@ export default function AccountAdmin() { {msg &&

{msg}

} {error &&

{error}

} + + ) } diff --git a/client/src/routes/admin/views/AuthProvidersAdmin.jsx b/client/src/routes/admin/views/AuthProvidersAdmin.jsx new file mode 100644 index 0000000..b7c27a0 --- /dev/null +++ b/client/src/routes/admin/views/AuthProvidersAdmin.jsx @@ -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 ( +

+ Enabled but incomplete (missing: {provider.health.missing.join(', ')}). Hidden from the login + page until fully configured. +

+ ) +} + +function CallbackHint({ id }) { + return ( +
+ Redirect / callback URL (register this with the provider) + + {callbackUrl(id)} + +
+ ) +} + +function Toggle({ checked, onChange, label }) { + return ( + + ) +} + +// ── 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 ( +
+
+ + + +

+ {provider.name} +

+
+ + + + + + + + + + +
+ + {msg && {msg}} + {error && {error}} +
+
+ ) +} + +// ── Local accounts (informational) ───────────────────────────────────────── +function LocalInfo() { + return ( +
+

+ Local accounts +

+

+ Username & 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 + Users, and your own two-factor under Account. +

+
+ ) +} + +// ── 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 ( +
+

+ {isNew ? 'Add custom provider' : `Edit ${initial.name}`} +

+ {isNew && ( +
+ + +
+ )} + +
+ + +
+ + + +
+ + +
+ setF((p) => ({ ...p, enabled: v }))} label="Enabled" /> + {!isNew && } +
+ + + {error && {error}} +
+
+ ) +} + +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 ( +
+
+

+ OAuth2 / OIDC providers (Authentik, Keycloak, Okta, Azure AD, Zitadel, …) +

+ {!editing && ( + + )} +
+ + {items.length === 0 && !editing && ( +

No custom providers yet.

+ )} + + {items.length > 0 && ( +
+ + + + + + + + + + {items.map((p) => ( + + + + + + + ))} + +
NameTypeStatus +
{p.name}{p.kind} + {p.enabled && p.health.valid ? ( + Live + ) : p.enabled ? ( + Incomplete + ) : ( + Disabled + )} + + setEditing(p)}>Edit + del(p)} style={{ marginLeft: 14, color: '#d98b84' }}>Delete +
+
+ )} + + {editing && ( + setEditing(null)} + onDone={async () => { + setEditing(null) + await onChanged() + }} + /> + )} +
+ ) +} + +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 + if (!providers) return + + const byId = (id) => providers.find((p) => p.id === id) + const customs = providers.filter((p) => !p.builtin) + + return ( +
+
+ {TABS.map((t) => ( + + ))} +
+ + {tab === 'local' && } + {tab === 'google' && } + {tab === 'discord' && } + {tab === 'custom' && } +
+ ) +} diff --git a/server/.env.example b/server/.env.example index 1fff7f0..98a71e1 100644 --- a/server/.env.example +++ b/server/.env.example @@ -23,6 +23,21 @@ JWT_EXPIRES_IN=1d COOKIE_SECURE=auto 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" # (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 diff --git a/server/db/schema.sql b/server/db/schema.sql index 9c74697..faf363b 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -119,6 +119,63 @@ CREATE TABLE IF NOT EXISTS activity_log ( INDEX idx_activity_created (created_at) ) 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 -- 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. diff --git a/server/src/auth/providers/base.provider.js b/server/src/auth/providers/base.provider.js new file mode 100644 index 0000000..093acb8 --- /dev/null +++ b/server/src/auth/providers/base.provider.js @@ -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 diff --git a/server/src/auth/providers/discord.provider.js b/server/src/auth/providers/discord.provider.js new file mode 100644 index 0000000..76f192b --- /dev/null +++ b/server/src/auth/providers/discord.provider.js @@ -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 diff --git a/server/src/auth/providers/genericOidc.provider.js b/server/src/auth/providers/genericOidc.provider.js new file mode 100644 index 0000000..bc1c0ac --- /dev/null +++ b/server/src/auth/providers/genericOidc.provider.js @@ -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 diff --git a/server/src/auth/providers/google.provider.js b/server/src/auth/providers/google.provider.js new file mode 100644 index 0000000..5597bd4 --- /dev/null +++ b/server/src/auth/providers/google.provider.js @@ -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 diff --git a/server/src/auth/providers/local.provider.js b/server/src/auth/providers/local.provider.js new file mode 100644 index 0000000..a21ecc6 --- /dev/null +++ b/server/src/auth/providers/local.provider.js @@ -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 diff --git a/server/src/auth/providers/oauth2.provider.js b/server/src/auth/providers/oauth2.provider.js new file mode 100644 index 0000000..3fc9854 --- /dev/null +++ b/server/src/auth/providers/oauth2.provider.js @@ -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 diff --git a/server/src/auth/providers/registry.js b/server/src/auth/providers/registry.js new file mode 100644 index 0000000..f150596 --- /dev/null +++ b/server/src/auth/providers/registry.js @@ -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, +} diff --git a/server/src/auth/session.middleware.js b/server/src/auth/session.middleware.js new file mode 100644 index 0000000..d539fac --- /dev/null +++ b/server/src/auth/session.middleware.js @@ -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, +} diff --git a/server/src/auth/session.service.js b/server/src/auth/session.service.js new file mode 100644 index 0000000..547fffd --- /dev/null +++ b/server/src/auth/session.service.js @@ -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, +} diff --git a/server/src/auth/ssoState.js b/server/src/auth/ssoState.js new file mode 100644 index 0000000..33ef40a --- /dev/null +++ b/server/src/auth/ssoState.js @@ -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 } diff --git a/server/src/auth/token.js b/server/src/auth/token.js new file mode 100644 index 0000000..d88efc3 --- /dev/null +++ b/server/src/auth/token.js @@ -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, +} diff --git a/server/src/middleware/rateLimit.js b/server/src/middleware/rateLimit.js index 283ff1f..b7a0a0c 100644 --- a/server/src/middleware/rateLimit.js +++ b/server/src/middleware/rateLimit.js @@ -32,4 +32,23 @@ const contactLimiter = makeLimiter({ 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 } diff --git a/server/src/model/authProviders/authProviders.db.js b/server/src/model/authProviders/authProviders.db.js new file mode 100644 index 0000000..bca6052 --- /dev/null +++ b/server/src/model/authProviders/authProviders.db.js @@ -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 } diff --git a/server/src/model/authProviders/authProviders.model.js b/server/src/model/authProviders/authProviders.model.js new file mode 100644 index 0000000..ad6ff23 --- /dev/null +++ b/server/src/model/authProviders/authProviders.model.js @@ -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 } diff --git a/server/src/model/mobileSessions/mobileSessions.db.js b/server/src/model/mobileSessions/mobileSessions.db.js new file mode 100644 index 0000000..26271d4 --- /dev/null +++ b/server/src/model/mobileSessions/mobileSessions.db.js @@ -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, +} diff --git a/server/src/model/mobileSessions/mobileSessions.model.js b/server/src/model/mobileSessions/mobileSessions.model.js new file mode 100644 index 0000000..6e58c1f --- /dev/null +++ b/server/src/model/mobileSessions/mobileSessions.model.js @@ -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, +} diff --git a/server/src/model/userIdentities/userIdentities.db.js b/server/src/model/userIdentities/userIdentities.db.js new file mode 100644 index 0000000..b24b8cc --- /dev/null +++ b/server/src/model/userIdentities/userIdentities.db.js @@ -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 } diff --git a/server/src/model/userIdentities/userIdentities.model.js b/server/src/model/userIdentities/userIdentities.model.js new file mode 100644 index 0000000..8971788 --- /dev/null +++ b/server/src/model/userIdentities/userIdentities.model.js @@ -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 } diff --git a/server/src/router/v1/admin/account.controller.js b/server/src/router/v1/admin/account.controller.js index bce172b..bacd71e 100644 --- a/server/src/router/v1/admin/account.controller.js +++ b/server/src/router/v1/admin/account.controller.js @@ -4,6 +4,7 @@ const users = require('../../../model/users/users.model') const activity = require('../../../model/activity/activity.model') +const userIdentities = require('../../../model/userIdentities/userIdentities.model') const totp = require('../../../utils/totp') 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 } diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index 39475f0..336e313 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -8,6 +8,7 @@ const { body, param } = require('express-validator') const ctrl = require('./admin.controller') const account = require('./account.controller') const botActivity = require('./botActivity.controller') +const authProviders = require('./authProviders.controller') const { isLoggedIn, requireRole } = require('../../../utils/auth') const noindex = require('../../../middleware/noindex') const validate = require('../../../middleware/validate') @@ -38,6 +39,15 @@ adminRouter.post( 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) ─────────────────────────────── const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads') @@ -191,6 +201,49 @@ adminRouter.post( 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) ────────────────────────────────────── adminRouter.use('/users', adminOnly) adminRouter.get('/users', ctrl.listUsers) diff --git a/server/src/router/v1/admin/authProviders.controller.js b/server/src/router/v1/admin/authProviders.controller.js new file mode 100644 index 0000000..3f99d94 --- /dev/null +++ b/server/src/router/v1/admin/authProviders.controller.js @@ -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 } diff --git a/server/src/router/v1/auth/auth.controller.js b/server/src/router/v1/auth/auth.controller.js index 54afcd9..423bc81 100644 --- a/server/src/router/v1/auth/auth.controller.js +++ b/server/src/router/v1/auth/auth.controller.js @@ -1,12 +1,7 @@ const users = require('../../../model/users/users.model') const activity = require('../../../model/activity/activity.model') -const { - signToken, - setAuthCookie, - clearAuthCookie, - signTotpChallenge, - verifyTotpChallenge, -} = require('../../../utils/auth') +const { setAuthCookie, clearAuthCookie } = require('../../../auth/token') +const sessionService = require('../../../auth/session.service') const totp = require('../../../utils/totp') const botScore = require('../../../middleware/botScore') const loginProtection = require('../../../middleware/loginProtection') @@ -26,15 +21,17 @@ function needsTotp(user) { return Boolean(user && user.totp_enabled) } -// Issue the real session: sign the JWT, set the cookie, clear the IP's failure -// backoff, and record the login. -async function issueSession(req, res, user) { +// Issue the real session: create the session token via the session service, set +// the cookie, clear the IP's failure backoff, and record the login. authMethod +// 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) await users.recordLogin(user.id) - const token = signToken(user) + const { token } = sessionService.createSession(user, authMethod) setAuthCookie(req, res, token) 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 } }) } @@ -64,12 +61,12 @@ async function login(req, res) { // hand back a short-lived, signed "password verified" challenge and require // the code. If TOTP is off, log them straight in. 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 }) return res.json({ totpRequired: true, challenge }) } - return issueSession(req, res, user) + return issueSession(req, res, user, 'local') } catch (err) { log.error('login error', err) 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). async function loginTotp(req, res) { const { challenge, code } = req.body - const decoded = verifyTotpChallenge(challenge) + const decoded = sessionService.upgradeSessionAfterTotp(challenge) if (!decoded) { 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 }) return res.status(401).json({ message: 'Invalid verification code.' }) } - return issueSession(req, res, user) + return issueSession(req, res, user, 'totp') } catch (err) { log.error('loginTotp error', err) return res.status(500).json({ message: 'Internal Server Error' }) diff --git a/server/src/router/v1/auth/auth.routes.js b/server/src/router/v1/auth/auth.routes.js index 77c9ed4..03f4052 100644 --- a/server/src/router/v1/auth/auth.routes.js +++ b/server/src/router/v1/auth/auth.routes.js @@ -6,9 +6,18 @@ const { isLoggedIn } = require('../../../utils/auth') const { loginLimiter } = require('../../../middleware/rateLimit') const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection') const validate = require('../../../middleware/validate') +const mobileRouter = require('./mobile.routes') +const ssoRouter = require('./sso.routes') 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): // backoffGuard → per-IP exponential lockout on repeated failures // slowLogin → progressive per-request delay within the window diff --git a/server/src/router/v1/auth/mobile.controller.js b/server/src/router/v1/auth/mobile.controller.js new file mode 100644 index 0000000..bf6c905 --- /dev/null +++ b/server/src/router/v1/auth/mobile.controller.js @@ -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 } diff --git a/server/src/router/v1/auth/mobile.routes.js b/server/src/router/v1/auth/mobile.routes.js new file mode 100644 index 0000000..6cf3009 --- /dev/null +++ b/server/src/router/v1/auth/mobile.routes.js @@ -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 diff --git a/server/src/router/v1/auth/sso.controller.js b/server/src/router/v1/auth/sso.controller.js new file mode 100644 index 0000000..9775702 --- /dev/null +++ b/server/src/router/v1/auth/sso.controller.js @@ -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 } diff --git a/server/src/router/v1/auth/sso.routes.js b/server/src/router/v1/auth/sso.routes.js new file mode 100644 index 0000000..f094ae2 --- /dev/null +++ b/server/src/router/v1/auth/sso.routes.js @@ -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 diff --git a/server/src/utils/auth.js b/server/src/utils/auth.js index 5b6aa40..64d1761 100644 --- a/server/src/utils/auth.js +++ b/server/src/utils/auth.js @@ -1,150 +1,36 @@ -const jwt = require('jsonwebtoken') -require('dotenv').config() +// ── Auth compatibility facade ────────────────────────────────────────────── +// +// 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 users = require('../model/users/users.model') +const token = require('../auth/token') +const sessionService = require('../auth/session.service') +const { requireAuth, requireRole } = require('../auth/session.middleware') -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() - -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. +// Non-rejecting identity check. Returns the decoded token payload (with `.id`) +// or null — same shape callers relied on (siteMode only truthiness-checks it). +// Backed by the session service so there is a single validation path. function getUserFromRequest(req) { - const token = extractToken(req) - if (!token) return null - const decoded = verifyToken(token) - if (!decoded || decoded.stage) return null - 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' }) - } + const session = sessionService.validateSession(req) + if (!session) return null + // Preserve the historical payload shape (id/username/role) for callers. + return { id: session.userId, username: session.username, role: session.role } } module.exports = { - COOKIE_NAME, - signToken, - verifyToken, - signTotpChallenge, - verifyTotpChallenge, - setAuthCookie, - clearAuthCookie, + COOKIE_NAME: token.COOKIE_NAME, + // Token primitives (re-exported from auth/token.js). + signToken: token.signToken, + verifyToken: token.verifyToken, + signTotpChallenge: token.signTotpChallenge, + verifyTotpChallenge: token.verifyTotpChallenge, + setAuthCookie: token.setAuthCookie, + clearAuthCookie: token.clearAuthCookie, + // Request helpers / middleware. getUserFromRequest, - isLoggedIn, + isLoggedIn: requireAuth, // old name → new middleware, identical behavior requireRole, } diff --git a/server/src/utils/secretBox.js b/server/src/utils/secretBox.js new file mode 100644 index 0000000..19be4b4 --- /dev/null +++ b/server/src/utils/secretBox.js @@ -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 } diff --git a/server/test/mobileSession.test.js b/server/test/mobileSession.test.js new file mode 100644 index 0000000..ad6aa0f --- /dev/null +++ b/server/test/mobileSession.test.js @@ -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) +}) diff --git a/server/test/providers.test.js b/server/test/providers.test.js new file mode 100644 index 0000000..23ecc90 --- /dev/null +++ b/server/test/providers.test.js @@ -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' })) +}) diff --git a/server/test/registry.test.js b/server/test/registry.test.js new file mode 100644 index 0000000..f3550a8 --- /dev/null +++ b/server/test/registry.test.js @@ -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, + }) +}) diff --git a/server/test/secretBox.test.js b/server/test/secretBox.test.js new file mode 100644 index 0000000..0289b8a --- /dev/null +++ b/server/test/secretBox.test.js @@ -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 +}) diff --git a/server/test/session.test.js b/server/test/session.test.js new file mode 100644 index 0000000..37bb01b --- /dev/null +++ b/server/test/session.test.js @@ -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) +}) diff --git a/server/test/ssoCallback.test.js b/server/test/ssoCallback.test.js new file mode 100644 index 0000000..1c96429 --- /dev/null +++ b/server/test/ssoCallback.test.js @@ -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) +}) diff --git a/server/test/ssoState.test.js b/server/test/ssoState.test.js new file mode 100644 index 0000000..abd1dce --- /dev/null +++ b/server/test/ssoState.test.js @@ -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) +})