Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions backend/__tests__/service/signupSourceHttp.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
const express = require('express');
const request = require('supertest');
const axios = require('axios');
const User = require('../../models/User');
const OAuthLoginState = require('../../models/OAuthLoginState');
const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../utils/testUtils');

jest.mock('axios', () => ({ post: jest.fn(), get: jest.fn() }));
jest.mock('../../services/emailService', () => ({ sendEmail: jest.fn().mockResolvedValue(undefined) }));
jest.mock('../../services/communityPodService', () => ({ ensureUserInCommunityPod: jest.fn().mockResolvedValue(undefined) }));

const app = express();
app.use(express.json());
app.use('/api/auth', require('../../routes/auth'));

describe('signup source over the public HTTP auth surface', () => {
beforeAll(setupMongoDb);
afterAll(closeMongoDb);
beforeEach(() => {
process.env.GITHUB_OAUTH_CLIENT_ID = 'source-test-client';
process.env.GITHUB_OAUTH_CLIENT_SECRET = 'source-test-secret';
process.env.REGISTRATION_INVITE_ONLY = 'false';
delete process.env.AUTH_ALLOWED_EMAILS;
delete process.env.AUTH_ALLOWED_DOMAINS;
delete process.env.SMTP2GO_API_KEY;
});
afterEach(async () => {
await clearMongoDb();
jest.clearAllMocks();
delete process.env.GITHUB_OAUTH_CLIENT_ID;
delete process.env.GITHUB_OAUTH_CLIENT_SECRET;
delete process.env.REGISTRATION_INVITE_ONLY;
});

it('refuses dollar-prefixed OAuth form keys before creating server state', async () => {
const refused = await request(app).post('/api/auth/oauth/github/start').type('form')
.send('$gt=1').expect(400);
expect(refused.body).toMatchObject({ code: 'dollar_key_refused', field: 'body.$gt' });
expect(await OAuthLoginState.countDocuments({})).toBe(0);
});

it('carries bounded form attribution in server state through callback and ignores replay', async () => {
const source = { ref: 'HN', landingPath: `/${'%2F'.repeat(480)}?email=private`,
referrer: `https://news.example/${'%2F'.repeat(480)}?token=private`, firstSeenAt: '2026-01-01T00:00:00.000Z' };
expect(new URLSearchParams({ signupSource: JSON.stringify(source) }).toString().length).toBeGreaterThan(4096);
const response = await request(app).post('/api/auth/oauth/github/start').type('form')
.send({ signupSource: JSON.stringify(source) }).expect(302);
expect(response.headers.location).not.toMatch(/signupSource|landingPath|private/);
const state = new URL(response.headers.location).searchParams.get('state');
const stored = await OAuthLoginState.findOne({ state });
expect(stored.signupSource.landingPath).toBe(source.landingPath.split('?')[0]);
expect(JSON.stringify(stored.signupSource)).not.toContain('private');
expect(stored.signupSource.referrer).toBe('https://news.example');
axios.post.mockResolvedValue({ data: { access_token: 'source-test-access' } });
axios.get.mockImplementation((url) => Promise.resolve({ data: url.endsWith('/emails')
? [{ email: 'source-http@example.com', verified: true, primary: true }]
: { id: 4245, login: 'source-http' } }));
const callback = `/api/auth/oauth/github/callback?state=${state}&code=source-code`;
await request(app).get(`${callback}&signupSource=forged`).expect(302);
const user = await User.findOne({ email: 'source-http@example.com' }).select('+signupSource');
expect(user.signupSource.authMethod).toBe('github');
expect(user.signupSource.ref).toBe('hn');
const replay = await request(app).get(callback).expect(302);
expect(replay.headers.location).toContain('oauthError=state_invalid');
expect(await User.countDocuments({ email: 'source-http@example.com' })).toBe(1);
});

it('writes the password source only at registration, and never returns it in an ordinary profile read', async () => {
await request(app).post('/api/auth/register').send({ email: 'password-source@example.com',
password: 'Password123!', signupSource: { landingPath: '/v2/invite/SECRETinviteTOKEN123',
referrer: 'https://news.example/v2/invite/SECRETinviteTOKEN123', ref: 'HN' } }).expect(201);
const before = await User.findOne({ email: 'password-source@example.com' }).select('+signupSource');
expect(before.signupSource.authMethod).toBe('password');
expect(before.signupSource.landingPath).toBeUndefined();
expect(before.signupSource.referrer).toBe('https://news.example');
expect(JSON.stringify(before.signupSource)).not.toContain('SECRETinviteTOKEN123');
const login = await request(app).post('/api/auth/login').send({ email: 'password-source@example.com',
password: 'Password123!', signupSource: { ref: 'replacement' } }).expect(200);
expect(JSON.stringify(login.body)).not.toContain('signupSource');
const after = await User.findById(before._id).select('+signupSource');
expect(after.signupSource.toObject()).toEqual(before.signupSource.toObject());
});
});
65 changes: 65 additions & 0 deletions backend/__tests__/unit/controllers/oauthController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const InvitationCode = require('../../../models/InvitationCode');
jest.mock('../../../services/communityPodService', () => ({
ensureUserInCommunityPod: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../../services/emailService', () => ({ sendEmail: jest.fn().mockResolvedValue(undefined) }));
const { ensureUserInCommunityPod } = require('../../../services/communityPodService');
const oauthController = require('../../../controllers/oauthController');
const authController = require('../../../controllers/authController');
Expand Down Expand Up @@ -81,6 +82,70 @@ describe('OAuth Controller', () => {
delete process.env.AUTH_ALLOWED_DOMAINS;
});

describe('signup source round trips', () => {
it.each(['password', 'github', 'google'].flatMap((method) => [[method, false], [method, true]]))(
'persists sanitized %s first touch once, never from callback parameters (invite path: %s)', async (method, invitePath) => {
const touch = {
ref: 'HN', referrer: invitePath ? 'https://search.example/v2/invite/SECRETinviteTOKEN123' : 'https://search.example/results?email=private#token',
landingPath: invitePath ? '/v2/invite/SECRETinviteTOKEN123' : '/guides/start?invite=private#token', firstSeenAt: '2026-01-01T00:00:00.000Z',
authMethod: 'forged', recordedAt: '1900-01-01',
};
if (method === 'password') {
const res = mockRes();
await authController.register({ body: {
username: 'source-user', email: 'source@example.com', password: 'Password123!', signupSource: touch,
} }, res);
expect(res.status).toHaveBeenCalledWith(201);
} else {
if (method === 'google') {
process.env.GOOGLE_OAUTH_CLIENT_ID = 'google-client';
process.env.GOOGLE_OAUTH_CLIENT_SECRET = 'google-secret';
}
const signIn = async (source) => {
const startRes = mockRes();
await oauthController.startOAuth({ ...startReq(method), method: 'POST',
body: { signupSource: JSON.stringify(source) } }, startRes);
const location = startRes.redirect.mock.calls[0][0];
expect(location).not.toMatch(/search\.example|landingPath|signupSource|private|firstSeenAt/);
const state = new URL(location).searchParams.get('state');
const storedState = await OAuthLoginState.findOne({ state });
expect(JSON.stringify(storedState.signupSource)).not.toContain('private');
expect(JSON.stringify(storedState.signupSource)).not.toContain('SECRETinviteTOKEN123');
if (method === 'github') mockGithubProfile({ email: 'source@example.com' });
else {
axios.post.mockResolvedValueOnce({ data: { access_token: 'google-access-token' } });
axios.get.mockResolvedValue({ data: { sub: 'google-source', email: 'source@example.com',
email_verified: true, name: 'source-user' } });
}
const callbackRes = mockRes();
await oauthController.oauthCallback({ ...startReq(method),
query: { code: 'provider-code', state, signupSource: JSON.stringify({ ref: 'forged' }), ref: 'forged' } }, callbackRes);
expect(callbackRes.redirect).toHaveBeenCalledWith(expect.stringContaining('/v2/oauth/complete?'));
expect(callbackRes.redirect.mock.calls[0][0]).not.toMatch(/signupSource|firstSeenAt|search\.example|private/);
return { state, callbackRes };
};
const first = await signIn(touch);
// A replay cannot create an account or overwrite its attribution.
const replay = mockRes();
await oauthController.oauthCallback({ ...startReq(method), query: { state: first.state, code: 'replay' } }, replay);
expect(replay.redirect).toHaveBeenCalledWith(expect.stringContaining('oauthError=state_invalid'));
await signIn({ ref: 'later', landingPath: '/later' });
}
const user = await User.findOne({ email: 'source@example.com' }).select('+signupSource');
expect(user.signupSource.toObject()).toEqual({
ref: 'hn', referrer: 'https://search.example', ...(invitePath ? {} : { landingPath: '/guides/start' }),
firstSeenAt: new Date('2026-01-01T00:00:00.000Z'), authMethod: method, recordedAt: expect.any(Date),
});
expect(user.onboarding.signupRef).toBe('hn');
expect((await User.findById(user._id).lean()).signupSource).toBeUndefined();
// Neither a later save nor a dotted update may rewrite a created source.
user.signupSource.ref = 'changed';
await user.save();
await User.updateOne({ _id: user._id }, { $set: { 'signupSource.ref': 'also-changed' } });
expect((await User.findById(user._id).select('+signupSource')).signupSource.ref).toBe('hn');
});
});

describe('getOAuthProviders', () => {
it('lists only configured providers', () => {
const res = mockRes();
Expand Down
2 changes: 1 addition & 1 deletion backend/__tests__/unit/middleware/refuseDollarKeys.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,6 @@ describe('where the guard is mounted', () => {
});

test('the OAuth start form runs it after its urlencoded parser', () => {
expect(src('routes/auth.ts')).toMatch(/express\.urlencoded\(\{ extended: false, limit: '4kb' \}\), refuseDollarKeys, startOAuth\)/);
expect(src('routes/auth.ts')).toMatch(/express\.urlencoded\(\{ extended: false, limit: '16kb' \}\), refuseDollarKeys, startOAuth\)/);
});
});
51 changes: 51 additions & 0 deletions backend/__tests__/unit/utils/signupSource.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
const { cleanSignupTouch, signupSourceFields } = require('../../../utils/signupSource');

describe('signup source privacy boundary', () => {
it('strips query, fragment and URL credentials, and ignores unrecognized keys', () => {
const source = cleanSignupTouch({
ref: ' X ', referrer: 'https://name:password@news.example/article?email=secret#token',
landingPath: '/guides/a?invite=private#secret', firstSeenAt: '2026-01-01T00:00:00.000Z',
authMethod: 'admin', extra: 'secret',
});
expect(source).toEqual({
ref: 'x', referrer: 'https://news.example', landingPath: '/guides/a',
firstSeenAt: new Date('2026-01-01T00:00:00.000Z'),
});
});

it.each([null, [], '{bad', 'x'.repeat(4097), { referrer: 'javascript:secret', landingPath: '//foreign.example', firstSeenAt: 'nope' }])(
'drops malformed optional input without failing registration: %p', (input) => {
expect(cleanSignupTouch(input)).toEqual({});
},
);

it.each([
'/v2/invite/SECRETinviteTOKEN123',
'/v2/INVITE/short',
'/v2/%69nvite/short',
'/v2/%2569nvite%252Fshort',
'/share/0123456789abcdef0123456789abcdef',
'/share/550e8400-e29b-41d4-a716-446655440000',
'/share/AbCdEfGhIjKlMnOpQrStUvWxYz012345', '/v2/%25252569nvite/short', '/bad/%zz',
])('drops path-embedded capabilities even from an old or forged client: %s', (path) => {
expect(cleanSignupTouch({ landingPath: path, referrer: `https://news.example${path}` }))
.toEqual({ referrer: 'https://news.example' });
});

it('retains a normal guide path while reducing even long referrer paths to an origin', () => {
expect(cleanSignupTouch({ landingPath: '/guides/multi-agent-collaboration-platform',
referrer: `https://news.example/${'a'.repeat(1500)}` })).toEqual({
landingPath: '/guides/multi-agent-collaboration-platform', referrer: 'https://news.example',
});
});

it('does not turn a later ref into the ref of an earlier direct visit', () => {
expect(cleanSignupTouch({ landingPath: '/' }, 'later')).toEqual({ landingPath: '/' });
expect(cleanSignupTouch(undefined, 'old-browser')).toEqual({ ref: 'old-browser' });
});

it('sets auth method and recording time on the server, even without browser storage', () => {
const source = signupSourceFields({ authMethod: 'github', recordedAt: '1900-01-01' }, 'password');
expect(source.signupSource).toEqual({ authMethod: 'password', recordedAt: expect.any(Date) });
});
});
12 changes: 3 additions & 9 deletions backend/controllers/authController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const AgentIdentityService = require('../services/agentIdentityService');
const { sendEmail } = require('../services/emailService');
const { ensureUserInCommunityPod } = require('../services/communityPodService');
const { normalizeAvatarUrl } = require('../services/avatarService');
const { cleanSignupRef } = require('../utils/signupRef');
const { signupSourceFields } = require('../utils/signupSource');
const { deriveUsernameFromEmail, ACCOUNT_LABEL_COLLATION } = require('../services/derivedUsername');
const { issueSessionToken } = require('../services/sessionTokenService');
const {
Expand All @@ -19,13 +19,6 @@ const { disconnectUserSessionSockets } = require('../services/sessionSocketServi
const { isInstanceAccessRestricted, isInstanceEmailAllowed,
INSTANCE_ACCESS_ERROR } = require('../services/instanceAccessPolicy');

// The first-touch ref a signup arrived with (?ref=x), stored once on the new
// account. Anything that is not a short plain code is dropped, not refused.
const signupRefField = (raw: unknown) => {
const ref = cleanSignupRef(raw);
return ref ? { onboarding: { signupRef: ref } } : {};
};

const joinCommunityPodBestEffort = (userId: any) => {
void ensureUserInCommunityPod(userId).catch((err: Error) => {
console.warn('[community-pod] background join failed after auth transition:', err.message);
Expand Down Expand Up @@ -412,6 +405,7 @@ exports.register = async (req: any, res: any) => {
password,
invitationCode,
signupRef,
signupSource,
} = req.body;

const normalizedEmail = normalizeEmail(email);
Expand Down Expand Up @@ -534,7 +528,7 @@ exports.register = async (req: any, res: any) => {
password: rawPassword,
verified: shouldAutoVerify,
entitlements: { cloudAgents: invitationRedeemed },
...signupRefField(signupRef),
...signupSourceFields(signupSource, 'password', signupRef),
});

// Save user to database
Expand Down
7 changes: 5 additions & 2 deletions backend/controllers/oauthController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const axios = require('axios');
const User = require('../models/User');
const OAuthLoginState = require('../models/OAuthLoginState');
const { cleanSignupRef } = require('../utils/signupRef');
const { cleanSignupTouch, signupSourceFields } = require('../utils/signupSource');
const AgentIdentityService = require('../services/agentIdentityService');
const {
isInviteOnlyRegistrationEnabled,
Expand Down Expand Up @@ -237,6 +238,9 @@ exports.startOAuth = async (req: any, res: any) => {
invitationCode: String(input.invite || '').trim(),
next: safeNextPath(input.next),
signupRef: cleanSignupRef(input.ref) || '',
// Attribution arrives in a form body, stays in this server-side state,
// and is never copied into a provider or completion URL.
...(req.method === 'POST' ? { signupSource: cleanSignupTouch(input.signupSource, input.ref) } : {}),
expiresAt: new Date(Date.now() + STATE_TTL_MS),
});

Expand Down Expand Up @@ -336,14 +340,13 @@ exports.oauthCallback = async (req: any, res: any) => {
return loginErrorRedirect(res, 'invitation_required');
}

const signupRef = cleanSignupRef(stateRow.signupRef);
user = new User({
username: await generateUsername(profile.usernameHint, profile.email),
email: profile.email,
verified: true, // provider-asserted
entitlements: { cloudAgents: invitationRedeemed },
// Set at creation only: signing in to an existing account never changes it.
...(signupRef ? { onboarding: { signupRef } } : {}),
...signupSourceFields(stateRow.signupSource, provider, stateRow.signupRef),
authProviders: [{
provider,
providerId: profile.providerId,
Expand Down
5 changes: 5 additions & 0 deletions backend/models/OAuthLoginState.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import mongoose, { Document, Schema, Types } from 'mongoose';
import type { SignupTouch } from '../utils/signupSource';

// One row per social-login attempt. Lives for the length of the OAuth
// round-trip and is deliberately separate from OAuthState (the X/Twitter
Expand All @@ -22,6 +23,7 @@ export interface IOAuthLoginState extends Document {
next?: string;
// First-touch ref code (?ref=x) for a signup made through this login.
signupRef?: string;
signupSource?: SignupTouch;
userId?: Types.ObjectId | null;
exchangeCode?: string | null;
exchangeExpiresAt?: Date | null;
Expand All @@ -39,6 +41,9 @@ const OAuthLoginStateSchema = new Schema<IOAuthLoginState>(
invitationCode: { type: String, default: '' },
next: { type: String, default: '/v2' },
signupRef: { type: String, default: '' },
signupSource: {
type: new Schema({ ref: String, referrer: String, landingPath: String, firstSeenAt: Date }, { _id: false }),
},
userId: { type: Schema.Types.ObjectId, ref: 'User', default: null },
// NO default here — a sparse unique index skips MISSING fields but still
// indexes explicit nulls, so `default: null` made every pending state row
Expand Down
23 changes: 23 additions & 0 deletions backend/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,15 @@ export interface IUser extends Document {
signupRef?: string;
};
createdAt: Date;
/** Browser-reported first touch, persisted only for a new human account. */
signupSource?: {
ref?: string;
referrer?: string;
landingPath?: string;
firstSeenAt?: Date;
authMethod: 'password' | 'google' | 'github';
recordedAt: Date;
};
// Instance methods
comparePassword(password: string): Promise<boolean>;
generateApiToken(): string;
Expand All @@ -230,6 +239,20 @@ export interface IUser extends Document {
export interface IUserModel extends Model<IUser> {}

const userSchema = new Schema<IUser>({
// Growth opts in with .select('+signupSource'); incidental profile/populate
// reads must not disclose referral paths. Every field is write-once too.
signupSource: {
type: new Schema({
ref: { type: String, immutable: true },
referrer: { type: String, immutable: true },
landingPath: { type: String, immutable: true },
firstSeenAt: { type: Date, immutable: true },
authMethod: { type: String, enum: ['password', 'google', 'github'], required: true, immutable: true },
recordedAt: { type: Date, required: true, immutable: true },
}, { _id: false }),
immutable: true,
select: false,
},
username: { type: String, required: true, unique: true },
previousUsernames: { type: [String], default: [] },
displayName: { type: String, trim: true },
Expand Down
3 changes: 2 additions & 1 deletion backend/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,8 @@ router.get('/oauth/:provider/start', oauthLimiter, startOAuth);
// The same start as a form POST, so an invite code rides in the body, not the URL
// (the register page uses it whenever it holds a code; TASK-404).
// This parser runs after the global $-key guard, so the guard runs again on its body.
router.post('/oauth/:provider/start', oauthLimiter, express.urlencoded({ extended: false, limit: '4kb' }), refuseDollarKeys, startOAuth);
// Two bounded 2 KiB paths in signupSource expand under form URL-encoding.
router.post('/oauth/:provider/start', oauthLimiter, express.urlencoded({ extended: false, limit: '16kb' }), refuseDollarKeys, startOAuth);
router.get('/oauth/:provider/callback', oauthLimiter, oauthCallback);
router.post('/oauth/exchange', oauthLimiter, exchangeOAuthCode);
router.get('/registration-policy', registrationPolicyLimit, getRegistrationPolicy);
Expand Down
Loading
Loading