Skip to content

Commit

Permalink
feat(connector): can access all user email even if no public email is…
Browse files Browse the repository at this point in the history
… set
  • Loading branch information
darcyYe committed May 6, 2024
1 parent c1c8410 commit fbc7e7f
Show file tree
Hide file tree
Showing 7 changed files with 180 additions and 91 deletions.
5 changes: 5 additions & 0 deletions .changeset/pretty-mirrors-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@logto/connector-github": minor
---

Can skip email completion step when not public email available
4 changes: 2 additions & 2 deletions packages/connectors/connector-github/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"dependencies": {
"@logto/connector-kit": "workspace:^3.0.0",
"@silverhand/essentials": "^2.9.0",
"got": "^14.0.0",
"ky": "^1.2.3",
"query-string": "^9.0.0",
"snakecase-keys": "^8.0.0",
"zod": "^3.22.4"
Expand Down Expand Up @@ -64,7 +64,7 @@
"@vitest/coverage-v8": "^1.4.0",
"eslint": "^8.56.0",
"lint-staged": "^15.0.2",
"nock": "^13.3.1",
"nock": "14.0.0-beta.6",
"prettier": "^3.0.0",
"rollup": "^4.12.0",
"rollup-plugin-output-size": "^1.3.0",
Expand Down
8 changes: 7 additions & 1 deletion packages/connectors/connector-github/src/constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@ import type { ConnectorMetadata } from '@logto/connector-kit';
import { ConnectorPlatform, ConnectorConfigFormItemType } from '@logto/connector-kit';

export const authorizationEndpoint = 'https://github.com/login/oauth/authorize';
export const scope = 'read:user';
/**
* `read:user` read user profile data; `user:email` read user email addresses (including private email addresses).
* Ref: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps
*/
export const scope = 'read:user user:email';
export const accessTokenEndpoint = 'https://github.com/login/oauth/access_token';
export const userInfoEndpoint = 'https://api.github.com/user';
// Ref: https://docs.github.com/en/rest/users/emails?apiVersion=2022-11-28#list-email-addresses-for-the-authenticated-user
export const userEmailsEndpoint = 'https://api.github.com/user/emails';

export const defaultMetadata: ConnectorMetadata = {
id: 'github-universal',
Expand Down
119 changes: 86 additions & 33 deletions packages/connectors/connector-github/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import nock from 'nock';

import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
import qs from 'query-string';

import { accessTokenEndpoint, authorizationEndpoint, userInfoEndpoint } from './constant.js';
import {
accessTokenEndpoint,
authorizationEndpoint,
userEmailsEndpoint,
userInfoEndpoint,
} from './constant.js';
import createConnector, { getAccessToken } from './index.js';
import { mockedConfig } from './mock.js';

Expand All @@ -28,7 +32,7 @@ describe('getAuthorizationUri', () => {
vi.fn()
);
expect(authorizationUri).toEqual(
`${authorizationEndpoint}?client_id=%3Cclient-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&state=some_state&scope=read%3Auser`
`${authorizationEndpoint}?client_id=%3Cclient-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&state=some_state&scope=read%3Auser+user%3Aemail`
);
});
});
Expand All @@ -40,24 +44,19 @@ describe('getAccessToken', () => {
});

it('should get an accessToken by exchanging with code', async () => {
nock(accessTokenEndpoint)
.post('')
.reply(
200,
qs.stringify({
access_token: 'access_token',
scope: 'scope',
token_type: 'token_type',
})
);
nock(accessTokenEndpoint).post('').reply(200, {
access_token: 'access_token',
scope: 'scope',
token_type: 'token_type',
});
const { accessToken } = await getAccessToken(mockedConfig, { code: 'code' });
expect(accessToken).toEqual('access_token');
});

it('throws SocialAuthCodeInvalid error if accessToken not found in response', async () => {
nock(accessTokenEndpoint)
.post('')
.reply(200, qs.stringify({ access_token: '', scope: 'scope', token_type: 'token_type' }));
.reply(200, { access_token: '', scope: 'scope', token_type: 'token_type' });
await expect(getAccessToken(mockedConfig, { code: 'code' })).rejects.toStrictEqual(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)
);
Expand All @@ -66,16 +65,11 @@ describe('getAccessToken', () => {

describe('getUserInfo', () => {
beforeEach(() => {
nock(accessTokenEndpoint)
.post('')
.reply(
200,
qs.stringify({
access_token: 'access_token',
scope: 'scope',
token_type: 'token_type',
})
);
nock(accessTokenEndpoint).post('').query(true).reply(200, {
access_token: 'access_token',
scope: 'scope',
token_type: 'token_type',
});
});

afterEach(() => {
Expand All @@ -91,6 +85,7 @@ describe('getUserInfo', () => {
email: 'octocat@github.com',
foo: 'bar',
});
nock(userEmailsEndpoint).get('').reply(200, []);
const connector = await createConnector({ getConfig });
const socialUserInfo = await connector.getUserInfo({ code: 'code' }, vi.fn());
expect(socialUserInfo).toStrictEqual({
Expand All @@ -99,11 +94,70 @@ describe('getUserInfo', () => {
name: 'monalisa octocat',
email: 'octocat@github.com',
rawData: {
id: 1,
avatar_url: 'https://github.com/images/error/octocat_happy.gif',
name: 'monalisa octocat',
email: 'octocat@github.com',
foo: 'bar',
userInfo: {
id: 1,
avatar_url: 'https://github.com/images/error/octocat_happy.gif',
name: 'monalisa octocat',
email: 'octocat@github.com',
foo: 'bar',
},
userEmails: [],
},
});
});

it('should fallback to verified primary email if not public is available', async () => {
nock(userInfoEndpoint).get('').reply(200, {
id: 1,
avatar_url: 'https://github.com/images/error/octocat_happy.gif',
name: 'monalisa octocat',
email: undefined,
foo: 'bar',
});
nock(userEmailsEndpoint)
.get('')
.reply(200, [
{
email: 'foo@logto.io',
verified: true,
primary: true,
visibility: 'public',
},
{
email: 'foo1@logto.io',
verified: true,
primary: false,
visibility: null,
},
]);
const connector = await createConnector({ getConfig });
const socialUserInfo = await connector.getUserInfo({ code: 'code' }, vi.fn());
expect(socialUserInfo).toStrictEqual({
id: '1',
avatar: 'https://github.com/images/error/octocat_happy.gif',
name: 'monalisa octocat',
email: 'foo@logto.io',
rawData: {
userInfo: {
id: 1,
avatar_url: 'https://github.com/images/error/octocat_happy.gif',
name: 'monalisa octocat',
foo: 'bar',
},
userEmails: [
{
email: 'foo@logto.io',
verified: true,
primary: true,
visibility: 'public',
},
{
email: 'foo1@logto.io',
verified: true,
primary: false,
visibility: null,
},
],
},
});
});
Expand All @@ -115,15 +169,14 @@ describe('getUserInfo', () => {
name: null,
email: null,
});
nock(userEmailsEndpoint).get('').reply(200, []);
const connector = await createConnector({ getConfig });
const socialUserInfo = await connector.getUserInfo({ code: 'code' }, vi.fn());
expect(socialUserInfo).toMatchObject({
id: '1',
rawData: {
id: 1,
avatar_url: null,
name: null,
email: null,
userInfo: { id: 1, avatar_url: null, name: null, email: null },
userEmails: [],
},
});
});
Expand Down
92 changes: 57 additions & 35 deletions packages/connectors/connector-github/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,35 @@
import { assert, conditional } from '@silverhand/essentials';
import { got, HTTPError } from 'got';

import {
ConnectorError,
ConnectorErrorCodes,
validateConfig,
ConnectorType,
jsonGuard,
} from '@logto/connector-kit';
import type {
GetAuthorizationUri,
GetUserInfo,
SocialConnector,
CreateConnector,
GetConnectorConfig,
} from '@logto/connector-kit';
import {
ConnectorError,
ConnectorErrorCodes,
validateConfig,
ConnectorType,
parseJson,
} from '@logto/connector-kit';
import qs from 'query-string';
import ky, { HTTPError } from 'ky';

import {
authorizationEndpoint,
accessTokenEndpoint,
scope as defaultScope,
userInfoEndpoint,
userEmailsEndpoint,
defaultMetadata,
defaultTimeout,
} from './constant.js';
import type { GithubConfig } from './types.js';
import {
authorizationCallbackErrorGuard,
githubConfigGuard,
emailAddressGuard,
accessTokenResponseGuard,
userInfoResponseGuard,
authResponseGuard,
Expand Down Expand Up @@ -79,17 +80,18 @@ export const getAccessToken = async (config: GithubConfig, codeObject: { code: s
const { code } = codeObject;
const { clientId: client_id, clientSecret: client_secret } = config;

const httpResponse = await got.post({
url: accessTokenEndpoint,
json: {
client_id,
client_secret,
code,
},
timeout: { request: defaultTimeout },
});
const httpResponse = await ky
.post(accessTokenEndpoint, {
body: new URLSearchParams({
client_id,
client_secret,
code,
}),
timeout: defaultTimeout,
})
.json();

const result = accessTokenResponseGuard.safeParse(qs.parse(httpResponse.body));
const result = accessTokenResponseGuard.safeParse(httpResponse);

if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
Expand All @@ -110,34 +112,54 @@ const getUserInfo =
validateConfig(config, githubConfigGuard);
const { accessToken } = await getAccessToken(config, { code });

const authedApi = ky.create({
timeout: defaultTimeout,
hooks: {
beforeRequest: [
(request) => {
request.headers.set('Authorization', `Bearer ${accessToken}`);
},
],
},
});

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

if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
const [userInfo, userEmails] = await Promise.all([
authedApi.get(userInfoEndpoint).json(),
authedApi.get(userEmailsEndpoint).json(),
]);

const userInfoResult = userInfoResponseGuard.safeParse(userInfo);
const userEmailsResult = emailAddressGuard.array().safeParse(userEmails);

if (!userInfoResult.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, userInfoResult.error);
}

if (!userEmailsResult.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, userEmailsResult.error);
}

const { id, avatar_url: avatar, email, name } = result.data;
const { id, avatar_url: avatar, email: publicEmail, name } = userInfoResult.data;

return {
id: String(id),
avatar: conditional(avatar),
email: conditional(email),
email: conditional(
publicEmail ??
userEmailsResult.data.find(({ verified, primary }) => verified && primary)?.email
),
name: conditional(name),
rawData,
rawData: jsonGuard.parse({
userInfo,
userEmails,
}),
};
} catch (error: unknown) {
if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response;
const { status, body: rawBody } = error.response;

if (statusCode === 401) {
if (status === 401) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}

Expand Down
11 changes: 11 additions & 0 deletions packages/connectors/connector-github/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ export const githubConfigGuard = z.object({

export type GithubConfig = z.infer<typeof githubConfigGuard>;

/**
* This guard is used to validate the response from the GitHub API when requesting the user's email addresses.
* Ref: https://docs.github.com/en/rest/users/emails?apiVersion=2022-11-28#list-email-addresses-for-the-authenticated-user
*/
export const emailAddressGuard = z.object({
email: z.string(),
primary: z.boolean(),
verified: z.boolean(),
visibility: z.string().nullable(),
});

export const accessTokenResponseGuard = z.object({
access_token: z.string(),
scope: z.string(),
Expand Down

0 comments on commit fbc7e7f

Please sign in to comment.