Skip to content

Commit

Permalink
feat(core): google one tap
Browse files Browse the repository at this point in the history
  • Loading branch information
gao-sun committed Jun 16, 2024
1 parent 6308ee1 commit 942780f
Show file tree
Hide file tree
Showing 14 changed files with 138 additions and 50 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
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ describe('getFullSignInExperience()', () => {
},
],
isDevelopmentTenant: false,
googleOneTap: undefined,
});
});
});
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,
};

Check warning on line 166 in packages/core/src/libraries/sign-in-experience/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/libraries/sign-in-experience/index.ts#L154-L166

Added lines #L154 - L166 were not covered by tests
};

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
Expand Up @@ -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 @@ -38,7 +38,7 @@ 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' });
});
});
13 changes: 10 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,20 @@ 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 Google One Tap CSRF token, if it exists
const csrfToken = connectorData[GoogleConnector.oneTapParams.csrfToken];
if (csrfToken) {
const value = ctx.cookies.get(GoogleConnector.oneTapParams.csrfToken);
assertThat(value === csrfToken, 'session.csrf_token_mismatch');
}

Check warning on line 71 in packages/core/src/routes/interaction/utils/social-verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/interaction/utils/social-verification.ts#L69-L71

Added lines #L69 - L71 were not covered by tests

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
3 changes: 1 addition & 2 deletions packages/core/src/routes/well-known.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { isBuiltInLanguageTag } from '@logto/phrases-experience';
import { adminTenantId } from '@logto/schemas';
import { adminTenantId, guardFullSignInExperience } from '@logto/schemas';
import { conditionalArray } from '@silverhand/essentials';
import { z } from 'zod';

import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
import detectLanguage from '#src/i18n/detect-language.js';
import { guardFullSignInExperience } from '#src/libraries/sign-in-experience/types.js';
import koaGuard from '#src/middleware/koa-guard.js';

import type { AnonymousRouter, RouterInitArgs } from './types.js';
Expand Down
1 change: 1 addition & 0 deletions packages/phrases/src/locales/en/errors/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const session = {
'The verification was not successful. Restart the verification flow and try again.',
connector_validation_session_not_found:
'The connector session for token validation is not found.',
csrf_token_mismatch: 'CSRF token mismatch.',
identifier_not_found: 'User identifier not found. Please go back and sign in again.',
interaction_not_found:
'Interaction session not found. Please go back and start the session again.',
Expand Down
1 change: 1 addition & 0 deletions packages/schemas/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ export * from './tenant-organization.js';
export * from './mapi-proxy.js';
export * from './consent.js';
export * from './onboarding.js';
export * from './sign-in-experience.js';
60 changes: 60 additions & 0 deletions packages/schemas/src/types/sign-in-experience.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {
connectorMetadataGuard,
type ConnectorMetadata,
type GoogleOneTapConfig,
googleOneTapConfigGuard,
} from '@logto/connector-kit';
import { z } from 'zod';

import { type SignInExperience, SignInExperiences } from '../db-entries/index.js';
import { type ToZodObject } from '../utils/zod.js';

import { type SsoConnectorMetadata, ssoConnectorMetadataGuard } from './sso-connector.js';

type ForgotPassword = {
phone: boolean;
email: boolean;
};

/**
* Basic information about a social connector for sign-in experience rendering. This type can avoid
* the need to load the full connector metadata that is not needed for rendering.
*/
export type ExperienceSocialConnector = Omit<
ConnectorMetadata,
'description' | 'configTemplate' | 'formItems' | 'readme' | 'customData'
>;

export type FullSignInExperience = SignInExperience & {
socialConnectors: ExperienceSocialConnector[];
ssoConnectors: SsoConnectorMetadata[];
forgotPassword: ForgotPassword;
isDevelopmentTenant: boolean;
/**
* The Google One Tap configuration if the Google connector is enabled and configured.
*
* @remarks
* We need to use a standalone property for the Google One Tap configuration because it needs
* data from database entries that other connectors don't need. Thus we manually extract the
* minimal data needed here.
*/
googleOneTap?: GoogleOneTapConfig & { clientId: string; connectorId: string };
};

export const guardFullSignInExperience = SignInExperiences.guard.extend({
socialConnectors: connectorMetadataGuard
.omit({
description: true,
configTemplate: true,
formItems: true,
readme: true,
customData: true,
})
.array(),
ssoConnectors: ssoConnectorMetadataGuard.array(),
forgotPassword: z.object({ phone: z.boolean(), email: z.boolean() }),
isDevelopmentTenant: z.boolean(),
googleOneTap: googleOneTapConfigGuard
.extend({ clientId: z.string(), connectorId: z.string() })
.optional(),
}) satisfies ToZodObject<FullSignInExperience>;

0 comments on commit 942780f

Please sign in to comment.