diff --git a/server/kf/api.ts b/server/kf/api.ts index fa6ed2a4bf..cfd0a05d0b 100644 --- a/server/kf/api.ts +++ b/server/kf/api.ts @@ -20,7 +20,6 @@ docker service logs auth_auth --tail 50 2>&1 | grep -i "error\|invalid\|authoriz import { timingSafeEqual } from 'crypto'; import { Router } from 'express'; -import { Op } from 'sequelize'; import { promisify } from 'util'; import { Collection, Community, Member, Pub, PubAttribution, Release, User } from 'server/models'; @@ -28,7 +27,6 @@ import { sequelize } from 'server/sequelize'; import { getHashedUserId } from 'utils/caching/getHashedUserId'; import { ensureUserIsCommunityAdmin } from 'utils/ensureUserIsCommunityAdmin'; import { isDevelopment, isDuqDuq, isProd } from 'utils/environment'; -import { slugifyString } from 'utils/strings'; import { buildAuthorizeUrl, @@ -40,6 +38,7 @@ import { generateCodeVerifier, OIDC_ISSUER_URL, } from './auth'; +import { provisionLocalUser } from './provisionLocalUser'; // ── Helpers ────────────────────────────────────────────────────────── @@ -150,45 +149,7 @@ router.get('/auth/callback', async (req: any, res: any) => { const userInfo = await fetchUserInfo(tokens.access_token); const kfUserId = userInfo.sub; - // Look up PubPub user by ID, or auto-create on first login - let user = await User.findOne({ where: { id: kfUserId } }); - - if (!user) { - const firstName = (userInfo.given_name || userInfo.name || 'New').trim(); - const lastName = (userInfo.family_name || 'User').trim(); - const fullName = `${firstName} ${lastName}`; - const initials = `${firstName[0] || '?'}${lastName[0] || '?'}`; - const baseSlug = slugifyString(fullName) || 'user'; - const existingSlugCount = await User.count({ - where: { slug: { [Op.like]: `${baseSlug}%` } }, - }); - const slug = existingSlugCount ? `${baseSlug}-${existingSlugCount + 1}` : baseSlug; - - // Use KF Auth email if available and not already taken - let email = `${kfUserId}@placeholder.invalid`; - if (userInfo.email) { - const emailTaken = await User.findOne({ - where: { email: userInfo.email.toLowerCase() }, - }); - if (!emailTaken) { - email = userInfo.email.toLowerCase(); - } - } - - user = await User.create({ - id: kfUserId, - slug, - firstName, - lastName, - fullName, - initials, - email, - avatar: userInfo.picture || null, - hash: '', - salt: '', - } as any); - console.log(`Auto-created PubPub user ${user.id} (${user.slug}) from KF Auth`); - } + const user = await provisionLocalUser(kfUserId, userInfo); const protocol = isDevelopment() ? 'http' : 'https'; diff --git a/server/kf/provisionLocalUser.ts b/server/kf/provisionLocalUser.ts new file mode 100644 index 0000000000..73611768dd --- /dev/null +++ b/server/kf/provisionLocalUser.ts @@ -0,0 +1,65 @@ +import type { OIDCUserInfo } from './oidc.server'; + +import { Op } from 'sequelize'; + +import { User } from 'server/models'; +import { slugifyString } from 'utils/strings'; + +/** + * Look up the local PubPub `User` row that corresponds to a kf-auth subject, + * auto-creating it from kf-auth userinfo on first contact. + * + * Both the OIDC `/auth/callback` flow and the legacy `/api/login` SDK bridge + * funnel users through here so the side effects (slug allocation, placeholder + * email handling, console logging) stay identical. + * + * `kfUserId` must equal the `sub` claim from kf-auth's userinfo / JWT — PubPub + * stores it verbatim as `User.id` since the migration kept UUIDs aligned. + */ +export async function provisionLocalUser( + kfUserId: string, + userInfo: Partial< + Pick + >, +): Promise> { + const existing = await User.findOne({ where: { id: kfUserId } }); + if (existing) return existing; + + const firstName = (userInfo.given_name || userInfo.name || 'New').trim(); + const lastName = (userInfo.family_name || 'User').trim(); + const fullName = `${firstName} ${lastName}`; + const initials = `${firstName[0] || '?'}${lastName[0] || '?'}`; + const baseSlug = slugifyString(fullName) || 'user'; + const existingSlugCount = await User.count({ + where: { slug: { [Op.like]: `${baseSlug}%` } }, + }); + const slug = existingSlugCount ? `${baseSlug}-${existingSlugCount + 1}` : baseSlug; + + // Prefer the kf-auth email if it's unique on PubPub; otherwise stash a + // placeholder so the row still satisfies the not-null constraint and the + // user can update it later. + let email = `${kfUserId}@placeholder.invalid`; + if (userInfo.email) { + const emailTaken = await User.findOne({ + where: { email: userInfo.email.toLowerCase() }, + }); + if (!emailTaken) { + email = userInfo.email.toLowerCase(); + } + } + + const created = await User.create({ + id: kfUserId, + slug, + firstName, + lastName, + fullName, + initials, + email, + avatar: userInfo.picture || null, + hash: '', + salt: '', + } as any); + console.log(`Auto-created PubPub user ${created.id} (${created.slug}) from KF Auth`); + return created; +} diff --git a/server/login/__tests__/api.kf-auth.test.ts b/server/login/__tests__/api.kf-auth.test.ts new file mode 100644 index 0000000000..af8a2c8931 --- /dev/null +++ b/server/login/__tests__/api.kf-auth.test.ts @@ -0,0 +1,178 @@ +import supertest from 'supertest'; +import { vi } from 'vitest'; + +import { SpamTag, User } from 'server/models'; +import { modelize, setup, teardown } from 'stubstub'; + +import { __appImmutableListenOnly } from '../../server'; + +const normalEmail = `${crypto.randomUUID()}@email.com`; +const restrictedEmail = `${crypto.randomUUID()}@email.com`; + +const models = modelize` + Community community { + Member { + permissions: "admin" + User legacyUser { + email: ${normalEmail} + } + } + Member { + permissions: "admin" + User restrictedUser { + email: ${restrictedEmail} + } + } + } +`; + +setup(beforeAll, async () => { + await models.resolve(); +}); + +teardown(afterAll); + +const AUTH_URL = 'http://kf-auth.test'; +const AUTH_KEY = 'test-internal-key'; +const ENDPOINT = '/api/internal/legacy-pubpub-login'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +beforeEach(() => { + vi.stubEnv('AUTH_INTERNAL_API_URL', AUTH_URL); + vi.stubEnv('AUTH_INTERNAL_API_KEY', AUTH_KEY); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +describe('/api/login (kf-auth handshake)', () => { + it('verifies via the internal endpoint and establishes a PubPub session', async () => { + const { legacyUser } = models; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => { + if (String(url).endsWith(ENDPOINT)) { + return jsonResponse({ verified: true, userId: legacyUser.id }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + const server = __appImmutableListenOnly.listen(); + try { + const res = await supertest(server) + .post('/api/login') + .send({ email: legacyUser.email, password: 'sha3-hex-payload' }) + .expect(201); + + expect(res.headers.deprecation).toBe('true'); + expect(res.headers.sunset).toBeTruthy(); + const cookies = (res.headers['set-cookie'] as unknown as string[]) ?? []; + expect(cookies.some((c) => c.startsWith('connect.sid='))).toBe(true); + expect(cookies.some((c) => c.startsWith('pp-lic='))).toBe(true); + + const call = fetchSpy.mock.calls.find(([u]) => String(u).endsWith(ENDPOINT)); + expect(call).toBeDefined(); + const init = call![1] as RequestInit; + expect((init.headers as Record).Authorization).toBe( + `Bearer ${AUTH_KEY}`, + ); + const body = JSON.parse(String(init.body)); + expect(body).toEqual({ + email: legacyUser.email, + prehashedPassword: 'sha3-hex-payload', + }); + } finally { + server.close(); + } + }); + + it('returns 401 when kf-auth reports verified:false (wrong password or unknown user)', async () => { + const { legacyUser } = models; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({ verified: false })); + + const server = __appImmutableListenOnly.listen(); + try { + const res = await supertest(server) + .post('/api/login') + .send({ email: legacyUser.email, password: 'sha3-wrong' }) + .expect(401); + expect(res.body).toBe('Login attempt failed'); + } finally { + server.close(); + } + }); + + it('returns 410 when kf-auth reports the hash has been migrated past pubpub-format', async () => { + const { legacyUser } = models; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({ migrated: true }, 410)); + + const server = __appImmutableListenOnly.listen(); + try { + const res = await supertest(server) + .post('/api/login') + .send({ email: legacyUser.email, password: 'sha3-hex' }) + .expect(410); + expect(res.text).toMatch(/API token/i); + } finally { + server.close(); + } + }); + + it('returns 403 when the local PubPub account is flagged as confirmed spam', async () => { + const { restrictedUser } = models; + const tag = await SpamTag.create({ + userId: restrictedUser.id, + status: 'confirmed-spam', + spamScore: 100, + spamScoreComputedAt: new Date(), + fields: { manuallyMarkedBy: [] }, + } as any); + await User.update({ spamTagId: tag.id }, { where: { id: restrictedUser.id } }); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ verified: true, userId: restrictedUser.id }), + ); + + const server = __appImmutableListenOnly.listen(); + try { + const res = await supertest(server) + .post('/api/login') + .send({ email: restrictedUser.email, password: 'sha3-hex' }) + .expect(403); + expect(res.text).toMatch(/restricted/i); + } finally { + server.close(); + } + }); + + it('auto-creates the local PubPub user when kf-auth returns an unknown id', async () => { + const newId = crypto.randomUUID(); + const newEmail = `${crypto.randomUUID()}@auto.created`; + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ verified: true, userId: newId }), + ); + + const before = await User.findOne({ where: { id: newId } }); + expect(before).toBeNull(); + + const server = __appImmutableListenOnly.listen(); + try { + await supertest(server) + .post('/api/login') + .send({ email: newEmail, password: 'sha3-hex' }) + .expect(201); + } finally { + server.close(); + } + + const after = await User.findOne({ where: { id: newId } }); + expect(after).not.toBeNull(); + expect(after!.email).toBe(newEmail); + }); +}); diff --git a/server/login/api.ts b/server/login/api.ts index 70f4ef5627..ad9bbf9359 100644 --- a/server/login/api.ts +++ b/server/login/api.ts @@ -8,6 +8,7 @@ import crypto from 'crypto'; import passport from 'passport'; import { promisify } from 'util'; +import { provisionLocalUser } from 'server/kf/provisionLocalUser'; import { User } from 'server/models'; import { getSpamTagForUser } from 'server/spamTag/userQueries'; import { verifyCaptchaPayload } from 'server/utils/captcha'; @@ -24,9 +25,139 @@ type LoginResult = | { status: 201; body: 'success' } | { status: 401; body: 'Login attempt failed' } | { status: 403; body: string } + | { status: 410; body: string } | { status: 500; body: string }; -const performLogin = (req: any, res: any): Promise => { +const DEPRECATION_SUNSET = 'Wed, 30 Jun 2026 23:59:59 GMT'; +const DEPRECATION_LINK = '; rel="deprecation"'; + +function applyDeprecationHeaders(res: any): void { + res.set('Deprecation', 'true'); + res.set('Sunset', DEPRECATION_SUNSET); + res.set('Link', DEPRECATION_LINK); +} + +/** + * Issue PubPub's session + cache cookie for a freshly-authenticated user. + * Shared between the legacy local verification path (test env) and the new + * kf-auth handshake path. + */ +async function establishPubPubSession(req: any, res: any, user: types.UserWithPrivateFields | any) { + const spamTag = await getSpamTagForUser(user.id); + if (spamTag?.status === 'confirmed-spam') { + const fields = spamTag.fields as UserSpamTagFields | null; + const wasAutomated = !fields?.manuallyMarkedBy?.length; + throw new Error(wasAutomated ? 'ACCOUNT_RESTRICTED_AUTOMATED' : 'ACCOUNT_RESTRICTED'); + } + + const logIn = promisify(req.logIn.bind(req)); + await logIn(user); + const hashedUserId = getHashedUserId(user); + + res.cookie('pp-lic', `pp-li-${hashedUserId}`, { + ...(isProd() && req.hostname.indexOf('pubpub.org') > -1 && { domain: '.pubpub.org' }), + ...(isDuqDuq() && req.hostname.indexOf('pubpub.org') > -1 && { domain: '.duqduq.org' }), + maxAge: 30 * 24 * 60 * 60 * 1000, + }); +} + +function mapEstablishError(err: Error): LoginResult | null { + if (err.message === 'ACCOUNT_RESTRICTED' || err.message === 'ACCOUNT_RESTRICTED_AUTOMATED') { + const isAutomated = err.message === 'ACCOUNT_RESTRICTED_AUTOMATED'; + const automatedNote = isAutomated + ? ' This action was taken by our automated spam detection systems.' + : ''; + return { + status: 403, + body: `Your account has been restricted due to activity identified as spam.${automatedNote} If you believe this is an error, please contact help@pubpub.org.`, + } as const; + } + return null; +} + +/** + * Verify the SDK's sha3-prehashed password against kf-auth's source-of-truth + * via the Bearer-authenticated internal route. We don't use Better Auth's + * public `/api/auth/sign-in/email` because (a) it enforces an Origin check + * (`MISSING_OR_NULL_ORIGIN`) intended for browsers, and (b) it would mint a + * kf-auth session we'd only have to clean up. The internal route just + * verifies and returns the userId; PubPub then provisions the local User row + * if missing and establishes its own passport session as it always has. + */ +async function performKfAuthLogin(req: any, res: any): Promise { + const apiUrl = + process.env.AUTH_INTERNAL_API_URL ?? + process.env.OIDC_ISSUER_INTERNAL_URL ?? + process.env.OIDC_ISSUER_URL ?? + ''; + const apiKey = process.env.AUTH_INTERNAL_API_KEY ?? ''; + if (!apiUrl || !apiKey) { + console.error('Legacy /api/login: AUTH_INTERNAL_API_URL/KEY not configured'); + return { status: 500, body: 'Authentication service not configured' } as const; + } + + let verifyRes: Response; + try { + verifyRes = await fetch(`${apiUrl}/api/internal/legacy-pubpub-login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + email: req.body.email, + prehashedPassword: req.body.password, + }), + }); + } catch (err: any) { + console.error('Legacy /api/login: kf-auth unreachable', err?.message ?? err); + return { status: 500, body: 'Authentication service unavailable' } as const; + } + + if (verifyRes.status === 410) { + // kf-auth says the user's stored hash is no longer in legacy pubpub: + // format (e.g. they reset their password and now have bcrypt). + // Route the SDK client to the API token UI. + return { + status: 410, + body: 'This login path is deprecated for your account. Generate an API token at /dashboard/settings/tokens to authenticate the SDK.', + } as const; + } + + if (!verifyRes.ok) { + console.error('Legacy /api/login: kf-auth verify failed', verifyRes.status); + return { status: 500, body: 'Authentication service error' } as const; + } + + let payload: { verified?: boolean; userId?: string } = {}; + try { + payload = (await verifyRes.json()) as typeof payload; + } catch { + return { status: 500, body: 'Invalid response from authentication service' } as const; + } + if (!payload.verified || !payload.userId) { + return { status: 401, body: 'Login attempt failed' } as const; + } + + const user = await provisionLocalUser(payload.userId, { email: req.body.email }); + + try { + await establishPubPubSession(req, res, user); + } catch (err: any) { + const mapped = mapEstablishError(err); + if (mapped) return mapped; + throw err; + } + return { status: 201, body: 'success' } as const; +} + +/** + * Pre-kf-auth verification flow, retained verbatim so the test harness (which + * seeds users only into PubPub's DB via passport-local-sequelize) keeps + * passing. Production environments always set `OIDC_ISSUER_URL`, so this + * branch is only reachable in tests. + */ +function performLegacyLocalLogin(req: any, res: any): Promise { const authenticate = new Promise((resolve, reject) => { passport.authenticate('local', (authErr: Error, user: types.UserWithPrivateFields) => { if (authErr) { @@ -108,47 +239,28 @@ const performLogin = (req: any, res: any): Promise => { return updatedUserData[1][0]; }) .then(async (user) => { - const spamTag = await getSpamTagForUser(user.id); - if (spamTag?.status === 'confirmed-spam') { - const fields = spamTag.fields as UserSpamTagFields | null; - const wasAutomated = !fields?.manuallyMarkedBy?.length; - throw new Error( - wasAutomated ? 'ACCOUNT_RESTRICTED_AUTOMATED' : 'ACCOUNT_RESTRICTED', - ); - } - const logIn = promisify(req.logIn.bind(req)); - await logIn(user); - const hashedUserId = getHashedUserId(user); - - res.cookie('pp-lic', `pp-li-${hashedUserId}`, { - ...(isProd() && - req.hostname.indexOf('pubpub.org') > -1 && { domain: '.pubpub.org' }), - ...(isDuqDuq() && - req.hostname.indexOf('pubpub.org') > -1 && { domain: '.duqduq.org' }), - maxAge: 30 * 24 * 60 * 60 * 1000, - }); + await establishPubPubSession(req, res, user); return { status: 201, body: 'success' } as const; }) .catch((err) => { - if ( - err.message === 'ACCOUNT_RESTRICTED' || - err.message === 'ACCOUNT_RESTRICTED_AUTOMATED' - ) { - const isAutomated = err.message === 'ACCOUNT_RESTRICTED_AUTOMATED'; - const automatedNote = isAutomated - ? ' This action was taken by our automated spam detection systems.' - : ''; - return { - status: 403, - body: `Your account has been restricted due to activity identified as spam.${automatedNote} If you believe this is an error, please contact help@pubpub.org.`, - } as const; - } + const mapped = mapEstablishError(err); + if (mapped) return mapped; const unaunthenticatedValues = ['Invalid password', 'Invalid email']; if (unaunthenticatedValues.includes(err.message)) { return { status: 401, body: 'Login attempt failed' } as const; } return { status: 500, body: err.message } as const; }); +} + +const performLogin = async (req: any, res: any): Promise => { + applyDeprecationHeaders(res); + // Production sets both — the key is the discriminator since AUTH_INTERNAL_API_URL + // falls back to OIDC_* defaults. Tests omit the key and take the legacy path. + if (process.env.AUTH_INTERNAL_API_KEY) { + return performKfAuthLogin(req, res); + } + return performLegacyLocalLogin(req, res); }; export const loginRouteImplementation: AppRouteImplementation = async ({ diff --git a/utils/api/contracts/auth.ts b/utils/api/contracts/auth.ts index 684a0d2f8f..4175f6a495 100644 --- a/utils/api/contracts/auth.ts +++ b/utils/api/contracts/auth.ts @@ -9,7 +9,13 @@ export const authRouter = { /** * `POST /api/login` * - * Login and returns authentication cookie + * Login and returns authentication cookie. + * + * @deprecated The SHA3-prehashed password flow is being retired in favour + * of API tokens (`/dashboard/settings/tokens`). The endpoint continues to + * work during the deprecation window, but accounts whose password has been + * reset through KF Auth will receive 410 Gone — those clients must + * migrate. Responses include `Deprecation`, `Sunset`, and `Link` headers. * * @access You need to be **logged in** and have access to this resource. * @@ -19,8 +25,8 @@ export const authRouter = { login: { path: '/api/login', method: 'POST', - summary: 'Login', - description: 'Login and returns authentication cookie', + summary: 'Login (deprecated)', + description: 'Login and returns authentication cookie. Deprecated: prefer API tokens.', body: z .object({ email: z.string().email(), @@ -40,6 +46,10 @@ export const authRouter = { description: 'Account restricted (e.g. marked as spam). Message is shown to the user.', }), + 410: z.string().openapi({ + description: + 'Account password has been migrated past the legacy SHA3 path. Switch to an API token.', + }), 500: z.string().openapi({}), }, }, @@ -64,6 +74,7 @@ export const authRouter = { 400: z.string(), 401: z.literal('Login attempt failed'), 403: z.string(), + 410: z.string(), 500: z.string(), }, },