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": { "dependencies": {
"@logto/connector-kit": "workspace:^3.0.0", "@logto/connector-kit": "workspace:^3.0.0",
"@silverhand/essentials": "^2.9.0", "@silverhand/essentials": "^2.9.0",
"got": "^14.0.0", "ky": "^1.2.3",
"query-string": "^9.0.0", "query-string": "^9.0.0",
"snakecase-keys": "^8.0.0", "snakecase-keys": "^8.0.0",
"zod": "^3.22.4" "zod": "^3.22.4"
@ -64,7 +64,7 @@
"@vitest/coverage-v8": "^1.4.0", "@vitest/coverage-v8": "^1.4.0",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"lint-staged": "^15.0.2", "lint-staged": "^15.0.2",
"nock": "^13.3.1", "nock": "14.0.0-beta.6",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"rollup": "^4.12.0", "rollup": "^4.12.0",
"rollup-plugin-output-size": "^1.3.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'; import { ConnectorPlatform, ConnectorConfigFormItemType } from '@logto/connector-kit';
export const authorizationEndpoint = 'https://github.com/login/oauth/authorize'; 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 accessTokenEndpoint = 'https://github.com/login/oauth/access_token';
export const userInfoEndpoint = 'https://api.github.com/user'; 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 = { export const defaultMetadata: ConnectorMetadata = {
id: 'github-universal', id: 'github-universal',

View file

@ -1,9 +1,13 @@
import nock from 'nock'; import nock from 'nock';
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; 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 createConnector, { getAccessToken } from './index.js';
import { mockedConfig } from './mock.js'; import { mockedConfig } from './mock.js';
@ -28,7 +32,7 @@ describe('getAuthorizationUri', () => {
vi.fn() vi.fn()
); );
expect(authorizationUri).toEqual( 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 () => { it('should get an accessToken by exchanging with code', async () => {
nock(accessTokenEndpoint) nock(accessTokenEndpoint).post('').reply(200, {
.post('') access_token: 'access_token',
.reply( scope: 'scope',
200, token_type: 'token_type',
qs.stringify({ });
access_token: 'access_token',
scope: 'scope',
token_type: 'token_type',
})
);
const { accessToken } = await getAccessToken(mockedConfig, { code: 'code' }); const { accessToken } = await getAccessToken(mockedConfig, { code: 'code' });
expect(accessToken).toEqual('access_token'); expect(accessToken).toEqual('access_token');
}); });
@ -57,7 +56,7 @@ describe('getAccessToken', () => {
it('throws SocialAuthCodeInvalid error if accessToken not found in response', async () => { it('throws SocialAuthCodeInvalid error if accessToken not found in response', async () => {
nock(accessTokenEndpoint) nock(accessTokenEndpoint)
.post('') .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( await expect(getAccessToken(mockedConfig, { code: 'code' })).rejects.toStrictEqual(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid) new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)
); );
@ -66,16 +65,11 @@ describe('getAccessToken', () => {
describe('getUserInfo', () => { describe('getUserInfo', () => {
beforeEach(() => { beforeEach(() => {
nock(accessTokenEndpoint) nock(accessTokenEndpoint).post('').query(true).reply(200, {
.post('') access_token: 'access_token',
.reply( scope: 'scope',
200, token_type: 'token_type',
qs.stringify({ });
access_token: 'access_token',
scope: 'scope',
token_type: 'token_type',
})
);
}); });
afterEach(() => { afterEach(() => {
@ -91,6 +85,7 @@ describe('getUserInfo', () => {
email: 'octocat@github.com', email: 'octocat@github.com',
foo: 'bar', foo: 'bar',
}); });
nock(userEmailsEndpoint).get('').reply(200, []);
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
const socialUserInfo = await connector.getUserInfo({ code: 'code' }, vi.fn()); const socialUserInfo = await connector.getUserInfo({ code: 'code' }, vi.fn());
expect(socialUserInfo).toStrictEqual({ expect(socialUserInfo).toStrictEqual({
@ -99,11 +94,70 @@ describe('getUserInfo', () => {
name: 'monalisa octocat', name: 'monalisa octocat',
email: 'octocat@github.com', email: 'octocat@github.com',
rawData: { rawData: {
id: 1, userInfo: {
avatar_url: 'https://github.com/images/error/octocat_happy.gif', id: 1,
name: 'monalisa octocat', avatar_url: 'https://github.com/images/error/octocat_happy.gif',
email: 'octocat@github.com', name: 'monalisa octocat',
foo: 'bar', 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, name: null,
email: null, email: null,
}); });
nock(userEmailsEndpoint).get('').reply(200, []);
const connector = await createConnector({ getConfig }); const connector = await createConnector({ getConfig });
const socialUserInfo = await connector.getUserInfo({ code: 'code' }, vi.fn()); const socialUserInfo = await connector.getUserInfo({ code: 'code' }, vi.fn());
expect(socialUserInfo).toMatchObject({ expect(socialUserInfo).toMatchObject({
id: '1', id: '1',
rawData: { rawData: {
id: 1, userInfo: { id: 1, avatar_url: null, name: null, email: null },
avatar_url: null, userEmails: [],
name: null,
email: null,
}, },
}); });
}); });

View file

@ -1,6 +1,12 @@
import { assert, conditional } from '@silverhand/essentials'; import { assert, conditional } from '@silverhand/essentials';
import { got, HTTPError } from 'got';
import {
ConnectorError,
ConnectorErrorCodes,
validateConfig,
ConnectorType,
jsonGuard,
} from '@logto/connector-kit';
import type { import type {
GetAuthorizationUri, GetAuthorizationUri,
GetUserInfo, GetUserInfo,
@ -8,20 +14,14 @@ import type {
CreateConnector, CreateConnector,
GetConnectorConfig, GetConnectorConfig,
} from '@logto/connector-kit'; } from '@logto/connector-kit';
import { import ky, { HTTPError } from 'ky';
ConnectorError,
ConnectorErrorCodes,
validateConfig,
ConnectorType,
parseJson,
} from '@logto/connector-kit';
import qs from 'query-string';
import { import {
authorizationEndpoint, authorizationEndpoint,
accessTokenEndpoint, accessTokenEndpoint,
scope as defaultScope, scope as defaultScope,
userInfoEndpoint, userInfoEndpoint,
userEmailsEndpoint,
defaultMetadata, defaultMetadata,
defaultTimeout, defaultTimeout,
} from './constant.js'; } from './constant.js';
@ -29,6 +29,7 @@ import type { GithubConfig } from './types.js';
import { import {
authorizationCallbackErrorGuard, authorizationCallbackErrorGuard,
githubConfigGuard, githubConfigGuard,
emailAddressGuard,
accessTokenResponseGuard, accessTokenResponseGuard,
userInfoResponseGuard, userInfoResponseGuard,
authResponseGuard, authResponseGuard,
@ -79,17 +80,18 @@ export const getAccessToken = async (config: GithubConfig, codeObject: { code: s
const { code } = codeObject; const { code } = codeObject;
const { clientId: client_id, clientSecret: client_secret } = config; const { clientId: client_id, clientSecret: client_secret } = config;
const httpResponse = await got.post({ const httpResponse = await ky
url: accessTokenEndpoint, .post(accessTokenEndpoint, {
json: { body: new URLSearchParams({
client_id, client_id,
client_secret, client_secret,
code, code,
}, }),
timeout: { request: defaultTimeout }, timeout: defaultTimeout,
}); })
.json();
const result = accessTokenResponseGuard.safeParse(qs.parse(httpResponse.body)); const result = accessTokenResponseGuard.safeParse(httpResponse);
if (!result.success) { if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
@ -110,34 +112,54 @@ const getUserInfo =
validateConfig(config, githubConfigGuard); validateConfig(config, githubConfigGuard);
const { accessToken } = await getAccessToken(config, { code }); const { accessToken } = await getAccessToken(config, { code });
try { const authedApi = ky.create({
const httpResponse = await got.get(userInfoEndpoint, { timeout: defaultTimeout,
headers: { hooks: {
authorization: `token ${accessToken}`, beforeRequest: [
}, (request) => {
timeout: { request: defaultTimeout }, request.headers.set('Authorization', `Bearer ${accessToken}`);
}); },
const rawData = parseJson(httpResponse.body); ],
const result = userInfoResponseGuard.safeParse(rawData); },
});
if (!result.success) { try {
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);
} }
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 { return {
id: String(id), id: String(id),
avatar: conditional(avatar), avatar: conditional(avatar),
email: conditional(email), email: conditional(
publicEmail ??
userEmailsResult.data.find(({ verified, primary }) => verified && primary)?.email
),
name: conditional(name), name: conditional(name),
rawData, rawData: jsonGuard.parse({
userInfo,
userEmails,
}),
}; };
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof HTTPError) { 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); throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
} }

View file

@ -8,6 +8,17 @@ export const githubConfigGuard = z.object({
export type GithubConfig = z.infer<typeof githubConfigGuard>; 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({ export const accessTokenResponseGuard = z.object({
access_token: z.string(), access_token: z.string(),
scope: z.string(), scope: z.string(),

View file

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