Skip to content

Commit

Permalink
Merge pull request #6030 from logto-io/gao-google-one-tap-connector
Browse files Browse the repository at this point in the history
feat(connector): google one tap
  • Loading branch information
gao-sun committed Jun 17, 2024
2 parents 4118669 + be1b570 commit 924ccb4
Show file tree
Hide file tree
Showing 11 changed files with 325 additions and 78 deletions.
9 changes: 9 additions & 0 deletions .changeset/breezy-bags-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@logto/connector-kit": major
---

remove `.catchall()` for `connectorMetadataGuard`

`.catchall()` allows unknown keys to be parsed as metadata. This is troublesome when we want to strip out unknown keys (Zod provides `.strip()` for this purpose but somehow it doesn't work with `.catchall()`).

For data extensibility, we added `customData` field to `ConnectorMetadata` type to store unknown keys. For example, the `fromEmail` field in `connector-logto-email` is not part of the standard metadata, so it should be stored in `customData` in the future.
9 changes: 9 additions & 0 deletions .changeset/cold-masks-film.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@logto/connector-google": minor
"@logto/connector-kit": minor
---

support Google One Tap

- support parsing and validating Google One Tap data in `connector-google`
- add Google connector constants in `connector-kit` for reuse
1 change: 1 addition & 0 deletions packages/connectors/connector-google/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"@logto/connector-kit": "workspace:^3.0.0",
"@silverhand/essentials": "^2.9.1",
"got": "^14.0.0",
"jose": "^5.0.0",
"snakecase-keys": "^8.0.0",
"zod": "^3.22.4"
},
Expand Down
16 changes: 13 additions & 3 deletions packages/connectors/connector-google/src/constant.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import type { ConnectorMetadata } from '@logto/connector-kit';
import { ConnectorConfigFormItemType, ConnectorPlatform } from '@logto/connector-kit';
import {
ConnectorConfigFormItemType,
ConnectorPlatform,
GoogleConnector,
} from '@logto/connector-kit';

export const authorizationEndpoint = 'https://accounts.google.com/o/oauth2/v2/auth';
export const accessTokenEndpoint = 'https://oauth2.googleapis.com/token';
export const userInfoEndpoint = 'https://openidconnect.googleapis.com/v1/userinfo';
export const scope = 'openid profile email';

// Instead of defining the metadata in the connector, we reuse the metadata from the connector-kit.
// This is not the normal practice, but Google One Tap is a special case.
// @see {@link GoogleConnector} for more information.
export const defaultMetadata: ConnectorMetadata = {
id: 'google-universal',
target: 'google',
id: GoogleConnector.factoryId,
target: GoogleConnector.target,
platform: ConnectorPlatform.Universal,
name: {
en: 'Google',
Expand Down Expand Up @@ -53,3 +60,6 @@ export const defaultMetadata: ConnectorMetadata = {
};

export const defaultTimeout = 5000;

// https://developers.google.com/identity/gsi/web/guides/verify-google-id-token
export const jwksUri = 'https://www.googleapis.com/oauth2/v3/certs';
44 changes: 44 additions & 0 deletions packages/connectors/connector-google/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,25 @@ import { mockedConfig } from './mock.js';

const getConfig = vi.fn().mockResolvedValue(mockedConfig);

vi.mock('jose', () => ({
createRemoteJWKSet: vi.fn().mockReturnValue({
getSigningKey: vi.fn().mockResolvedValue({
publicKey: 'publicKey',
}),
}),
jwtVerify: vi.fn().mockResolvedValue({
payload: {
sub: '1234567890',
name: 'John Wick',
given_name: 'John',
family_name: 'Wick',
email: '[email protected]',
email_verified: true,
picture: 'https://example.com/image.jpg',
},
}),
}));

describe('google connector', () => {
describe('getAuthorizationUri', () => {
afterEach(() => {
Expand Down Expand Up @@ -105,6 +124,31 @@ describe('google connector', () => {
});
});

it('should be able to decode ID token from Google One Tap', async () => {
const connector = await createConnector({ getConfig });
const socialUserInfo = await connector.getUserInfo(
{
credential: 'credential',
},
vi.fn()
);
expect(socialUserInfo).toStrictEqual({
id: '1234567890',
avatar: 'https://example.com/image.jpg',
name: 'John Wick',
email: '[email protected]',
rawData: {
sub: '1234567890',
name: 'John Wick',
given_name: 'John',
family_name: 'Wick',
email: '[email protected]',
email_verified: true,
picture: 'https://example.com/image.jpg',
},
});
});

it('throws SocialAccessTokenInvalid error if remote response code is 401', async () => {
nock(userInfoEndpoint).post('').reply(401);
const connector = await createConnector({ getConfig });
Expand Down
69 changes: 54 additions & 15 deletions packages/connectors/connector-google/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,17 @@ import type {
GetConnectorConfig,
CreateConnector,
SocialConnector,
GoogleConnectorConfig,
} from '@logto/connector-kit';
import {
ConnectorError,
ConnectorErrorCodes,
validateConfig,
ConnectorType,
parseJson,
GoogleConnector,
} from '@logto/connector-kit';
import { createRemoteJWKSet, jwtVerify } from 'jose';

import {
accessTokenEndpoint,
Expand All @@ -27,20 +30,20 @@ import {
userInfoEndpoint,
defaultMetadata,
defaultTimeout,
jwksUri,
} from './constant.js';
import type { GoogleConfig } from './types.js';
import {
googleConfigGuard,
accessTokenResponseGuard,
userInfoResponseGuard,
authResponseGuard,
googleOneTapDataGuard,
} from './types.js';

const getAuthorizationUri =
(getConfig: GetConnectorConfig): GetAuthorizationUri =>
async ({ state, redirectUri }) => {
const config = await getConfig(defaultMetadata.id);
validateConfig(config, googleConfigGuard);
validateConfig(config, GoogleConnector.configGuard);

const queryParameters = new URLSearchParams({
client_id: config.clientId,
Expand All @@ -54,7 +57,7 @@ const getAuthorizationUri =
};

export const getAccessToken = async (
config: GoogleConfig,
config: GoogleConnectorConfig,
codeObject: { code: string; redirectUri: string }
) => {
const { code, redirectUri } = codeObject;
Expand Down Expand Up @@ -86,22 +89,58 @@ export const getAccessToken = async (
return { accessToken };
};

type Json = ReturnType<typeof parseJson>;

/**
* Get user information JSON from Google Identity Platform. It will use the following order to
* retrieve user information:
*
* 1. Google One Tap: https://developers.google.com/identity/gsi/web/guides/verify-google-id-token
* 2. Normal Google OAuth: https://developers.google.com/identity/protocols/oauth2/openid-connect
*
* @param data The data from the client.
* @param config The configuration of the connector.
* @returns A Promise that resolves to the user information JSON.
*/
const getUserInfoJson = async (data: unknown, config: GoogleConnectorConfig): Promise<Json> => {
// Google One Tap
const oneTapResult = googleOneTapDataGuard.safeParse(data);

if (oneTapResult.success) {
const { payload } = await jwtVerify<Json>(
oneTapResult.data.credential,
createRemoteJWKSet(new URL(jwksUri)),
{
// https://developers.google.com/identity/gsi/web/guides/verify-google-id-token
issuer: ['https://accounts.google.com', 'accounts.google.com'],
audience: config.clientId,
clockTolerance: 10,
}
);
return payload;
}

// Normal Google OAuth
const { code, redirectUri } = await authorizationCallbackHandler(data);
const { accessToken } = await getAccessToken(config, { code, redirectUri });

const httpResponse = await got.post(userInfoEndpoint, {
headers: {
authorization: `Bearer ${accessToken}`,
},
timeout: { request: defaultTimeout },
});
return parseJson(httpResponse.body);
};

const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => {
const { code, redirectUri } = await authorizationCallbackHandler(data);
const config = await getConfig(defaultMetadata.id);
validateConfig(config, googleConfigGuard);
const { accessToken } = await getAccessToken(config, { code, redirectUri });
validateConfig(config, GoogleConnector.configGuard);

try {
const httpResponse = await got.post(userInfoEndpoint, {
headers: {
authorization: `Bearer ${accessToken}`,
},
timeout: { request: defaultTimeout },
});
const rawData = parseJson(httpResponse.body);
const rawData = await getUserInfoJson(data, config);
const result = userInfoResponseGuard.safeParse(rawData);

if (!result.success) {
Expand Down Expand Up @@ -150,7 +189,7 @@ const createGoogleConnector: CreateConnector<SocialConnector> = async ({ getConf
return {
metadata: defaultMetadata,
type: ConnectorType.Social,
configGuard: googleConfigGuard,
configGuard: GoogleConnector.configGuard,
getAuthorizationUri: getAuthorizationUri(getConfig),
getUserInfo: getUserInfo(getConfig),
};
Expand Down
16 changes: 9 additions & 7 deletions packages/connectors/connector-google/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import { z } from 'zod';

export const googleConfigGuard = z.object({
clientId: z.string(),
clientSecret: z.string(),
scope: z.string().optional(),
});

export type GoogleConfig = z.infer<typeof googleConfigGuard>;
import { GoogleConnector } from '@logto/connector-kit';

export const accessTokenResponseGuard = z.object({
access_token: z.string(),
Expand All @@ -33,3 +27,11 @@ export const authResponseGuard = z.object({
code: z.string(),
redirectUri: z.string(),
});

/**
* Response payload from Google One Tap. Note the CSRF token is not included since it should be
* verified by the web server.
*/
export const googleOneTapDataGuard = z.object({
[GoogleConnector.oneTapParams.credential]: z.string(),
});
6 changes: 5 additions & 1 deletion packages/toolkit/connector-kit/src/types/foundation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ZodType } from 'zod';
import type { ZodType, z } from 'zod';

import { type ConnectorMetadata } from './metadata.js';

Expand All @@ -17,3 +17,7 @@ export type BaseConnector<Type extends ConnectorType> = {
metadata: ConnectorMetadata;
configGuard: ZodType;
};

export type ToZodObject<T> = z.ZodObject<{
[K in keyof T]-?: z.ZodType<T[K]>;
}>;
31 changes: 26 additions & 5 deletions packages/toolkit/connector-kit/src/types/metadata.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { LanguageTag } from '@logto/language-kit';
import { isLanguageTag } from '@logto/language-kit';
import { type Nullable } from '@silverhand/essentials';
import type { ZodType } from 'zod';
import { z } from 'zod';

import { connectorConfigFormItemGuard } from './config-form.js';
import { type ToZodObject } from './foundation.js';

export enum ConnectorPlatform {
Native = 'Native',
Expand Down Expand Up @@ -34,12 +36,32 @@ export type I18nPhrases = { en: string } & {
[K in Exclude<LanguageTag, 'en'>]?: string;
};

export type SocialConnectorMetadata = {
platform: Nullable<ConnectorPlatform>;
isStandard?: boolean;
};

export const socialConnectorMetadataGuard = z.object({
// Social connector platform. TODO: @darcyYe considering remove the nullable and make all the social connector field optional
platform: z.nativeEnum(ConnectorPlatform).nullable(),
// Indicates custom connector that follows standard protocol. Currently supported standard connectors are OIDC, OAuth2, and SAML2
isStandard: z.boolean().optional(),
});
}) satisfies ToZodObject<SocialConnectorMetadata>;

export type ConnectorMetadata = {
id: string;
target: string;
name: I18nPhrases;
description: I18nPhrases;
logo: string;
logoDark: Nullable<string>;
readme: string;
configTemplate?: string;
formItems?: Array<z.infer<typeof connectorConfigFormItemGuard>>;
customData?: Record<string, unknown>;
/** @deprecated Use `customData` instead. */
fromEmail?: string;
} & SocialConnectorMetadata;

export const connectorMetadataGuard = z
.object({
Expand All @@ -57,11 +79,10 @@ export const connectorMetadataGuard = z
readme: z.string(),
configTemplate: z.string().optional(), // Connector config template
formItems: connectorConfigFormItemGuard.array().optional(),
customData: z.record(z.unknown()).optional(),
fromEmail: z.string().optional(),
})
.merge(socialConnectorMetadataGuard)
.catchall(z.unknown());

export type ConnectorMetadata = z.infer<typeof connectorMetadataGuard>;
.merge(socialConnectorMetadataGuard) satisfies ToZodObject<ConnectorMetadata>;

// Configurable connector metadata guard. Stored in DB metadata field
export const configurableConnectorMetadataGuard = connectorMetadataGuard
Expand Down
Loading

0 comments on commit 924ccb4

Please sign in to comment.