Skip to content

Commit cd98ede

Browse files
authored
feat: allow api login w/ kf auth (#3620)
1 parent d4c4626 commit cd98ede

5 files changed

Lines changed: 404 additions & 77 deletions

File tree

server/kf/api.ts

Lines changed: 2 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,13 @@ docker service logs auth_auth --tail 50 2>&1 | grep -i "error\|invalid\|authoriz
2020

2121
import { timingSafeEqual } from 'crypto';
2222
import { Router } from 'express';
23-
import { Op } from 'sequelize';
2423
import { promisify } from 'util';
2524

2625
import { Collection, Community, Member, Pub, PubAttribution, Release, User } from 'server/models';
2726
import { sequelize } from 'server/sequelize';
2827
import { getHashedUserId } from 'utils/caching/getHashedUserId';
2928
import { ensureUserIsCommunityAdmin } from 'utils/ensureUserIsCommunityAdmin';
3029
import { isDevelopment, isDuqDuq, isProd } from 'utils/environment';
31-
import { slugifyString } from 'utils/strings';
3230

3331
import {
3432
buildAuthorizeUrl,
@@ -40,6 +38,7 @@ import {
4038
generateCodeVerifier,
4139
OIDC_ISSUER_URL,
4240
} from './auth';
41+
import { provisionLocalUser } from './provisionLocalUser';
4342

4443
// ── Helpers ──────────────────────────────────────────────────────────
4544

@@ -150,45 +149,7 @@ router.get('/auth/callback', async (req: any, res: any) => {
150149
const userInfo = await fetchUserInfo(tokens.access_token);
151150
const kfUserId = userInfo.sub;
152151

153-
// Look up PubPub user by ID, or auto-create on first login
154-
let user = await User.findOne({ where: { id: kfUserId } });
155-
156-
if (!user) {
157-
const firstName = (userInfo.given_name || userInfo.name || 'New').trim();
158-
const lastName = (userInfo.family_name || 'User').trim();
159-
const fullName = `${firstName} ${lastName}`;
160-
const initials = `${firstName[0] || '?'}${lastName[0] || '?'}`;
161-
const baseSlug = slugifyString(fullName) || 'user';
162-
const existingSlugCount = await User.count({
163-
where: { slug: { [Op.like]: `${baseSlug}%` } },
164-
});
165-
const slug = existingSlugCount ? `${baseSlug}-${existingSlugCount + 1}` : baseSlug;
166-
167-
// Use KF Auth email if available and not already taken
168-
let email = `${kfUserId}@placeholder.invalid`;
169-
if (userInfo.email) {
170-
const emailTaken = await User.findOne({
171-
where: { email: userInfo.email.toLowerCase() },
172-
});
173-
if (!emailTaken) {
174-
email = userInfo.email.toLowerCase();
175-
}
176-
}
177-
178-
user = await User.create({
179-
id: kfUserId,
180-
slug,
181-
firstName,
182-
lastName,
183-
fullName,
184-
initials,
185-
email,
186-
avatar: userInfo.picture || null,
187-
hash: '',
188-
salt: '',
189-
} as any);
190-
console.log(`Auto-created PubPub user ${user.id} (${user.slug}) from KF Auth`);
191-
}
152+
const user = await provisionLocalUser(kfUserId, userInfo);
192153

193154
const protocol = isDevelopment() ? 'http' : 'https';
194155

server/kf/provisionLocalUser.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { OIDCUserInfo } from './oidc.server';
2+
3+
import { Op } from 'sequelize';
4+
5+
import { User } from 'server/models';
6+
import { slugifyString } from 'utils/strings';
7+
8+
/**
9+
* Look up the local PubPub `User` row that corresponds to a kf-auth subject,
10+
* auto-creating it from kf-auth userinfo on first contact.
11+
*
12+
* Both the OIDC `/auth/callback` flow and the legacy `/api/login` SDK bridge
13+
* funnel users through here so the side effects (slug allocation, placeholder
14+
* email handling, console logging) stay identical.
15+
*
16+
* `kfUserId` must equal the `sub` claim from kf-auth's userinfo / JWT — PubPub
17+
* stores it verbatim as `User.id` since the migration kept UUIDs aligned.
18+
*/
19+
export async function provisionLocalUser(
20+
kfUserId: string,
21+
userInfo: Partial<
22+
Pick<OIDCUserInfo, 'name' | 'email' | 'picture' | 'given_name' | 'family_name'>
23+
>,
24+
): Promise<InstanceType<typeof User>> {
25+
const existing = await User.findOne({ where: { id: kfUserId } });
26+
if (existing) return existing;
27+
28+
const firstName = (userInfo.given_name || userInfo.name || 'New').trim();
29+
const lastName = (userInfo.family_name || 'User').trim();
30+
const fullName = `${firstName} ${lastName}`;
31+
const initials = `${firstName[0] || '?'}${lastName[0] || '?'}`;
32+
const baseSlug = slugifyString(fullName) || 'user';
33+
const existingSlugCount = await User.count({
34+
where: { slug: { [Op.like]: `${baseSlug}%` } },
35+
});
36+
const slug = existingSlugCount ? `${baseSlug}-${existingSlugCount + 1}` : baseSlug;
37+
38+
// Prefer the kf-auth email if it's unique on PubPub; otherwise stash a
39+
// placeholder so the row still satisfies the not-null constraint and the
40+
// user can update it later.
41+
let email = `${kfUserId}@placeholder.invalid`;
42+
if (userInfo.email) {
43+
const emailTaken = await User.findOne({
44+
where: { email: userInfo.email.toLowerCase() },
45+
});
46+
if (!emailTaken) {
47+
email = userInfo.email.toLowerCase();
48+
}
49+
}
50+
51+
const created = await User.create({
52+
id: kfUserId,
53+
slug,
54+
firstName,
55+
lastName,
56+
fullName,
57+
initials,
58+
email,
59+
avatar: userInfo.picture || null,
60+
hash: '',
61+
salt: '',
62+
} as any);
63+
console.log(`Auto-created PubPub user ${created.id} (${created.slug}) from KF Auth`);
64+
return created;
65+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import supertest from 'supertest';
2+
import { vi } from 'vitest';
3+
4+
import { SpamTag, User } from 'server/models';
5+
import { modelize, setup, teardown } from 'stubstub';
6+
7+
import { __appImmutableListenOnly } from '../../server';
8+
9+
const normalEmail = `${crypto.randomUUID()}@email.com`;
10+
const restrictedEmail = `${crypto.randomUUID()}@email.com`;
11+
12+
const models = modelize`
13+
Community community {
14+
Member {
15+
permissions: "admin"
16+
User legacyUser {
17+
email: ${normalEmail}
18+
}
19+
}
20+
Member {
21+
permissions: "admin"
22+
User restrictedUser {
23+
email: ${restrictedEmail}
24+
}
25+
}
26+
}
27+
`;
28+
29+
setup(beforeAll, async () => {
30+
await models.resolve();
31+
});
32+
33+
teardown(afterAll);
34+
35+
const AUTH_URL = 'http://kf-auth.test';
36+
const AUTH_KEY = 'test-internal-key';
37+
const ENDPOINT = '/api/internal/legacy-pubpub-login';
38+
39+
function jsonResponse(body: unknown, status = 200): Response {
40+
return new Response(JSON.stringify(body), {
41+
status,
42+
headers: { 'Content-Type': 'application/json' },
43+
});
44+
}
45+
46+
beforeEach(() => {
47+
vi.stubEnv('AUTH_INTERNAL_API_URL', AUTH_URL);
48+
vi.stubEnv('AUTH_INTERNAL_API_KEY', AUTH_KEY);
49+
});
50+
51+
afterEach(() => {
52+
vi.unstubAllEnvs();
53+
vi.restoreAllMocks();
54+
});
55+
56+
describe('/api/login (kf-auth handshake)', () => {
57+
it('verifies via the internal endpoint and establishes a PubPub session', async () => {
58+
const { legacyUser } = models;
59+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
60+
if (String(url).endsWith(ENDPOINT)) {
61+
return jsonResponse({ verified: true, userId: legacyUser.id });
62+
}
63+
throw new Error(`Unexpected fetch: ${url}`);
64+
});
65+
66+
const server = __appImmutableListenOnly.listen();
67+
try {
68+
const res = await supertest(server)
69+
.post('/api/login')
70+
.send({ email: legacyUser.email, password: 'sha3-hex-payload' })
71+
.expect(201);
72+
73+
expect(res.headers.deprecation).toBe('true');
74+
expect(res.headers.sunset).toBeTruthy();
75+
const cookies = (res.headers['set-cookie'] as unknown as string[]) ?? [];
76+
expect(cookies.some((c) => c.startsWith('connect.sid='))).toBe(true);
77+
expect(cookies.some((c) => c.startsWith('pp-lic='))).toBe(true);
78+
79+
const call = fetchSpy.mock.calls.find(([u]) => String(u).endsWith(ENDPOINT));
80+
expect(call).toBeDefined();
81+
const init = call![1] as RequestInit;
82+
expect((init.headers as Record<string, string>).Authorization).toBe(
83+
`Bearer ${AUTH_KEY}`,
84+
);
85+
const body = JSON.parse(String(init.body));
86+
expect(body).toEqual({
87+
email: legacyUser.email,
88+
prehashedPassword: 'sha3-hex-payload',
89+
});
90+
} finally {
91+
server.close();
92+
}
93+
});
94+
95+
it('returns 401 when kf-auth reports verified:false (wrong password or unknown user)', async () => {
96+
const { legacyUser } = models;
97+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({ verified: false }));
98+
99+
const server = __appImmutableListenOnly.listen();
100+
try {
101+
const res = await supertest(server)
102+
.post('/api/login')
103+
.send({ email: legacyUser.email, password: 'sha3-wrong' })
104+
.expect(401);
105+
expect(res.body).toBe('Login attempt failed');
106+
} finally {
107+
server.close();
108+
}
109+
});
110+
111+
it('returns 410 when kf-auth reports the hash has been migrated past pubpub-format', async () => {
112+
const { legacyUser } = models;
113+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({ migrated: true }, 410));
114+
115+
const server = __appImmutableListenOnly.listen();
116+
try {
117+
const res = await supertest(server)
118+
.post('/api/login')
119+
.send({ email: legacyUser.email, password: 'sha3-hex' })
120+
.expect(410);
121+
expect(res.text).toMatch(/API token/i);
122+
} finally {
123+
server.close();
124+
}
125+
});
126+
127+
it('returns 403 when the local PubPub account is flagged as confirmed spam', async () => {
128+
const { restrictedUser } = models;
129+
const tag = await SpamTag.create({
130+
userId: restrictedUser.id,
131+
status: 'confirmed-spam',
132+
spamScore: 100,
133+
spamScoreComputedAt: new Date(),
134+
fields: { manuallyMarkedBy: [] },
135+
} as any);
136+
await User.update({ spamTagId: tag.id }, { where: { id: restrictedUser.id } });
137+
138+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
139+
jsonResponse({ verified: true, userId: restrictedUser.id }),
140+
);
141+
142+
const server = __appImmutableListenOnly.listen();
143+
try {
144+
const res = await supertest(server)
145+
.post('/api/login')
146+
.send({ email: restrictedUser.email, password: 'sha3-hex' })
147+
.expect(403);
148+
expect(res.text).toMatch(/restricted/i);
149+
} finally {
150+
server.close();
151+
}
152+
});
153+
154+
it('auto-creates the local PubPub user when kf-auth returns an unknown id', async () => {
155+
const newId = crypto.randomUUID();
156+
const newEmail = `${crypto.randomUUID()}@auto.created`;
157+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
158+
jsonResponse({ verified: true, userId: newId }),
159+
);
160+
161+
const before = await User.findOne({ where: { id: newId } });
162+
expect(before).toBeNull();
163+
164+
const server = __appImmutableListenOnly.listen();
165+
try {
166+
await supertest(server)
167+
.post('/api/login')
168+
.send({ email: newEmail, password: 'sha3-hex' })
169+
.expect(201);
170+
} finally {
171+
server.close();
172+
}
173+
174+
const after = await User.findOne({ where: { id: newId } });
175+
expect(after).not.toBeNull();
176+
expect(after!.email).toBe(newEmail);
177+
});
178+
});

0 commit comments

Comments
 (0)