0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(connector): can access all user email even if no public email is set (#5737)

This commit is contained in:
Darcy Ye 2024-05-09 12:55:56 +08:00 committed by GitHub
parent f8221a38db
commit 0227822b2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 179 additions and 76 deletions

View file

@ -0,0 +1,8 @@
---
"@logto/connector-github": minor
---
fetch GitHub account's private email address list and pick the verified primary email as a fallback
- Add `user:email` as part of default scope to fetch GitHub account's private email address list
- Pick the verified primary email among private email address list as a fallback if the user does not set a public email for GitHub account

View file

@ -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"
@ -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",

View file

@ -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',

View file

@ -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';
@ -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`
);
});
});
@ -40,16 +44,11 @@ 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');
});
@ -57,7 +56,7 @@ describe('getAccessToken', () => {
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)
);
@ -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(() => {
@ -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({
@ -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,
},
],
},
});
});
@ -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: [],
},
});
});

View file

@ -1,6 +1,12 @@
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,
@ -8,20 +14,14 @@ import type {
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';
@ -29,6 +29,7 @@ import type { GithubConfig } from './types.js';
import {
authorizationCallbackErrorGuard,
githubConfigGuard,
emailAddressGuard,
accessTokenResponseGuard,
userInfoResponseGuard,
authResponseGuard,
@ -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);
@ -110,34 +112,54 @@ const getUserInfo =
validateConfig(config, githubConfigGuard);
const { accessToken } = await getAccessToken(config, { code });
try {
const httpResponse = await got.get(userInfoEndpoint, {
headers: {
authorization: `token ${accessToken}`,
},
timeout: { request: defaultTimeout },
});
const rawData = parseJson(httpResponse.body);
const result = userInfoResponseGuard.safeParse(rawData);
const authedApi = ky.create({
timeout: defaultTimeout,
hooks: {
beforeRequest: [
(request) => {
request.headers.set('Authorization', `Bearer ${accessToken}`);
},
],
},
});
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
try {
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);
}
const { id, avatar_url: avatar, email, name } = result.data;
if (!userEmailsResult.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, userEmailsResult.error);
}
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);
}

View file

@ -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(),

View file

@ -981,9 +981,9 @@ importers:
'@silverhand/essentials':
specifier: ^2.9.0
version: 2.9.0
got:
specifier: ^14.0.0
version: 14.0.0
ky:
specifier: ^1.2.3
version: 1.2.3
query-string:
specifier: ^9.0.0
version: 9.0.0
@ -1028,8 +1028,8 @@ importers:
specifier: ^15.0.2
version: 15.0.2
nock:
specifier: ^13.3.1
version: 13.3.1
specifier: 14.0.0-beta.6
version: 14.0.0-beta.6
prettier:
specifier: ^3.0.0
version: 3.0.0
@ -18007,6 +18007,9 @@ packages:
resolution: {integrity: sha512-2GTVocFkwblV/TIg9AmT7TI2fO4xdWkyN8aFUEVtiVNWt96GTR3FgQyHFValfCbcj1k9Xf962Ws2hYXYUr9k1Q==}
engines: {node: '>= 12.0.0'}
hasBin: true
peerDependenciesMeta:
'@parcel/core':
optional: true
dependencies:
'@parcel/config-default': 2.9.3(@parcel/core@2.9.3)(postcss@8.4.31)
'@parcel/core': 2.9.3