From 23b42bd6faf1b5972d00345cfc39991d788fe51a Mon Sep 17 00:00:00 2001 From: andypalmi Date: Fri, 23 Oct 2026 14:32:57 +0200 Subject: [PATCH] Route Google sign-in through the domain's SSO provider and require a verified email Google sign-in now looks up an SSO provider for the email's domain for every account, not only Google Workspace accounts, and redirects to it when one exists. Logins where Google does not report email_verified are rejected. --- forge/ee/routes/sso/social/google.js | 30 ++++--- test/unit/forge/ee/routes/sso/google_spec.js | 88 ++++++++++++++++++++ 2 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 test/unit/forge/ee/routes/sso/google_spec.js diff --git a/forge/ee/routes/sso/social/google.js b/forge/ee/routes/sso/social/google.js index a2f1463e17..8d0571d6d8 100644 --- a/forge/ee/routes/sso/social/google.js +++ b/forge/ee/routes/sso/social/google.js @@ -49,20 +49,24 @@ module.exports = fp(async function (app, opts) { }) const googleUserInfo = userinfo.data await googleJWTCache.set(request.query.code, {}) + if (googleUserInfo.email_verified !== true) { + reply.code(403).send({ code: 'unverified_email', error: 'Google account email is not verified' }) + return + } + // Any email on a domain with an SSO provider must go through that provider, + // whether or not Google reports a Workspace (hd) account + const providerId = await app.sso.getProviderForEmail(googleUserInfo.email) + if (providerId) { + reply.send({ + url: `/ee/sso/login?u=${encodeURIComponent(googleUserInfo.email)}` + }) + return + } if (googleUserInfo.hd) { - // This is a Google Workspace account - check if there is an SSO provider configured for this domain - const providerId = await app.sso.getProviderForEmail(googleUserInfo.email) - if (!providerId) { - // No SSO provider configured for this domain - redirect to home - reply.send({ - error: 'SSO not available for this Google Workspace domain' - }) - } else { - // There is an SSO provider configured for this domain - use it - reply.send({ - url: `/ee/sso/login?u=${encodeURIComponent(googleUserInfo.email)}` - }) - } + // Workspace account on a domain with no SSO provider configured + reply.send({ + error: 'SSO not available for this Google Workspace domain' + }) return } const user = await app.db.models.User.byUsernameOrEmail(googleUserInfo.email) diff --git a/test/unit/forge/ee/routes/sso/google_spec.js b/test/unit/forge/ee/routes/sso/google_spec.js new file mode 100644 index 0000000000..b7cd2d59d2 --- /dev/null +++ b/test/unit/forge/ee/routes/sso/google_spec.js @@ -0,0 +1,88 @@ +const { OAuth2Client } = require('google-auth-library') +const should = require('should') // eslint-disable-line +const sinon = require('sinon') + +const setup = require('../../setup') + +describe('Google SSO callback', function () { + let app + let userinfo + let codeCounter = 0 + + before(async function () { + app = await setup({ billing: undefined }) + await app.settings.set('platform:sso:google', true) + await app.settings.set('platform:sso:google:clientId', 'test-client-id') + + await app.db.models.User.create({ username: 'carol', name: 'Carol', email: 'carol@corp.com', email_verified: true, password: 'ccPassword1!' }) + await app.db.models.User.create({ username: 'dave', name: 'Dave', email: 'dave@plain.com', email_verified: true, password: 'ddPassword1!' }) + await app.db.models.SAMLProvider.create({ + name: 'corp.com', + domainFilter: '@corp.com', + active: true, + options: { + cert: '-----BEGIN CERTIFICATE-----abcde-----END CERTIFICATE-----', + entryPoint: 'https://sso.corp.com/entry' + } + }) + + sinon.stub(OAuth2Client.prototype, 'getTokenInfo').resolves({ aud: 'test-client-id' }) + sinon.stub(OAuth2Client.prototype, 'request').callsFake(async () => ({ data: userinfo })) + }) + + after(async function () { + sinon.restore() + await app.close() + }) + + async function googleCallback (info) { + userinfo = info + return app.inject({ + method: 'POST', + url: `/ee/sso/login/callback/google?code=code-${codeCounter++}` + }) + } + + it('redirects a consumer account on an SSO enforced domain to the provider', async function () { + const response = await googleCallback({ email: 'carol@corp.com', email_verified: true }) + response.statusCode.should.equal(200) + response.json().should.have.property('url', `/ee/sso/login?u=${encodeURIComponent('carol@corp.com')}`) + response.cookies.should.have.length(0) + }) + + it('redirects a Workspace account on an SSO enforced domain to the provider', async function () { + const response = await googleCallback({ email: 'carol@corp.com', email_verified: true, hd: 'corp.com' }) + response.statusCode.should.equal(200) + response.json().should.have.property('url', `/ee/sso/login?u=${encodeURIComponent('carol@corp.com')}`) + response.cookies.should.have.length(0) + }) + + it('rejects an account whose email Google reports as unverified', async function () { + const response = await googleCallback({ email: 'dave@plain.com', email_verified: false }) + response.statusCode.should.equal(403) + response.json().should.have.property('code', 'unverified_email') + response.cookies.should.have.length(0) + }) + + it('rejects an account with no email_verified claim', async function () { + const response = await googleCallback({ email: 'dave@plain.com' }) + response.statusCode.should.equal(403) + response.json().should.have.property('code', 'unverified_email') + response.cookies.should.have.length(0) + }) + + it('returns an error for a Workspace account on a domain with no SSO provider', async function () { + const response = await googleCallback({ email: 'dave@plain.com', email_verified: true, hd: 'plain.com' }) + response.statusCode.should.equal(200) + response.json().should.have.property('error', 'SSO not available for this Google Workspace domain') + response.cookies.should.have.length(0) + }) + + it('signs in an existing user on a domain with no SSO provider', async function () { + const response = await googleCallback({ email: 'dave@plain.com', email_verified: true }) + response.statusCode.should.equal(200) + response.json().should.have.property('url', '/') + response.cookies.should.have.length(1) + response.cookies[0].name.should.equal('sid') + }) +})