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:
parent
f8221a38db
commit
0227822b2d
7 changed files with 179 additions and 76 deletions
8
.changeset/pretty-mirrors-peel.md
Normal file
8
.changeset/pretty-mirrors-peel.md
Normal 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
|
|
@ -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",
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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({
|
||||
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({
|
||||
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,12 +94,71 @@ describe('getUserInfo', () => {
|
|||
name: 'monalisa octocat',
|
||||
email: 'octocat@github.com',
|
||||
rawData: {
|
||||
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: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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: {
|
||||
const httpResponse = await ky
|
||||
.post(accessTokenEndpoint, {
|
||||
body: new URLSearchParams({
|
||||
client_id,
|
||||
client_secret,
|
||||
code,
|
||||
},
|
||||
timeout: { request: defaultTimeout },
|
||||
});
|
||||
}),
|
||||
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}`,
|
||||
const authedApi = ky.create({
|
||||
timeout: defaultTimeout,
|
||||
hooks: {
|
||||
beforeRequest: [
|
||||
(request) => {
|
||||
request.headers.set('Authorization', `Bearer ${accessToken}`);
|
||||
},
|
||||
],
|
||||
},
|
||||
timeout: { request: defaultTimeout },
|
||||
});
|
||||
const rawData = parseJson(httpResponse.body);
|
||||
const result = userInfoResponseGuard.safeParse(rawData);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue