🔁 fix: Tighten Google admin refresh and limit social-login changes

Brutal-review findings on top of the upstream feature work.

socialLogin.js: the migrate-or-reject pattern from the previous commit
applied to every provider's chat-side verify callback, not just the admin
flow. Gate both branches on `options.existingUsersOnly` so the chat-side
googleLogin / facebookLogin / etc. keep their pre-existing email-fallback
behavior unchanged. Tests follow: restore the original `should fallback to
finding user by email` chat-side case and re-add the migration and
mismatch-reject cases as admin-only by passing `{ existingUsersOnly: true }`
to socialLogin in those tests.

googleRefresh.ts: add a defense-in-depth `isEmailAllowed(user)` dep that
the helper invokes before `canAccessAdmin`. Mirrors the
`isEmailDomainAllowed` check the initial Google admin login already runs,
so a deployment that removes a domain from `registration.allowedDomains`
after issuance can no longer mint fresh JWTs for that admin via refresh.
The route handler wires it up with `resolveAppConfigForUser` +
`isEmailDomainAllowed`, falling back to `baseOnly` config for users
without a tenantId.

googleRefresh.ts: drop the unreachable `?? ''` defensive coalescing in
`fetchGoogleTokenset`. The `GOOGLE_NOT_CONFIGURED` guard upstream already
narrows `clientId`/`clientSecret` to non-empty strings; the function
takes a narrowed `GoogleAdminRefreshConfiguredOptions` shape and
`applyGoogleAdminRefresh` constructs that shape after the guard.
This commit is contained in:
Dustin Healy 2026-06-22 08:35:12 -07:00
parent 21922eea78
commit 0e55d8a1df
5 changed files with 112 additions and 16 deletions

View file

@ -69,6 +69,7 @@ describe('applyGoogleAdminRefresh', () => {
findUsers: jest.fn(),
getUserById: jest.fn(),
canAccessAdmin: jest.fn(),
isEmailAllowed: jest.fn().mockResolvedValue(true),
mintToken: jest.fn(),
};
originalFetch = global.fetch;
@ -247,6 +248,22 @@ describe('applyGoogleAdminRefresh', () => {
});
});
it('throws FORBIDDEN when isEmailAllowed rejects the refreshed identity', async () => {
const user = makeUser();
fetchMock.mockResolvedValueOnce(
makeOkJson({ access_token: 'new-access', id_token: makeIdToken() }),
);
deps.findUsers.mockResolvedValue([user]);
(deps.isEmailAllowed as jest.Mock).mockResolvedValue(false);
await expect(applyGoogleAdminRefresh(deps, baseOptions)).rejects.toMatchObject({
code: 'FORBIDDEN',
status: 403,
message: expect.stringContaining('domain'),
});
expect(deps.canAccessAdmin).not.toHaveBeenCalled();
});
it('returns the rotated refresh_token when Google supplies one', async () => {
const user = makeUser();
fetchMock.mockResolvedValueOnce(

View file

@ -35,6 +35,13 @@ export interface GoogleAdminRefreshDeps {
) => Promise<IUser[]>;
getUserById: (id: string, projection: string) => Promise<IUser | null>;
canAccessAdmin: (user: IUser) => Promise<boolean>;
/**
* Re-runs the deployment's `registration.allowedDomains` check against the
* resolved user's email. Returns true to allow refresh, false to reject.
* Mirrors the `isEmailDomainAllowed` call the initial OAuth login enforces
* so a domain removed from the allowlist after issuance can't refresh.
*/
isEmailAllowed?: (user: IUser) => Promise<boolean>;
mintToken: (user: IUser) => Promise<MintedGoogleAdminToken>;
}
@ -80,15 +87,22 @@ async function resolveSubFromUserinfo(accessToken: string): Promise<string | und
}
}
async function fetchGoogleTokenset(options: GoogleAdminRefreshOptions): Promise<GoogleTokenset> {
interface GoogleAdminRefreshConfiguredOptions extends GoogleAdminRefreshOptions {
clientId: string;
clientSecret: string;
}
async function fetchGoogleTokenset(
options: GoogleAdminRefreshConfiguredOptions,
): Promise<GoogleTokenset> {
let response: Response;
try {
response = await fetch(GOOGLE_TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: options.clientId ?? '',
client_secret: options.clientSecret ?? '',
client_id: options.clientId,
client_secret: options.clientSecret,
refresh_token: options.refreshToken,
grant_type: 'refresh_token',
}),
@ -212,10 +226,24 @@ export async function applyGoogleAdminRefresh(
);
}
const tokenset = await fetchGoogleTokenset(options);
const configured: GoogleAdminRefreshConfiguredOptions = {
...options,
clientId: options.clientId,
clientSecret: options.clientSecret,
};
const tokenset = await fetchGoogleTokenset(configured);
const googleId = await resolveGoogleSub(tokenset);
const user = await resolveAdminUser(googleId, deps, options);
if (deps.isEmailAllowed && !(await deps.isEmailAllowed(user))) {
throw new AdminRefreshError(
'FORBIDDEN',
403,
'User email domain is not on the deployment allowlist',
);
}
if (!(await deps.canAccessAdmin(user))) {
throw new AdminRefreshError('FORBIDDEN', 403, 'User does not have admin access');
}