Files
website/client/src/api/client.js
Claude 31b31c3a17 Add session abstraction, mobile bearer auth, and pluggable SSO
Refactor authentication into a provider-agnostic session layer and build
two new auth surfaces on top of it, without changing local password/TOTP
behavior. Every flow now issues sessions through
sessionService.createSession(user, authMethod).

Part 1 — Session abstraction (backward-compatible refactor):
- New server/src/auth/: token.js (JWT/cookie primitives), session.service.js
  (create/validate/partial-TOTP/revoke), session.middleware.js
  (attachSession/requireAuth/requireRole). utils/auth.js is now a thin
  compat facade so existing imports are unchanged.

Part 2 — Mobile bearer auth (additive):
- /api/v1/auth/mobile/{login,refresh,logout}: short-lived access JWT +
  long-lived refresh token, stored hashed and rotated on use, in a new
  mobile_refresh_tokens table. Reuses web bot-scoring/backoff; single-request
  TOTP. token.signToken gains a backward-compatible expiresIn option.

Part 3 — Pluggable SSO (Google, Discord, generic OIDC):
- OAuth2Provider base + built-in Google/Discord (fixed endpoints) + generic
  OIDC, a registry with health/validation, PKCE+CSRF transaction state, and
  discovery (GET /auth/providers), start/link/callback routes.
- Link-only policy: SSO signs in only to an already-linked account; external
  identities are never auto-provisioned. Client secrets encrypted at rest
  (AES-256-GCM, utils/secretBox.js). Admin CRUD (/admin/auth/providers) and
  account linking (/admin/account/identities). New auth_providers +
  user_identities tables.

Frontend:
- Login page renders provider buttons from /auth/providers (inline SVG icons,
  graceful with zero providers). New Authentication admin view
  (Local/Google/Discord/Custom). Account page linked-accounts section.

Tests: 83 passing (session, mobile, providers, registry, secretBox, ssoState,
ssoCallback) — all DB-free via fetch mocks + model stubs. README + .env.example
updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:31:29 -05:00

139 lines
6.2 KiB
JavaScript

// Thin fetch wrapper. Always sends cookies (httpOnly JWT) and talks to the
// same-origin API (/api/v1) — proxied to the Express server in dev.
const BASE = '/api/v1'
class ApiError extends Error {
constructor(status, message, body) {
super(message)
this.status = status
this.body = body
}
}
async function req(path, { method = 'GET', body, headers, raw } = {}) {
const opts = { method, credentials: 'include', headers: { ...headers } }
if (body !== undefined) {
if (raw) {
opts.body = body // FormData — let the browser set the content-type
} else {
opts.headers['Content-Type'] = 'application/json'
opts.body = JSON.stringify(body)
}
}
const res = await fetch(BASE + path, opts)
const text = await res.text()
const data = text ? safeParse(text) : null
if (!res.ok) {
const message = (data && data.message) || res.statusText || 'Request failed'
throw new ApiError(res.status, message, data)
}
return data
}
function safeParse(text) {
try {
return JSON.parse(text)
} catch {
return text
}
}
export const api = {
// ----- auth -----
me: () => req('/auth/me'),
// `extra` carries the honeypot field (and any future login fields).
login: (username, password, extra = {}) =>
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
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'),
status: () => req('/public/status'),
posts: (category) => req(`/public/posts/${category}`),
post: (category, idOrSlug) => req(`/public/posts/${category}/${idOrSlug}`),
wiki: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.category) qs.set('category', opts.category)
if (opts.tag) qs.set('tag', opts.tag)
if (opts.q) qs.set('q', opts.q)
const s = qs.toString()
return req(`/public/wiki${s ? `?${s}` : ''}`)
},
wikiCategories: () => req('/public/wiki/categories'),
wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`),
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
// ----- admin -----
admin: {
dashboard: () => req('/admin/dashboard'),
setSiteMode: (mode) => req('/admin/site-mode', { method: 'PUT', body: { mode } }),
listPosts: (category) => req(`/admin/posts${category ? `?category=${category}` : ''}`),
getPost: (id) => req(`/admin/posts/${id}`),
createPost: (data) => req('/admin/posts', { method: 'POST', body: data }),
updatePost: (id, data) => req(`/admin/posts/${id}`, { method: 'PUT', body: data }),
deletePost: (id) => req(`/admin/posts/${id}`, { method: 'DELETE' }),
publishPost: (id, published) =>
req(`/admin/posts/${id}/publish`, { method: 'PATCH', body: { published } }),
uploadImage: (file) => {
const fd = new FormData()
fd.append('image', file)
return req('/admin/posts/upload', { method: 'POST', body: fd, raw: true })
},
// Generalized upload for rich-text editors → { url }.
upload: (file) => {
const fd = new FormData()
fd.append('image', file)
return req('/admin/uploads', { method: 'POST', body: fd, raw: true })
},
listWiki: (params = '') => req(`/admin/wiki${params}`),
getWiki: (slug) => req(`/admin/wiki/${slug}`),
createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }),
updateWiki: (slug, data) => req(`/admin/wiki/${slug}`, { method: 'PUT', body: data }),
publishWiki: (slug, published) =>
req(`/admin/wiki/${slug}/publish`, { method: 'PATCH', body: { published } }),
deleteWiki: (slug) => req(`/admin/wiki/${slug}`, { method: 'DELETE' }),
listWikiRevisions: (slug) => req(`/admin/wiki/${slug}/revisions`),
getWikiRevision: (slug, id) => req(`/admin/wiki/${slug}/revisions/${id}`),
restoreWikiRevision: (slug, id) =>
req(`/admin/wiki/${slug}/revisions/${id}/restore`, { method: 'POST' }),
listWikiTags: () => req('/admin/wiki/tags'),
listWikiCategories: () => req('/admin/wiki/categories'),
createWikiCategory: (data) => req('/admin/wiki/categories', { method: 'POST', body: data }),
updateWikiCategory: (id, data) =>
req(`/admin/wiki/categories/${id}`, { method: 'PUT', body: data }),
deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }),
getSettings: () => req('/admin/settings'),
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
botActivity: () => req('/admin/bot-activity'),
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
listUsers: () => req('/admin/users'),
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
// ----- account security (self-service 2FA) -----
getAccount: () => req('/admin/account'),
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' }),
},
}
export { ApiError }