Skip to content

Commit

Permalink
Merge pull request #6031 from logto-io/gao-google-one-tap-core
Browse files Browse the repository at this point in the history
  • Loading branch information
gao-sun authored Jun 18, 2024
2 parents bc1efc7 + 552a3e5 commit d9119b5
Show file tree
Hide file tree
Showing 15 changed files with 231 additions and 64 deletions.
13 changes: 13 additions & 0 deletions .changeset/cyan-garlics-tan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@logto/core": minor
"@logto/phrases": patch
"@logto/schemas": patch
---

support Google One Tap

- core: `GET /api/.well-known/sign-in-exp` now returns `googleOneTap` field with the configuration when available
- core: add Google Sign-In (GSI) url to the security headers
- core: verify Google One Tap CSRF token in `verifySocialIdentity()`
- phrases: add Google One Tap phrases
- schemas: migrate sign-in experience types from core to schemas
20 changes: 9 additions & 11 deletions packages/core/src/__mocks__/connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,18 @@ export const mockGoogleConnector: LogtoConnector = {
dbEntry: {
...mockConnector,
id: 'google',
config: {
clientId: 'fake_client_id',
clientSecret: 'fake_client_secret',
oneTap: {
isEnabled: true,
autoSelect: true,
},
},
},
metadata: {
...mockMetadata,
id: 'google',
id: 'google-universal',
target: 'google',
platform: ConnectorPlatform.Web,
},
Expand All @@ -238,16 +246,6 @@ export const mockDemoSocialConnector: LogtoConnector = {
...mockLogtoConnector,
};

export const mockLogtoConnectors = [
mockAliyunDmConnector,
mockAliyunSmsConnector,
mockFacebookConnector,
mockGithubConnector,
mockGoogleConnector,
mockWechatConnector,
mockWechatNativeConnector,
];

export const socialTarget01 = 'socialTarget-id01';
export const socialTarget02 = 'socialTarget-id02';

Expand Down
45 changes: 45 additions & 0 deletions packages/core/src/libraries/sign-in-experience/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { builtInLanguages } from '@logto/phrases-experience';
import type { CreateSignInExperience, SignInExperience } from '@logto/schemas';

import {
mockGithubConnector,
mockGoogleConnector,
mockSignInExperience,
mockSocialConnectors,
socialTarget01,
Expand Down Expand Up @@ -167,6 +169,49 @@ describe('getFullSignInExperience()', () => {
},
],
isDevelopmentTenant: false,
googleOneTap: undefined,
});
});

it('should return full sign-in experience with google one tap', async () => {
findDefaultSignInExperience.mockResolvedValueOnce({
...mockSignInExperience,
socialSignInConnectorTargets: ['github', 'facebook', 'google'],
});
getLogtoConnectors.mockResolvedValueOnce([mockGoogleConnector, mockGithubConnector]);
ssoConnectorLibrary.getAvailableSsoConnectors.mockResolvedValueOnce([
wellConfiguredSsoConnector,
]);

const fullSignInExperience = await getFullSignInExperience('en');
const connectorFactory = ssoConnectorFactories[wellConfiguredSsoConnector.providerName];

expect(fullSignInExperience).toStrictEqual({
...mockSignInExperience,
socialConnectors: [
{ ...mockGithubConnector.metadata, id: mockGithubConnector.dbEntry.id },
{ ...mockGoogleConnector.metadata, id: mockGoogleConnector.dbEntry.id },
],
socialSignInConnectorTargets: ['github', 'facebook', 'google'],
forgotPassword: {
email: false,
phone: false,
},
ssoConnectors: [
{
id: wellConfiguredSsoConnector.id,
connectorName: connectorFactory.name.en,
logo: connectorFactory.logo,
darkLogo: connectorFactory.logoDark,
},
],
isDevelopmentTenant: false,
googleOneTap: {
isEnabled: true,
autoSelect: true,
clientId: 'fake_client_id',
connectorId: 'google',
},
});
});
});
Expand Down
40 changes: 36 additions & 4 deletions packages/core/src/libraries/sign-in-experience/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { GoogleConnector } from '@logto/connector-kit';
import { builtInLanguages } from '@logto/phrases-experience';
import type { ConnectorMetadata, LanguageInfo, SsoConnectorMetadata } from '@logto/schemas';
import type {
ConnectorMetadata,
FullSignInExperience,
LanguageInfo,
SsoConnectorMetadata,
} from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import { deduplicate } from '@silverhand/essentials';

Expand All @@ -15,8 +21,6 @@ import { isKeyOfI18nPhrases } from '#src/utils/translation.js';

import { type CloudConnectionLibrary } from '../cloud-connection.js';

import { type FullSignInExperience } from './types.js';

export * from './sign-up.js';
export * from './sign-in.js';

Expand Down Expand Up @@ -123,7 +127,7 @@ export const createSignInExperienceLibrary = (
};

const socialConnectors = signInExperience.socialSignInConnectorTargets.reduce<
Array<ConnectorMetadata & { id: string }>
ConnectorMetadata[]
>((previous, connectorTarget) => {
const connectors = logtoConnectors.filter(
({ metadata: { target } }) => target === connectorTarget
Expand All @@ -135,12 +139,40 @@ export const createSignInExperienceLibrary = (
];
}, []);

/**
* Get the Google One Tap configuration if the Google connector is enabled and configured.
*/
const getGoogleOneTap = (): FullSignInExperience['googleOneTap'] => {
const googleConnector =
signInExperience.socialSignInConnectorTargets.includes(GoogleConnector.target) &&
logtoConnectors.find(({ metadata }) => metadata.id === GoogleConnector.factoryId);

if (!googleConnector) {
return;
}

const googleConnectorConfig = GoogleConnector.configGuard.safeParse(
googleConnector.dbEntry.config
);

if (!googleConnectorConfig.success) {
return;
}

return {
...googleConnectorConfig.data.oneTap,
clientId: googleConnectorConfig.data.clientId,
connectorId: googleConnector.dbEntry.id,
};
};

return {
...signInExperience,
socialConnectors,
ssoConnectors,
forgotPassword,
isDevelopmentTenant,
googleOneTap: getGoogleOneTap(),
};
};

Expand Down
30 changes: 0 additions & 30 deletions packages/core/src/libraries/sign-in-experience/types.ts

This file was deleted.

4 changes: 2 additions & 2 deletions packages/core/src/libraries/social.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const createSocialLibrary = (queries: Queries, connectorLibrary: Connecto
}
};

const getUserInfoByAuthCode = async (
const getUserInfo = async (
connectorId: string,
data: unknown,
getConnectorSession: GetSession
Expand Down Expand Up @@ -105,7 +105,7 @@ export const createSocialLibrary = (queries: Queries, connectorLibrary: Connecto

return {
getConnector,
getUserInfoByAuthCode,
getUserInfo,
getUserInfoFromInteractionResult,
findSocialRelatedUser,
};
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/middleware/koa-security-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export default function koaSecurityHeaders<StateT, ContextT, ResponseBodyT>(
const coreOrigins = urlSet.origins;
const developmentOrigins = conditionalArray(!isProduction && 'ws:');
const logtoOrigin = 'https://*.logto.io';
/** Google Sign-In (GSI) origin for Google One Tap. */
const gsiOrigin = 'https://accounts.google.com/gsi/';

// We use react-monaco-editor for code editing in the admin console. It loads the monaco editor asynchronously from a CDN.
// Allow the CDN src in the CSP.
Expand Down Expand Up @@ -90,13 +92,15 @@ export default function koaSecurityHeaders<StateT, ContextT, ResponseBodyT>(
scriptSrc: [
"'self'",
"'unsafe-inline'",
`${gsiOrigin}client`,
...conditionalArray(!isProduction && "'unsafe-eval'"),
],
connectSrc: ["'self'", tenantEndpointOrigin, ...developmentOrigins],
connectSrc: ["'self'", gsiOrigin, tenantEndpointOrigin, ...developmentOrigins],
// WARNING: high risk Need to allow self hosted terms of use page loaded in an iframe
frameSrc: ["'self'", 'https:'],
frameSrc: ["'self'", 'https:', gsiOrigin],
// Alow loaded by console preview iframe
frameAncestors: ["'self'", ...adminOrigins],
defaultSrc: ["'self'", gsiOrigin],
},
},
};
Expand Down
3 changes: 0 additions & 3 deletions packages/core/src/routes/interaction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,11 +198,8 @@ export default function interactionRoutes<T extends AnonymousRouter>(
);

log.append({ identifier: verifiedIdentifier, interactionStorage });

const identifiers = mergeIdentifiers(verifiedIdentifier, interactionStorage.identifiers);

await storeInteractionResult({ identifiers }, ctx, provider, true);

ctx.status = 204;

return next();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ConnectorType } from '@logto/connector-kit';
import { ConnectorType, GoogleConnector } from '@logto/connector-kit';
import { createMockUtils } from '@logto/shared/esm';

import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
Expand All @@ -9,10 +9,10 @@ import { MockTenant } from '#src/test-utils/tenant.js';
const { jest } = import.meta;
const { mockEsm } = createMockUtils(jest);

const getUserInfoByAuthCode = jest.fn().mockResolvedValue({ id: 'foo' });
const getUserInfo = jest.fn().mockResolvedValue({ id: 'foo' });

const tenant = new MockTenant(undefined, undefined, undefined, {
socials: { getUserInfoByAuthCode },
socials: { getUserInfo },
});

mockEsm('#src/libraries/connector.js', () => ({
Expand All @@ -27,8 +27,8 @@ mockEsm('#src/libraries/connector.js', () => ({

const { verifySocialIdentity } = await import('./social-verification.js');

describe('social-verification', () => {
it('verifySocialIdentity', async () => {
describe('verifySocialIdentity', () => {
it('should verify social identity', async () => {
// @ts-expect-error test mock context
const ctx: WithLogContext = {
...createMockContext(),
Expand All @@ -38,7 +38,40 @@ describe('social-verification', () => {
const connectorData = { authCode: 'code' };
const userInfo = await verifySocialIdentity({ connectorId, connectorData }, ctx, tenant);

expect(getUserInfoByAuthCode).toBeCalledWith(connectorId, connectorData, expect.anything());
expect(getUserInfo).toBeCalledWith(connectorId, connectorData, expect.anything());
expect(userInfo).toEqual({ id: 'foo' });
});

it('should throw error if csrf token is not matched for Google One Tap verification', async () => {
const ctx: WithLogContext = {
...createMockContext(),
...createMockLogContext(),
// @ts-expect-error test mock context
cookies: { get: jest.fn().mockReturnValue('token') },
};
const connectorId = GoogleConnector.factoryId;
const connectorData = { credential: 'credential' };

await expect(verifySocialIdentity({ connectorId, connectorData }, ctx, tenant)).rejects.toThrow(
'CSRF token mismatch.'
);
});

it('should verify Google One Tap verification', async () => {
const ctx: WithLogContext = {
...createMockContext(),
...createMockLogContext(),
// @ts-expect-error test mock context
cookies: { get: jest.fn().mockReturnValue('token') },
};
const connectorId = GoogleConnector.factoryId;
const connectorData = {
[GoogleConnector.oneTapParams.credential]: 'credential',
[GoogleConnector.oneTapParams.csrfToken]: 'token',
};

await expect(
verifySocialIdentity({ connectorId, connectorData }, ctx, tenant)
).resolves.toEqual({ id: 'foo' });
});
});
17 changes: 14 additions & 3 deletions packages/core/src/routes/interaction/utils/social-verification.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ConnectorSession, SocialUserInfo } from '@logto/connector-kit';
import { connectorSessionGuard } from '@logto/connector-kit';
import { connectorSessionGuard, GoogleConnector } from '@logto/connector-kit';
import type { SocialConnectorPayload } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import type { Context } from 'koa';
Expand Down Expand Up @@ -57,13 +57,24 @@ export const verifySocialIdentity = async (
{ provider, libraries }: TenantContext
): Promise<SocialUserInfo> => {
const {
socials: { getUserInfoByAuthCode },
socials: { getUserInfo },
} = libraries;

const log = ctx.createLog('Interaction.SignIn.Identifier.Social.Submit');
log.append({ connectorId, connectorData });

const userInfo = await getUserInfoByAuthCode(connectorId, connectorData, async () =>
// Verify the CSRF token if it's a Google connector and has credential (a Google One Tap
// verification)
if (
connectorId === GoogleConnector.factoryId &&
connectorData[GoogleConnector.oneTapParams.credential]
) {
const csrfToken = connectorData[GoogleConnector.oneTapParams.csrfToken];
const value = ctx.cookies.get(GoogleConnector.oneTapParams.csrfToken);
assertThat(value === csrfToken, 'session.csrf_token_mismatch');
}

const userInfo = await getUserInfo(connectorId, connectorData, async () =>
getConnectorSessionResult(ctx, provider)
);

Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/routes/well-known.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,10 @@ describe('GET /.well-known/sign-in-exp', () => {
...mockWechatNativeConnector.metadata,
id: mockWechatNativeConnector.dbEntry.id,
},
],
].map(
// Omits fields to match the `ExperienceSocialConnector` type
({ description, configTemplate, formItems, readme, customData, ...metadata }) => metadata
),
ssoConnectors: [],
});
});
Expand Down
Loading

0 comments on commit d9119b5

Please sign in to comment.