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>
This commit is contained in:
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
143
server/src/router/v1/auth/mobile.controller.js
Normal file
143
server/src/router/v1/auth/mobile.controller.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// ── Mobile (Android) bearer-token auth ─────────────────────────────────────
|
||||
//
|
||||
// Purely additive alongside the web cookie flow. Native clients POST credentials
|
||||
// here and receive a short-lived access token (a normal session JWT, validated
|
||||
// on every route by the shared requireAuth middleware) plus a long-lived,
|
||||
// server-stored, revocable refresh token. This controller reuses the exact same
|
||||
// brute-force defenses as web login (bot scoring + login backoff), and handles
|
||||
// TOTP in a single stateless request: if 2FA is on and no/invalid code is given,
|
||||
// it replies { totpRequired: true } and the app retries with the code.
|
||||
//
|
||||
// It does NOT touch the web login/loginTotp handlers or the TOTP staged-challenge
|
||||
// flow — those are unchanged.
|
||||
|
||||
const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
|
||||
const sessionService = require('../../../auth/session.service')
|
||||
const totp = require('../../../utils/totp')
|
||||
const botScore = require('../../../middleware/botScore')
|
||||
const loginProtection = require('../../../middleware/loginProtection')
|
||||
|
||||
const log = require('../../../utils/logger')('auth-mobile')
|
||||
|
||||
// Same generic failure text as web — never reveals which credential was wrong.
|
||||
const GENERIC_FAIL = { message: 'Incorrect username or password.' }
|
||||
|
||||
// Shape returned to the client on a successful login/refresh. Access + refresh
|
||||
// tokens, the access lifetime, and the safe (secret-stripped) user.
|
||||
function tokenResponse(out, user) {
|
||||
return {
|
||||
accessToken: out.accessToken,
|
||||
refreshToken: out.refreshToken,
|
||||
expiresIn: out.expiresIn,
|
||||
user: { id: user.id, username: user.username, role: user.role },
|
||||
}
|
||||
}
|
||||
|
||||
// Persist a freshly minted refresh token (by hash) and record the login. Shared
|
||||
// by login and refresh so the storage/side-effect logic lives in one place.
|
||||
async function persistAndFinish(req, user, out, action) {
|
||||
await mobileSessions.store({
|
||||
userId: user.id,
|
||||
tokenHash: out.refreshHash,
|
||||
deviceHash: out.deviceHash,
|
||||
userAgent: out.userAgent,
|
||||
expiresAt: out.refreshExpiresAt,
|
||||
})
|
||||
await users.recordLogin(user.id)
|
||||
await activity.log({ req, userId: user.id, action })
|
||||
}
|
||||
|
||||
// POST /auth/mobile/login { username, password, code? }
|
||||
async function login(req, res) {
|
||||
const { username, password, code } = req.body
|
||||
try {
|
||||
const user = await users.getRawByUsername(username)
|
||||
const ok = user && (await users.validatePassword(user, password))
|
||||
if (!ok) {
|
||||
botScore.recordLoginFailure(req.ip)
|
||||
loginProtection.recordFailure(req.ip)
|
||||
log.warn('mobile login failed', { username, ip: req.ip })
|
||||
return res.status(401).json(GENERIC_FAIL)
|
||||
}
|
||||
|
||||
// Second factor, single-request style: if 2FA is enabled, a valid code must
|
||||
// accompany this request. Missing or wrong → tell the app to prompt + retry.
|
||||
// A wrong code is a real failed attempt (scored + backed off like web).
|
||||
if (user.totp_enabled) {
|
||||
if (!code || !totp.verifyCode(user.totp_secret, code)) {
|
||||
if (code) {
|
||||
botScore.recordLoginFailure(req.ip)
|
||||
loginProtection.recordFailure(req.ip)
|
||||
log.warn('mobile TOTP verify failed', { id: user.id, ip: req.ip })
|
||||
}
|
||||
return res.status(401).json({ totpRequired: true, message: 'A verification code is required.' })
|
||||
}
|
||||
}
|
||||
|
||||
loginProtection.recordSuccess(req.ip)
|
||||
const meta = sessionService.sessionMeta(req)
|
||||
const out = sessionService.createMobileSession(user, meta)
|
||||
await persistAndFinish(req, user, out, 'auth.mobile.login')
|
||||
log.info('mobile login success', { username: user.username, id: user.id, ip: req.ip })
|
||||
return res.json(tokenResponse(out, user))
|
||||
} catch (err) {
|
||||
log.error('mobile login error', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /auth/mobile/refresh { refreshToken }
|
||||
// Validates the presented refresh token, rotates it (revoke old + issue new),
|
||||
// and returns a fresh access + refresh pair. Rotation means a stolen-and-used
|
||||
// refresh token is single-use: the legitimate client's next refresh fails and
|
||||
// surfaces the compromise.
|
||||
async function refresh(req, res) {
|
||||
const { refreshToken } = req.body
|
||||
try {
|
||||
const hash = sessionService.hashRefreshToken(refreshToken)
|
||||
const row = await mobileSessions.findValidByHash(hash)
|
||||
if (!row) {
|
||||
log.warn('mobile refresh rejected (unknown/expired/revoked)', { ip: req.ip })
|
||||
return res.status(401).json({ message: 'Invalid or expired session. Please sign in again.' })
|
||||
}
|
||||
const user = await users.getById(row.user_id) // fresh row; 401 if user gone
|
||||
if (!user) {
|
||||
await mobileSessions.revokeByHash(hash)
|
||||
return res.status(401).json({ message: 'Invalid or expired session. Please sign in again.' })
|
||||
}
|
||||
|
||||
await mobileSessions.revokeByHash(hash) // rotate: old token is now dead
|
||||
const meta = sessionService.sessionMeta(req)
|
||||
const out = sessionService.refreshMobileSession(user, meta)
|
||||
await persistAndFinish(req, user, out, 'auth.mobile.refresh')
|
||||
log.info('mobile session refreshed', { id: user.id, ip: req.ip })
|
||||
return res.json(tokenResponse(out, user))
|
||||
} catch (err) {
|
||||
log.error('mobile refresh error', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /auth/mobile/logout { refreshToken?, all? }
|
||||
// Runs behind requireAuth (bearer), so req.user is the caller. Revokes the given
|
||||
// refresh token, or every token for the user when { all: true }. Idempotent.
|
||||
async function logout(req, res) {
|
||||
const { refreshToken, all } = req.body
|
||||
try {
|
||||
if (all) {
|
||||
const n = await mobileSessions.revokeAllForUser(req.user.id)
|
||||
log.info('mobile logout (all devices)', { id: req.user.id, revoked: n })
|
||||
} else if (refreshToken) {
|
||||
await mobileSessions.revokeByHash(sessionService.hashRefreshToken(refreshToken))
|
||||
}
|
||||
await activity.log({ req, userId: req.user.id, action: 'auth.mobile.logout' })
|
||||
return res.json({ message: 'Logged out.' })
|
||||
} catch (err) {
|
||||
log.error('mobile logout error', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { login, refresh, logout }
|
||||
48
server/src/router/v1/auth/mobile.routes.js
Normal file
48
server/src/router/v1/auth/mobile.routes.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const express = require('express')
|
||||
const { body } = require('express-validator')
|
||||
|
||||
const { login, refresh, logout } = require('./mobile.controller')
|
||||
const { requireAuth } = require('../../../auth/session.middleware')
|
||||
const { loginLimiter, mobileRefreshLimiter } = require('../../../middleware/rateLimit')
|
||||
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
const mobileRouter = express.Router()
|
||||
|
||||
// Mobile login is a credential surface too, so it sits behind the SAME guards as
|
||||
// web login (cheapest rejection first): per-IP backoff → progressive slowdown →
|
||||
// hard rate cap.
|
||||
const loginGuards = [backoffGuard, slowLogin, loginLimiter]
|
||||
|
||||
// POST /auth/mobile/login — { username, password, code? }
|
||||
mobileRouter.post(
|
||||
'/login',
|
||||
...loginGuards,
|
||||
body('username').isString().trim().notEmpty(),
|
||||
body('password').isString().notEmpty(),
|
||||
// Optional TOTP code (single-request 2FA); only checked when the account has 2FA on.
|
||||
body('code').optional().isString().trim().isLength({ min: 6, max: 8 }),
|
||||
validate,
|
||||
login,
|
||||
)
|
||||
|
||||
// POST /auth/mobile/refresh — { refreshToken }
|
||||
mobileRouter.post(
|
||||
'/refresh',
|
||||
mobileRefreshLimiter,
|
||||
body('refreshToken').isString().notEmpty(),
|
||||
validate,
|
||||
refresh,
|
||||
)
|
||||
|
||||
// POST /auth/mobile/logout — { refreshToken?, all? } — requires a valid bearer.
|
||||
mobileRouter.post(
|
||||
'/logout',
|
||||
requireAuth,
|
||||
body('refreshToken').optional().isString(),
|
||||
body('all').optional().isBoolean(),
|
||||
validate,
|
||||
logout,
|
||||
)
|
||||
|
||||
module.exports = mobileRouter
|
||||
176
server/src/router/v1/auth/sso.controller.js
Normal file
176
server/src/router/v1/auth/sso.controller.js
Normal file
@@ -0,0 +1,176 @@
|
||||
// ── SSO (OAuth2 / OIDC) controller ─────────────────────────────────────────
|
||||
//
|
||||
// Drives the redirect flow for built-in (Google, Discord) and custom providers:
|
||||
// GET /auth/providers → public discovery (enabled + valid providers)
|
||||
// GET /auth/sso/:provider/start → begin login (redirect to the IdP)
|
||||
// GET /auth/sso/:provider/link → begin account linking (requireAuth)
|
||||
// GET /auth/sso/:provider/callback → exchange code, then log in OR link
|
||||
//
|
||||
// LINK-ONLY policy: a login succeeds only if the external identity is already
|
||||
// linked to an internal account. Unknown identities are refused, never
|
||||
// auto-provisioned. Every successful login goes through sessionService, so the
|
||||
// resulting session is identical to a local login (same cookie, logging, RBAC).
|
||||
|
||||
const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const authProviders = require('../../../model/authProviders/authProviders.model')
|
||||
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
|
||||
const registry = require('../../../auth/providers/registry')
|
||||
const sessionService = require('../../../auth/session.service')
|
||||
const ssoState = require('../../../auth/ssoState')
|
||||
const token = require('../../../auth/token')
|
||||
|
||||
const log = require('../../../utils/logger')('sso')
|
||||
|
||||
const PROVIDER_ID_RE = /^[a-z0-9-]+$/
|
||||
|
||||
// Redirect targets (front-end routes). Errors surface as a query param the login
|
||||
// / account pages can render.
|
||||
const loginError = (code) => `/admin/login?sso_error=${code}`
|
||||
const accountError = (code) => `/admin/account?link_error=${code}`
|
||||
|
||||
// Only allow returning to an internal /admin path (prevents open redirect).
|
||||
function sanitizeReturn(returnTo) {
|
||||
if (typeof returnTo === 'string' && /^\/admin(?:[/?]|$)/.test(returnTo) && !returnTo.startsWith('//')) {
|
||||
return returnTo
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Public base URL used to build the OAuth redirect_uri. Prefer APP_BASE_URL;
|
||||
// fall back to the request's own origin with a warning if it is unset.
|
||||
function appBaseUrl(req) {
|
||||
const configured = process.env.APP_BASE_URL
|
||||
if (configured) return configured.replace(/\/+$/, '')
|
||||
const derived = `${req.protocol}://${req.get('host')}`
|
||||
log.warn('APP_BASE_URL not set — deriving redirect_uri from the request', { derived })
|
||||
return derived
|
||||
}
|
||||
function redirectUriFor(req, providerId) {
|
||||
return `${appBaseUrl(req)}/api/v1/auth/sso/${providerId}/callback`
|
||||
}
|
||||
|
||||
// httpOnly cookie carrying the signed tx (nonce + PKCE verifier + mode). Reuse the
|
||||
// app's standard cookie options (httpOnly, sameSite=lax, secure=auto) + a TTL.
|
||||
function txCookieOptions(req) {
|
||||
return { ...token.cookieOptions(req), maxAge: 10 * 60 * 1000 }
|
||||
}
|
||||
|
||||
// GET /auth/providers — public discovery. Never touches secrets.
|
||||
async function listProviders(req, res) {
|
||||
try {
|
||||
return res.json(await registry.listEnabledValid())
|
||||
} catch (err) {
|
||||
log.error('listProviders', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Shared start for both login and link. `mode` ∈ 'login' | 'link'. For link,
|
||||
// requireAuth has already run so req.user is the account to attach the identity to.
|
||||
async function beginFlow(req, res, mode) {
|
||||
const providerId = req.params.provider
|
||||
const failUrl = mode === 'link' ? accountError('error') : loginError('error')
|
||||
try {
|
||||
if (!PROVIDER_ID_RE.test(providerId)) return res.redirect(failUrl)
|
||||
const row = await authProviders.getWithSecret(providerId)
|
||||
if (!row || !row.enabled || !registry.validateConfig(row).valid) {
|
||||
log.warn('sso start: provider unavailable', { provider: providerId, mode })
|
||||
return res.redirect(mode === 'link' ? accountError('unavailable') : loginError('unavailable'))
|
||||
}
|
||||
const provider = registry.instantiate(row)
|
||||
const tx = ssoState.createTx({
|
||||
provider: providerId,
|
||||
mode,
|
||||
linkUserId: mode === 'link' ? req.user.id : undefined,
|
||||
returnTo: sanitizeReturn(req.query.returnTo) || undefined,
|
||||
})
|
||||
res.cookie(ssoState.TX_COOKIE, tx.txToken, txCookieOptions(req))
|
||||
const url = provider.getAuthorizationUrl(tx.nonce, {
|
||||
redirectUri: redirectUriFor(req, providerId),
|
||||
codeChallenge: tx.codeChallenge,
|
||||
})
|
||||
return res.redirect(url)
|
||||
} catch (err) {
|
||||
log.error('sso start', err)
|
||||
return res.redirect(failUrl)
|
||||
}
|
||||
}
|
||||
|
||||
const start = (req, res) => beginFlow(req, res, 'login')
|
||||
const linkStart = (req, res) => beginFlow(req, res, 'link')
|
||||
|
||||
// GET /auth/sso/:provider/callback
|
||||
async function callback(req, res) {
|
||||
const providerId = req.params.provider
|
||||
const txToken = req.cookies && req.cookies[ssoState.TX_COOKIE]
|
||||
const { code, state, error: oauthError } = req.query
|
||||
// The tx cookie is single-use — clear it no matter the outcome.
|
||||
res.clearCookie(ssoState.TX_COOKIE, token.cookieOptions(req))
|
||||
|
||||
if (oauthError) {
|
||||
log.warn('sso callback: provider returned error', { provider: providerId, error: String(oauthError).slice(0, 60) })
|
||||
return res.redirect(loginError('denied'))
|
||||
}
|
||||
const tx = ssoState.verifyTx(txToken, state)
|
||||
if (!tx || tx.provider !== providerId || !code) {
|
||||
log.warn('sso callback: bad state', { provider: providerId })
|
||||
return res.redirect(loginError('bad_state'))
|
||||
}
|
||||
|
||||
try {
|
||||
const row = await authProviders.getWithSecret(providerId)
|
||||
if (!row || !row.enabled || !registry.validateConfig(row).valid) {
|
||||
return res.redirect(loginError('unavailable'))
|
||||
}
|
||||
const provider = registry.instantiate(row)
|
||||
const profile = await provider.handleCallback({
|
||||
code,
|
||||
redirectUri: redirectUriFor(req, providerId),
|
||||
codeVerifier: tx.verifier,
|
||||
})
|
||||
if (tx.mode === 'link') return finishLink(req, res, providerId, tx, profile)
|
||||
return finishLogin(req, res, providerId, row.kind, tx, profile)
|
||||
} catch (err) {
|
||||
log.error('sso callback', err)
|
||||
return res.redirect(loginError('error'))
|
||||
}
|
||||
}
|
||||
|
||||
// Link-only login: require an existing (provider, subject) identity → session.
|
||||
async function finishLogin(req, res, providerId, kind, tx, profile) {
|
||||
const identity = await userIdentities.findByProviderSubject(providerId, profile.subject)
|
||||
if (!identity) {
|
||||
log.warn('sso login refused: no linked account', { provider: providerId })
|
||||
return res.redirect(loginError('not_linked'))
|
||||
}
|
||||
const user = await users.getById(identity.user_id)
|
||||
if (!user) return res.redirect(loginError('not_linked'))
|
||||
|
||||
const authMethod = sessionService.AUTH_METHODS.includes(kind) ? kind : 'sso'
|
||||
const { token: sessionToken } = sessionService.createSession(user, authMethod)
|
||||
token.setAuthCookie(req, res, sessionToken)
|
||||
await users.recordLogin(user.id)
|
||||
await activity.log({ req, userId: user.id, action: 'auth.sso.login', detail: { provider: providerId } })
|
||||
log.info('sso login success', { provider: providerId, id: user.id, ip: req.ip })
|
||||
return res.redirect(sanitizeReturn(tx.returnTo) || '/admin')
|
||||
}
|
||||
|
||||
// Attach the external identity to the account that initiated linking (tx.linkUserId
|
||||
// was captured behind requireAuth at /link start, so the signed tx authorizes it).
|
||||
async function finishLink(req, res, providerId, tx, profile) {
|
||||
const userId = tx.linkUserId
|
||||
if (!userId) return res.redirect(loginError('error'))
|
||||
const existing = await userIdentities.findByProviderSubject(providerId, profile.subject)
|
||||
if (existing && existing.user_id !== userId) {
|
||||
return res.redirect(accountError('in_use')) // that external identity belongs to another account
|
||||
}
|
||||
if (!existing) {
|
||||
await userIdentities.link({ userId, provider: providerId, subject: profile.subject, email: profile.email })
|
||||
await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } })
|
||||
log.info('sso account linked', { provider: providerId, userId })
|
||||
}
|
||||
return res.redirect(`/admin/account?linked=${providerId}`)
|
||||
}
|
||||
|
||||
module.exports = { listProviders, start, linkStart, callback, beginFlow, finishLogin, finishLink }
|
||||
22
server/src/router/v1/auth/sso.routes.js
Normal file
22
server/src/router/v1/auth/sso.routes.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./sso.controller')
|
||||
const { requireAuth } = require('../../../auth/session.middleware')
|
||||
const { ssoStartLimiter } = require('../../../middleware/rateLimit')
|
||||
|
||||
const ssoRouter = express.Router()
|
||||
|
||||
// Public discovery — the login page reads this to render provider buttons.
|
||||
ssoRouter.get('/providers', ctrl.listProviders)
|
||||
|
||||
// Begin login (public) — redirects to the IdP.
|
||||
ssoRouter.get('/sso/:provider/start', ssoStartLimiter, ctrl.start)
|
||||
|
||||
// Begin account linking (must be signed in — the tx captures the acting user).
|
||||
ssoRouter.get('/sso/:provider/link', requireAuth, ctrl.linkStart)
|
||||
|
||||
// OAuth redirect target — completes login or linking. Not behind requireAuth:
|
||||
// the signed tx cookie authorizes link mode; login mode is link-only anyway.
|
||||
ssoRouter.get('/sso/:provider/callback', ctrl.callback)
|
||||
|
||||
module.exports = ssoRouter
|
||||
Reference in New Issue
Block a user