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) => (
+
startSso(p)}
+ className="btn"
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 10,
+ width: '100%',
+ borderRadius: 8,
+ padding: 11,
+ border: '1px solid var(--line)',
+ background: 'rgba(255,255,255,0.04)',
+ color: 'var(--ink)',
+ }}
+ >
+
+
+
+ Continue with {p.name}
+
+ ))}
+
+
+ )}
+
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}
}
+
+
unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
+ Unlink
+
+
+ ))}
+
+ )}
+
+ {linkable.length > 0 && (
+
+ {linkable.map((p) => (
+
window.location.assign(`/api/v1/auth/sso/${p.id}/link`)}
+ className="btn"
+ style={{ display: 'flex', alignItems: 'center', gap: 10, justifyContent: 'center', width: '100%', maxWidth: 320, borderRadius: 8, padding: 10, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.04)', color: 'var(--ink)' }}
+ >
+
+
+
+ Link {p.name}
+
+ ))}
+
+ )}
+
+ {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 (
+
+ onChange(e.target.checked)} />
+ {label}
+
+ )
+}
+
+// ── Built-in (Google / Discord) config form ────────────────────────────────
+function BuiltinForm({ provider, onSaved }) {
+ const [enabled, setEnabled] = useState(provider.enabled)
+ const [clientId, setClientId] = useState(provider.clientId || '')
+ const [secret, setSecret] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [msg, setMsg] = useState('')
+ const [error, setError] = useState('')
+
+ // Re-sync when switching between provider tabs.
+ useEffect(() => {
+ setEnabled(provider.enabled)
+ setClientId(provider.clientId || '')
+ setSecret('')
+ setMsg('')
+ setError('')
+ }, [provider.id]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ async function save() {
+ setBusy(true)
+ setMsg('')
+ setError('')
+ try {
+ const body = { enabled, clientId }
+ if (secret) body.secret = secret // only send a new secret when entered
+ await api.admin.updateAuthProvider(provider.id, body)
+ setSecret('')
+ setMsg('Saved.')
+ await onSaved()
+ } catch (err) {
+ setError(err.message || 'Could not save.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ {provider.name}
+
+
+
+
+
+
+
+ Client ID
+ setClientId(e.target.value)} className="input" autoComplete="off" />
+
+
+
+ Client Secret
+ setSecret(e.target.value)}
+ className="input"
+ autoComplete="new-password"
+ placeholder={provider.hasSecret ? '•••••••• configured — leave blank to keep' : 'Client secret'}
+ />
+
+
+
+
+
+
+ {busy ? 'Saving…' : 'Save changes'}
+
+ {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 (
+
+ )
+}
+
+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 && (
+
setEditing('new')} className="btn btn-primary btn-sq">+ Add provider
+ )}
+
+
+ {items.length === 0 && !editing && (
+
No custom providers yet.
+ )}
+
+ {items.length > 0 && (
+
+
+
+
+ Name
+ Type
+ Status
+
+
+
+
+ {items.map((p) => (
+
+ {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) => (
+ setTab(t.id)}
+ className="sans"
+ style={{
+ padding: '9px 16px',
+ border: 'none',
+ background: 'transparent',
+ cursor: 'pointer',
+ fontSize: '0.9rem',
+ color: tab === t.id ? 'var(--head)' : 'var(--muted)',
+ borderBottom: `2px solid ${tab === t.id ? 'var(--accent)' : 'transparent'}`,
+ marginBottom: -1,
+ }}
+ >
+ {t.label}
+
+ ))}
+
+
+ {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)
+})