diff --git a/jest.config.ts b/jest.config.ts index f4e712f0e..bac4e32fd 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -14,9 +14,10 @@ const config: Config = { }, // Allow transforming specific ESM packages in node_modules that ship untranspiled ESM. // @primer/* libraries rely on lit and @lit-labs/react internally for some components. + // @octokit/* libraries rely on universal-user-agent internally. // We also include GitHub web components that ship ESM-only builds. transformIgnorePatterns: [ - 'node_modules/(?!(?:@primer/react|@primer/primitives|@primer/octicons-react|@lit-labs/react|lit|@github/relative-time-element|@github/tab-container-element)/)', + 'node_modules/(?!(?:@primer/react|@primer/primitives|@primer/octicons-react|@lit-labs/react|lit|@github/relative-time-element|@github/tab-container-element|@octokit/oauth-methods|@octokit/oauth-authorization-url|@octokit/request|@octokit/request-error|@octokit/endpoint|universal-user-agent)/)', ], moduleNameMapper: { // Force CommonJS build for http adapter to be available. diff --git a/package.json b/package.json index 51dc286a0..143d7e662 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,9 @@ "@electron/notarize": "3.1.1", "@graphql-codegen/cli": "6.1.1", "@graphql-codegen/schema-ast": "5.0.0", + "@octokit/oauth-methods": "6.0.2", "@octokit/openapi-types": "27.0.0", + "@octokit/request": "10.0.7", "@parcel/watcher": "2.5.4", "@primer/css": "22.1.0", "@primer/octicons-react": "19.21.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 265e46986..5da90e071 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,9 +48,15 @@ importers: '@graphql-codegen/schema-ast': specifier: 5.0.0 version: 5.0.0(graphql@16.12.0) + '@octokit/oauth-methods': + specifier: 6.0.2 + version: 6.0.2 '@octokit/openapi-types': specifier: 27.0.0 version: 27.0.0 + '@octokit/request': + specifier: 10.0.7 + version: 10.0.7 '@parcel/watcher': specifier: 2.5.4 version: 2.5.4 @@ -1674,9 +1680,32 @@ packages: resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==} engines: {node: ^18.17.0 || >=20.5.0} + '@octokit/endpoint@11.0.2': + resolution: {integrity: sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==} + engines: {node: '>= 20'} + + '@octokit/oauth-authorization-url@8.0.0': + resolution: {integrity: sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==} + engines: {node: '>= 20'} + + '@octokit/oauth-methods@6.0.2': + resolution: {integrity: sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng==} + engines: {node: '>= 20'} + '@octokit/openapi-types@27.0.0': resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + '@octokit/request-error@7.1.0': + resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} + engines: {node: '>= 20'} + + '@octokit/request@10.0.7': + resolution: {integrity: sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==} + engines: {node: '>= 20'} + + '@octokit/types@16.0.0': + resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@oddbird/popover-polyfill@0.5.2': resolution: {integrity: sha512-iFrvar5SOMtKFOSjYvs4z9UlLqDdJbMx0mgISLcPedv+g0ac5sgeETLGtipHCVIae6HJPclNEH5aCyD1RZaEHw==} @@ -3325,6 +3354,9 @@ packages: resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} engines: {'0': node >=0.6.0} + fast-content-type-parse@3.0.0: + resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -5687,6 +5719,9 @@ packages: resolution: {integrity: sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==} engines: {node: ^18.17.0 || >=20.5.0} + universal-user-agent@7.0.3: + resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -7988,8 +8023,38 @@ snapshots: dependencies: semver: 7.7.3 + '@octokit/endpoint@11.0.2': + dependencies: + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/oauth-authorization-url@8.0.0': {} + + '@octokit/oauth-methods@6.0.2': + dependencies: + '@octokit/oauth-authorization-url': 8.0.0 + '@octokit/request': 10.0.7 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + '@octokit/openapi-types@27.0.0': {} + '@octokit/request-error@7.1.0': + dependencies: + '@octokit/types': 16.0.0 + + '@octokit/request@10.0.7': + dependencies: + '@octokit/endpoint': 11.0.2 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + fast-content-type-parse: 3.0.0 + universal-user-agent: 7.0.3 + + '@octokit/types@16.0.0': + dependencies: + '@octokit/openapi-types': 27.0.0 + '@oddbird/popover-polyfill@0.5.2': {} '@parcel/watcher-android-arm64@2.5.4': @@ -9812,6 +9877,8 @@ snapshots: extsprintf@1.4.1: optional: true + fast-content-type-parse@3.0.0: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -12330,6 +12397,8 @@ snapshots: dependencies: imurmurhash: 0.1.4 + universal-user-agent@7.0.3: {} + universalify@0.1.2: {} universalify@2.0.1: {} diff --git a/src/main/menu.ts b/src/main/menu.ts index 2bf472f2d..da6f9f8f3 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -78,7 +78,9 @@ export default class MenuBuilder { { label: 'Visit Repository', click: () => { - shell.openExternal(`https://github.com/${APPLICATION.REPO_SLUG}`); + shell.openExternal( + `${APPLICATION.GITHUB_BASE_URL}/${APPLICATION.REPO_SLUG}`, + ); }, }, { diff --git a/src/renderer/constants.ts b/src/renderer/constants.ts index 340f6c3b4..c81c24317 100644 --- a/src/renderer/constants.ts +++ b/src/renderer/constants.ts @@ -1,4 +1,5 @@ import type { ClientID, ClientSecret, Hostname, Link } from './types'; +import type { LoginOAuthAppOptions } from './utils/auth/types'; export const Constants = { STORAGE_KEY: 'gitify-storage', @@ -13,7 +14,7 @@ export const Constants = { hostname: 'github.com' as Hostname, clientId: process.env.OAUTH_CLIENT_ID as ClientID, clientSecret: process.env.OAUTH_CLIENT_SECRET as ClientSecret, - }, + } satisfies LoginOAuthAppOptions, GITHUB_API_BASE_URL: 'https://api.github.com', GITHUB_API_GRAPHQL_URL: 'https://api.github.com/graphql', diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index 2ef6b33db..4d37c4491 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -39,10 +39,10 @@ import type { import { headNotifications } from '../utils/api/client'; import { addAccount, - authGitHub, + exchangeAuthCodeForAccessToken, getAccountUUID, - getToken, hasAccounts, + performGitHubOAuth, refreshAccount, removeAccount, } from '../utils/auth/utils'; @@ -395,9 +395,15 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { return hasAccounts(auth); }, [auth]); + /** + * Login with GitHub App. + * + * Note: although we call this "Login with GitHub App", this function actually + * authenticates via a predefined "Gitify" GitHub OAuth App. + */ const loginWithGitHubApp = useCallback(async () => { - const { authCode } = await authGitHub(); - const { token } = await getToken(authCode); + const { authCode } = await performGitHubOAuth(); + const token = await exchangeAuthCodeForAccessToken(authCode); const hostname = Constants.DEFAULT_AUTH_OPTIONS.hostname; const updatedAuth = await addAccount(auth, 'GitHub App', token, hostname); @@ -405,18 +411,29 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { persistAuth(updatedAuth); }, [auth, persistAuth]); + /** + * Login with custom GitHub OAuth App. + */ const loginWithOAuthApp = useCallback( async (data: LoginOAuthAppOptions) => { - const { authOptions, authCode } = await authGitHub(data); - const { token, hostname } = await getToken(authCode, authOptions); + const { authOptions, authCode } = await performGitHubOAuth(data); + const token = await exchangeAuthCodeForAccessToken(authCode, authOptions); - const updatedAuth = await addAccount(auth, 'OAuth App', token, hostname); + const updatedAuth = await addAccount( + auth, + 'OAuth App', + token, + authOptions.hostname, + ); persistAuth(updatedAuth); }, [auth, persistAuth], ); + /** + * Login with Personal Access Token (PAT). + */ const loginWithPersonalAccessToken = useCallback( async ({ token, hostname }: LoginPersonalAccessTokenOptions) => { const encryptedToken = (await encryptValue(token)) as Token; diff --git a/src/renderer/utils/api/graphql/generated/gql.ts b/src/renderer/utils/api/graphql/generated/gql.ts index e72c24c08..aa6d78043 100644 --- a/src/renderer/utils/api/graphql/generated/gql.ts +++ b/src/renderer/utils/api/graphql/generated/gql.ts @@ -20,7 +20,7 @@ type Documents = { "query FetchIssueByNumber($owner: String!, $name: String!, $number: Int!, $lastComments: Int, $firstLabels: Int) {\n repository(owner: $owner, name: $name) {\n issue(number: $number) {\n ...IssueDetails\n }\n }\n}\n\nfragment IssueDetails on Issue {\n __typename\n number\n title\n url\n state\n stateReason\n milestone {\n ...MilestoneFields\n }\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n url\n author {\n ...AuthorFields\n }\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n}": typeof types.FetchIssueByNumberDocument, "query FetchMergedDetailsTemplate($ownerINDEX: String!, $nameINDEX: String!, $numberINDEX: Int!, $isDiscussionNotificationINDEX: Boolean!, $isIssueNotificationINDEX: Boolean!, $isPullRequestNotificationINDEX: Boolean!, $lastComments: Int, $lastThreadedComments: Int, $lastReplies: Int, $lastReviews: Int, $firstLabels: Int, $firstClosingIssues: Int, $includeIsAnswered: Boolean!) {\n ...MergedDetailsQueryTemplate\n}\n\nfragment MergedDetailsQueryTemplate on Query {\n repository(owner: $ownerINDEX, name: $nameINDEX) {\n discussion(number: $numberINDEX) @include(if: $isDiscussionNotificationINDEX) {\n ...DiscussionDetails\n }\n issue(number: $numberINDEX) @include(if: $isIssueNotificationINDEX) {\n ...IssueDetails\n }\n pullRequest(number: $numberINDEX) @include(if: $isPullRequestNotificationINDEX) {\n ...PullRequestDetails\n }\n }\n}": typeof types.FetchMergedDetailsTemplateDocument, "query FetchPullRequestByNumber($owner: String!, $name: String!, $number: Int!, $firstLabels: Int, $lastComments: Int, $lastReviews: Int, $firstClosingIssues: Int) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n ...PullRequestDetails\n }\n }\n}\n\nfragment PullRequestDetails on PullRequest {\n __typename\n number\n title\n url\n state\n merged\n isDraft\n isInMergeQueue\n milestone {\n ...MilestoneFields\n }\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n url\n author {\n ...AuthorFields\n }\n }\n }\n reviews(last: $lastReviews) {\n totalCount\n nodes {\n ...PullRequestReviewFields\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n closingIssuesReferences(first: $firstClosingIssues) {\n nodes {\n number\n }\n }\n}\n\nfragment PullRequestReviewFields on PullRequestReview {\n state\n author {\n login\n }\n}": typeof types.FetchPullRequestByNumberDocument, - "query FetchAuthenticatedUserDetails {\n viewer {\n id\n name\n login\n avatarUrl\n }\n}": typeof types.FetchAuthenticatedUserDetailsDocument, + "query FetchAuthenticatedUserDetails {\n viewer {\n id\n name\n login\n avatar: avatarUrl\n }\n}": typeof types.FetchAuthenticatedUserDetailsDocument, }; const documents: Documents = { "fragment AuthorFields on Actor {\n login\n htmlUrl: url\n avatarUrl: avatarUrl\n type: __typename\n}\n\nfragment MilestoneFields on Milestone {\n state\n title\n}": types.AuthorFieldsFragmentDoc, @@ -28,7 +28,7 @@ const documents: Documents = { "query FetchIssueByNumber($owner: String!, $name: String!, $number: Int!, $lastComments: Int, $firstLabels: Int) {\n repository(owner: $owner, name: $name) {\n issue(number: $number) {\n ...IssueDetails\n }\n }\n}\n\nfragment IssueDetails on Issue {\n __typename\n number\n title\n url\n state\n stateReason\n milestone {\n ...MilestoneFields\n }\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n url\n author {\n ...AuthorFields\n }\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n}": types.FetchIssueByNumberDocument, "query FetchMergedDetailsTemplate($ownerINDEX: String!, $nameINDEX: String!, $numberINDEX: Int!, $isDiscussionNotificationINDEX: Boolean!, $isIssueNotificationINDEX: Boolean!, $isPullRequestNotificationINDEX: Boolean!, $lastComments: Int, $lastThreadedComments: Int, $lastReplies: Int, $lastReviews: Int, $firstLabels: Int, $firstClosingIssues: Int, $includeIsAnswered: Boolean!) {\n ...MergedDetailsQueryTemplate\n}\n\nfragment MergedDetailsQueryTemplate on Query {\n repository(owner: $ownerINDEX, name: $nameINDEX) {\n discussion(number: $numberINDEX) @include(if: $isDiscussionNotificationINDEX) {\n ...DiscussionDetails\n }\n issue(number: $numberINDEX) @include(if: $isIssueNotificationINDEX) {\n ...IssueDetails\n }\n pullRequest(number: $numberINDEX) @include(if: $isPullRequestNotificationINDEX) {\n ...PullRequestDetails\n }\n }\n}": types.FetchMergedDetailsTemplateDocument, "query FetchPullRequestByNumber($owner: String!, $name: String!, $number: Int!, $firstLabels: Int, $lastComments: Int, $lastReviews: Int, $firstClosingIssues: Int) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n ...PullRequestDetails\n }\n }\n}\n\nfragment PullRequestDetails on PullRequest {\n __typename\n number\n title\n url\n state\n merged\n isDraft\n isInMergeQueue\n milestone {\n ...MilestoneFields\n }\n author {\n ...AuthorFields\n }\n comments(last: $lastComments) {\n totalCount\n nodes {\n url\n author {\n ...AuthorFields\n }\n }\n }\n reviews(last: $lastReviews) {\n totalCount\n nodes {\n ...PullRequestReviewFields\n }\n }\n labels(first: $firstLabels) {\n nodes {\n name\n }\n }\n closingIssuesReferences(first: $firstClosingIssues) {\n nodes {\n number\n }\n }\n}\n\nfragment PullRequestReviewFields on PullRequestReview {\n state\n author {\n login\n }\n}": types.FetchPullRequestByNumberDocument, - "query FetchAuthenticatedUserDetails {\n viewer {\n id\n name\n login\n avatarUrl\n }\n}": types.FetchAuthenticatedUserDetailsDocument, + "query FetchAuthenticatedUserDetails {\n viewer {\n id\n name\n login\n avatar: avatarUrl\n }\n}": types.FetchAuthenticatedUserDetailsDocument, }; /** @@ -54,7 +54,7 @@ export function graphql(source: "query FetchPullRequestByNumber($owner: String!, /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "query FetchAuthenticatedUserDetails {\n viewer {\n id\n name\n login\n avatarUrl\n }\n}"): typeof import('./graphql').FetchAuthenticatedUserDetailsDocument; +export function graphql(source: "query FetchAuthenticatedUserDetails {\n viewer {\n id\n name\n login\n avatar: avatarUrl\n }\n}"): typeof import('./graphql').FetchAuthenticatedUserDetailsDocument; export function graphql(source: string) { diff --git a/src/renderer/utils/api/graphql/generated/graphql.ts b/src/renderer/utils/api/graphql/generated/graphql.ts index 9802e1530..24969e990 100644 --- a/src/renderer/utils/api/graphql/generated/graphql.ts +++ b/src/renderer/utils/api/graphql/generated/graphql.ts @@ -35855,7 +35855,7 @@ export type PullRequestReviewFieldsFragment = { __typename?: 'PullRequestReview' export type FetchAuthenticatedUserDetailsQueryVariables = Exact<{ [key: string]: never; }>; -export type FetchAuthenticatedUserDetailsQuery = { __typename?: 'Query', viewer: { __typename?: 'User', id: string, name?: string | null, login: string, avatarUrl: any } }; +export type FetchAuthenticatedUserDetailsQuery = { __typename?: 'Query', viewer: { __typename?: 'User', id: string, name?: string | null, login: string, avatar: any } }; export class TypedDocumentString extends String @@ -36528,7 +36528,7 @@ export const FetchAuthenticatedUserDetailsDocument = new TypedDocumentString(` id name login - avatarUrl + avatar: avatarUrl } } `) as unknown as TypedDocumentString; \ No newline at end of file diff --git a/src/renderer/utils/api/graphql/user.graphql b/src/renderer/utils/api/graphql/user.graphql index a69b914f6..9f0d86b71 100644 --- a/src/renderer/utils/api/graphql/user.graphql +++ b/src/renderer/utils/api/graphql/user.graphql @@ -3,6 +3,6 @@ query FetchAuthenticatedUserDetails { id name login - avatarUrl + avatar: avatarUrl } } diff --git a/src/renderer/utils/api/request.test.ts b/src/renderer/utils/api/request.test.ts index 2b865971a..de501988b 100644 --- a/src/renderer/utils/api/request.test.ts +++ b/src/renderer/utils/api/request.test.ts @@ -11,7 +11,6 @@ import type { Link } from '../../types'; import { FetchAuthenticatedUserDetailsDocument } from './graphql/generated/graphql'; import { - apiRequest, apiRequestAuth, getHeaders, performGraphQLRequest, @@ -29,33 +28,6 @@ describe('renderer/utils/api/request.ts', () => { jest.clearAllMocks(); }); - describe('apiRequest', () => { - it('should make a request with the correct parameters', async () => { - const data = { key: 'value' }; - - await apiRequest(url, method, data); - - expect(axios).toHaveBeenCalledWith({ - method, - url, - data, - headers: mockNoAuthHeaders, - }); - }); - - it('should make a request with the correct parameters and default data', async () => { - const data = {}; - await apiRequest(url, method); - - expect(axios).toHaveBeenCalledWith({ - method, - url, - data, - headers: mockNoAuthHeaders, - }); - }); - }); - describe('apiRequestAuth', () => { afterEach(() => { jest.clearAllMocks(); diff --git a/src/renderer/utils/api/request.ts b/src/renderer/utils/api/request.ts index c52da0c2c..b3971b206 100644 --- a/src/renderer/utils/api/request.ts +++ b/src/renderer/utils/api/request.ts @@ -19,24 +19,6 @@ export type ExecutionResultWithHeaders = ExecutionResult & { headers: Record; }; -/** - * Perform an unauthenticated API request - * - * @param url - * @param method - * @param data - * @returns - */ -export async function apiRequest( - url: Link, - method: Method, - data = {}, -): Promise { - const headers = await getHeaders(url); - - return axios({ method, url, data, headers }); -} - /** * Perform an authenticated API request * diff --git a/src/renderer/utils/api/utils.test.ts b/src/renderer/utils/api/utils.test.ts index f543a468d..9362ddf74 100644 --- a/src/renderer/utils/api/utils.test.ts +++ b/src/renderer/utils/api/utils.test.ts @@ -4,11 +4,24 @@ import type { Hostname } from '../../types'; import { getGitHubAPIBaseUrl, + getGitHubAuthBaseUrl, getGitHubGraphQLUrl, getNextURLFromLinkHeader, } from './utils'; describe('renderer/utils/api/utils.ts', () => { + describe('getGitHubAuthBaseUrl', () => { + it('should generate a GitHub Auth url - non enterprise', () => { + const result = getGitHubAuthBaseUrl('github.com' as Hostname); + expect(result.toString()).toBe('https://github.com/'); + }); + + it('should generate a GitHub Auth url - enterprise', () => { + const result = getGitHubAuthBaseUrl('github.gitify.io' as Hostname); + expect(result.toString()).toBe('https://github.gitify.io/api/v3/'); + }); + }); + describe('getGitHubAPIBaseUrl', () => { it('should generate a GitHub API url - non enterprise', () => { const result = getGitHubAPIBaseUrl('github.com' as Hostname); diff --git a/src/renderer/utils/api/utils.ts b/src/renderer/utils/api/utils.ts index 4e1c54746..706d09893 100644 --- a/src/renderer/utils/api/utils.ts +++ b/src/renderer/utils/api/utils.ts @@ -1,5 +1,7 @@ import type { AxiosResponse } from 'axios'; +import { APPLICATION } from '../../../shared/constants'; + import { Constants } from '../../constants'; import type { Hostname } from '../../types'; @@ -16,6 +18,16 @@ export function getGitHubAPIBaseUrl(hostname: Hostname): URL { return url; } +export function getGitHubAuthBaseUrl(hostname: Hostname): URL { + const url = new URL(APPLICATION.GITHUB_BASE_URL); + + if (isEnterpriseServerHost(hostname)) { + url.hostname = hostname; + url.pathname = '/api/v3/'; + } + return url; +} + export function getGitHubGraphQLUrl(hostname: Hostname): URL { const url = new URL(Constants.GITHUB_API_GRAPHQL_URL); diff --git a/src/renderer/utils/auth/types.ts b/src/renderer/utils/auth/types.ts index c50b68621..684f73904 100644 --- a/src/renderer/utils/auth/types.ts +++ b/src/renderer/utils/auth/types.ts @@ -26,8 +26,3 @@ export interface AuthResponse { authCode: AuthCode; authOptions: LoginOAuthAppOptions; } - -export interface AuthTokenResponse { - hostname: Hostname; - token: Token; -} diff --git a/src/renderer/utils/auth/utils.test.ts b/src/renderer/utils/auth/utils.test.ts index 06acb3c9d..98fc67a8c 100644 --- a/src/renderer/utils/auth/utils.test.ts +++ b/src/renderer/utils/auth/utils.test.ts @@ -1,5 +1,3 @@ -import type { AxiosResponse } from 'axios'; - import { configureAxiosHttpAdapterForNock } from '../../__helpers__/test-utils'; import { mockGitHubCloudAccount } from '../../__mocks__/account-mocks'; import { mockAuth } from '../../__mocks__/state-mocks'; @@ -20,13 +18,20 @@ import type { AuthMethod } from './types'; import * as comms from '../../utils/comms'; import * as apiClient from '../api/client'; -import type { FetchAuthenticatedUserDetailsQuery } from '../api/graphql/generated/graphql'; -import * as apiRequests from '../api/request'; import * as logger from '../logger'; import * as authUtils from './utils'; import { getNewOAuthAppURL, getNewTokenURL } from './utils'; -type UserDetailsResponse = FetchAuthenticatedUserDetailsQuery['viewer']; +jest.mock('@octokit/oauth-methods', () => ({ + ...jest.requireActual('@octokit/oauth-methods'), + exchangeWebFlowCode: jest.fn(), +})); + +import { exchangeWebFlowCode } from '@octokit/oauth-methods'; + +const exchangeWebFlowCodeMock = exchangeWebFlowCode as jest.MockedFunction< + typeof exchangeWebFlowCode +>; describe('renderer/utils/auth/utils.ts', () => { beforeEach(() => { @@ -43,18 +48,20 @@ describe('renderer/utils/auth/utils.ts', () => { jest.clearAllMocks(); }); - it('should call authGitHub - success auth flow', async () => { + it('should call performGitHubOAuth using gitify oauth app - success auth flow', async () => { window.gitify.onAuthCallback = jest .fn() .mockImplementation((callback) => { callback('gitify://auth?code=123-456'); }); - const res = await authUtils.authGitHub(); + const res = await authUtils.performGitHubOAuth(); expect(openExternalLinkSpy).toHaveBeenCalledTimes(1); expect(openExternalLinkSpy).toHaveBeenCalledWith( - 'https://github.com/login/oauth/authorize?client_id=FAKE_CLIENT_ID_123&scope=read%3Auser%2Cnotifications%2Crepo', + expect.stringContaining( + 'https://github.com/login/oauth/authorize?allow_signup=false&client_id=FAKE_CLIENT_ID_123&scope=read%3Auser%2Cnotifications%2Crepo', + ), ); expect(window.gitify.onAuthCallback).toHaveBeenCalledTimes(1); @@ -66,14 +73,14 @@ describe('renderer/utils/auth/utils.ts', () => { expect(res.authCode).toBe('123-456'); }); - it('should call authGitHub - success oauth flow', async () => { + it('should call performGitHubOAuth using custom oauth app - success oauth flow', async () => { window.gitify.onAuthCallback = jest .fn() .mockImplementation((callback) => { callback('gitify://oauth?code=123-456'); }); - const res = await authUtils.authGitHub({ + const res = await authUtils.performGitHubOAuth({ clientId: 'BYO_CLIENT_ID' as ClientID, clientSecret: 'BYO_CLIENT_SECRET' as ClientSecret, hostname: 'my.git.com' as Hostname, @@ -81,7 +88,9 @@ describe('renderer/utils/auth/utils.ts', () => { expect(openExternalLinkSpy).toHaveBeenCalledTimes(1); expect(openExternalLinkSpy).toHaveBeenCalledWith( - 'https://my.git.com/login/oauth/authorize?client_id=BYO_CLIENT_ID&scope=read%3Auser%2Cnotifications%2Crepo', + expect.stringContaining( + 'https://my.git.com/login/oauth/authorize?allow_signup=false&client_id=BYO_CLIENT_ID&scope=read%3Auser%2Cnotifications%2Crepo', + ), ); expect(window.gitify.onAuthCallback).toHaveBeenCalledTimes(1); @@ -93,7 +102,7 @@ describe('renderer/utils/auth/utils.ts', () => { expect(res.authCode).toBe('123-456'); }); - it('should call authGitHub - failure', async () => { + it('should call performGitHubOAuth - failure', async () => { window.gitify.onAuthCallback = jest .fn() .mockImplementation((callback) => { @@ -102,7 +111,9 @@ describe('renderer/utils/auth/utils.ts', () => { ); }); - await expect(async () => await authUtils.authGitHub()).rejects.toEqual( + await expect( + async () => await authUtils.performGitHubOAuth(), + ).rejects.toEqual( new Error( "Oops! Something went wrong and we couldn't log you in using GitHub. Please try again. Reason: The redirect_uri is missing or invalid. Docs: https://docs.github.com/en/developers/apps/troubleshooting-oauth-errors", ), @@ -110,7 +121,9 @@ describe('renderer/utils/auth/utils.ts', () => { expect(openExternalLinkSpy).toHaveBeenCalledTimes(1); expect(openExternalLinkSpy).toHaveBeenCalledWith( - 'https://github.com/login/oauth/authorize?client_id=FAKE_CLIENT_ID_123&scope=read%3Auser%2Cnotifications%2Crepo', + expect.stringContaining( + 'https://github.com/login/oauth/authorize?allow_signup=false&client_id=FAKE_CLIENT_ID_123&scope=read%3Auser%2Cnotifications%2Crepo', + ), ); expect(window.gitify.onAuthCallback).toHaveBeenCalledTimes(1); @@ -120,30 +133,29 @@ describe('renderer/utils/auth/utils.ts', () => { }); }); - describe('getToken', () => { + describe('exchangeAuthCodeForAccessToken', () => { const authCode = '123-456' as AuthCode; - const apiRequestSpy = jest.spyOn(apiRequests, 'apiRequest'); - it('should get a token', async () => { - apiRequestSpy.mockResolvedValueOnce( - Promise.resolve({ - data: { access_token: 'this-is-a-token' }, - } as AxiosResponse), - ); - - const res = await authUtils.getToken(authCode); - - expect(apiRequests.apiRequest).toHaveBeenCalledWith( - 'https://github.com/login/oauth/access_token', - 'POST', - { - client_id: 'FAKE_CLIENT_ID_123', - client_secret: 'FAKE_CLIENT_SECRET_123', - code: '123-456', + it('should exchange auth code for access token', async () => { + exchangeWebFlowCodeMock.mockResolvedValueOnce({ + authentication: { + token: 'this-is-a-token', }, + } as any); + + const res = await authUtils.exchangeAuthCodeForAccessToken( + authCode, + Constants.DEFAULT_AUTH_OPTIONS, ); - expect(res.token).toBe('this-is-a-token'); - expect(res.hostname).toBe('github.com' as Hostname); + + expect(exchangeWebFlowCodeMock).toHaveBeenCalledWith({ + clientType: 'oauth-app', + clientId: 'FAKE_CLIENT_ID_123', + clientSecret: 'FAKE_CLIENT_SECRET_123', + code: '123-456', + request: expect.any(Function), + }); + expect(res).toBe('this-is-a-token'); }); }); @@ -164,10 +176,7 @@ describe('renderer/utils/auth/utils.ts', () => { beforeEach(() => { fetchAuthenticatedUserDetailsSpy.mockResolvedValue({ data: { - viewer: { - ...mockGitifyUser, - avatarUrl: mockGitifyUser.avatar, - } as UserDetailsResponse, + viewer: mockGitifyUser, }, headers: { 'x-oauth-scopes': Constants.OAUTH_SCOPES.RECOMMENDED.join(', '), @@ -222,10 +231,7 @@ describe('renderer/utils/auth/utils.ts', () => { beforeEach(() => { fetchAuthenticatedUserDetailsSpy.mockResolvedValue({ data: { - viewer: { - ...mockGitifyUser, - avatarUrl: mockGitifyUser.avatar, - } as UserDetailsResponse, + viewer: mockGitifyUser, }, headers: { 'x-github-enterprise-version': '3.0.0', diff --git a/src/renderer/utils/auth/utils.ts b/src/renderer/utils/auth/utils.ts index 61f70c2db..6246a707e 100644 --- a/src/renderer/utils/auth/utils.ts +++ b/src/renderer/utils/auth/utils.ts @@ -1,3 +1,8 @@ +import { + exchangeWebFlowCode, + getWebFlowAuthorizationUrl, +} from '@octokit/oauth-methods'; +import { request } from '@octokit/request'; import { format } from 'date-fns'; import semver from 'semver'; @@ -14,27 +19,29 @@ import type { Link, Token, } from '../../types'; -import type { AuthMethod, AuthResponse, AuthTokenResponse } from './types'; +import type { AuthMethod, AuthResponse, LoginOAuthAppOptions } from './types'; import { fetchAuthenticatedUserDetails } from '../api/client'; -import { apiRequest } from '../api/request'; +import { getGitHubAuthBaseUrl } from '../api/utils'; import { encryptValue, openExternalLink } from '../comms'; import { getPlatformFromHostname } from '../helpers'; import { rendererLogError, rendererLogInfo, rendererLogWarn } from '../logger'; -export function authGitHub( - authOptions = Constants.DEFAULT_AUTH_OPTIONS, +export function performGitHubOAuth( + authOptions: LoginOAuthAppOptions = Constants.DEFAULT_AUTH_OPTIONS, ): Promise { return new Promise((resolve, reject) => { - const authUrl = new URL(`https://${authOptions.hostname}`); - authUrl.pathname = '/login/oauth/authorize'; - authUrl.searchParams.append('client_id', authOptions.clientId); - authUrl.searchParams.append( - 'scope', - Constants.OAUTH_SCOPES.RECOMMENDED.toString(), - ); + const { url } = getWebFlowAuthorizationUrl({ + clientType: 'oauth-app', + clientId: authOptions.clientId, + scopes: Constants.OAUTH_SCOPES.RECOMMENDED, + allowSignup: false, + request: request.defaults({ + baseUrl: `https://${authOptions.hostname}`, + }), + }); - openExternalLink(authUrl.toString() as Link); + openExternalLink(url as Link); const handleCallback = (callbackUrl: string) => { const url = new URL(callbackUrl); @@ -73,23 +80,21 @@ export function authGitHub( }); } -export async function getToken( +export async function exchangeAuthCodeForAccessToken( authCode: AuthCode, - authOptions = Constants.DEFAULT_AUTH_OPTIONS, -): Promise { - const url = - `https://${authOptions.hostname}/login/oauth/access_token` as Link; - const data = { - client_id: authOptions.clientId, - client_secret: authOptions.clientSecret, + authOptions: LoginOAuthAppOptions = Constants.DEFAULT_AUTH_OPTIONS, +): Promise { + const { authentication } = await exchangeWebFlowCode({ + clientType: 'oauth-app', + clientId: authOptions.clientId, + clientSecret: authOptions.clientSecret, code: authCode, - }; + request: request.defaults({ + baseUrl: getGitHubAuthBaseUrl(authOptions.hostname).toString(), + }), + }); - const response = await apiRequest(url, 'POST', data); - return { - hostname: authOptions.hostname, - token: response.data.access_token, - }; + return authentication.token as Token; } export async function addAccount( @@ -152,7 +157,7 @@ export async function refreshAccount(account: Account): Promise { id: user.id, login: user.login, name: user.name, - avatar: user.avatarUrl, + avatar: user.avatar, }; account.version = extractHostVersion( diff --git a/src/renderer/utils/links.ts b/src/renderer/utils/links.ts index a3058a1f5..ba555c677 100644 --- a/src/renderer/utils/links.ts +++ b/src/renderer/utils/links.ts @@ -17,7 +17,7 @@ import { generateGitHubWebUrl } from './helpers'; export function openGitifyReleaseNotes(version: string) { openExternalLink( - `https://github.com/${APPLICATION.REPO_SLUG}/releases/tag/${version}` as Link, + `${APPLICATION.GITHUB_BASE_URL}/${APPLICATION.REPO_SLUG}/releases/tag/${version}` as Link, ); } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 0315b8720..c2c38b170 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -9,6 +9,8 @@ export const APPLICATION = { WEBSITE: 'https://gitify.io', + GITHUB_BASE_URL: 'https://github.com', + REPO_SLUG: 'gitify-app/gitify', DEFAULT_KEYBOARD_SHORTCUT: 'CommandOrControl+Shift+G',