From f51e48996da8ed0668056eba1bdbe2b83c7f243b Mon Sep 17 00:00:00 2001 From: William Hilton Date: Tue, 18 Feb 2020 22:25:57 -0500 Subject: [PATCH] feat(onAuth): remove OAuth2 variant, support raw HTTP headers, endless retry, canceling (#1054) BREAKING CHANGE: The `token` and `oauth2format` properties have been removed from the GitAuth interface, which makes it much simpler and and eliminates a dozen GitErrors handling specific edge cases. A `headers` property has been added to the GitAuth interface. This makes `onAuth` much more powerful because you can inject whatever HTTP headers you want. The `onAuthFailed` callback now lets you return a GitAuth object. This means you can keep retrying to authenticate as many times as you like. A `cancel` property has been added to the GitAuth interface. This means you gracefully give up authenticating and the function will throw an E.UserCancelledError instead of an E.HTTPError. --- __tests__/test-hosting-providers.js | 7 +- __tests__/test-push.js | 258 +++++++++++++++--- docs/onAuth.md | 102 ++++--- docs/onAuthFailure.md | 31 ++- docs/onAuthSuccess.md | 9 +- package-lock.json | 41 ++- package.json | 4 +- src/api/clone.js | 2 +- src/api/fetch.js | 2 +- src/api/getRemoteInfo.js | 6 +- src/api/pull.js | 2 +- src/api/push.js | 4 +- src/commands/checkout.js | 2 +- src/commands/clone.js | 2 +- src/commands/fetch.js | 6 +- src/commands/pull.js | 2 +- src/commands/push.js | 7 +- src/internal-apis.js | 1 - src/managers/GitRemoteHTTP.js | 140 +++++----- src/models/GitError.js | 39 +-- src/typedefs.js | 41 +-- src/utils/calculateBasicAuthHeader.js | 2 +- .../calculateBasicAuthUsernamePasswordPair.js | 37 --- src/utils/extractAuthFromUrl.js | 7 +- 24 files changed, 440 insertions(+), 314 deletions(-) delete mode 100644 src/utils/calculateBasicAuthUsernamePasswordPair.js diff --git a/__tests__/test-hosting-providers.js b/__tests__/test-hosting-providers.js index 5feffd006..fb08181d9 100644 --- a/__tests__/test-hosting-providers.js +++ b/__tests__/test-hosting-providers.js @@ -151,7 +151,8 @@ describe('Hosting Providers', () => { // with "public_repo" access. The only repo it has write access to is // https://github.com/isomorphic-git/test.empty // It is stored reversed to avoid Github's auto-revoking feature. - const token = reverse('e8df25b340c98b7eec57a4976bd9074b93a7dc1c') + const password = reverse('e8df25b340c98b7eec57a4976bd9074b93a7dc1c') + const username = 'isomorphic-git-test-push' it('fetch', async () => { // Setup const { fs, gitdir } = await makeFixture('test-hosting-providers') @@ -163,7 +164,7 @@ describe('Hosting Providers', () => { corsProxy: process.browser ? `http://${localhost}:9999` : undefined, remote: 'github', ref: 'master', - onAuth: () => ({ token }), + onAuth: () => ({ username, password }), }) expect(res).toBeTruthy() expect(res.defaultBranch).toBe('refs/heads/test') @@ -181,7 +182,7 @@ describe('Hosting Providers', () => { remote: 'github', ref: 'master', force: true, - onAuth: () => ({ token }), + onAuth: () => ({ username, password }), }) expect(res).toBeTruthy() expect(res.ok).toBe(true) diff --git a/__tests__/test-push.js b/__tests__/test-push.js index 77d5900ab..68393b62f 100644 --- a/__tests__/test-push.js +++ b/__tests__/test-push.js @@ -269,6 +269,7 @@ describe('push', () => { } expect(error).toContain('401') }) + it('onAuthSuccess', async () => { // Setup const { fs, gitdir } = await makeFixture('test-push') @@ -279,12 +280,9 @@ describe('push', () => { value: `http://${localhost}:8888/test-push-server-auth.git`, }) // Test - let fillCalled = false - let approvedCalled = false - let rejectedCalled = false - let onAuthArgs = null - let onAuthSuccessArgs = null - let onAuthFailureArgs = null + const onAuthArgs = [] + const onAuthSuccessArgs = [] + const onAuthFailureArgs = [] await push({ fs, http, @@ -292,37 +290,39 @@ describe('push', () => { remote: 'auth', ref: 'master', async onAuth(...args) { - fillCalled = true - onAuthArgs = args + onAuthArgs.push(args) return { username: 'testuser', password: 'testpassword', } }, async onAuthSuccess(...args) { - approvedCalled = true - onAuthSuccessArgs = args + onAuthSuccessArgs.push(args) }, async onAuthFailure(...args) { - rejectedCalled = true - onAuthFailureArgs = args + onAuthFailureArgs.push(args) }, }) - expect(fillCalled).toBe(true) - expect(approvedCalled).toBe(true) - expect(rejectedCalled).toBe(false) expect(onAuthArgs).toEqual([ - `http://${localhost}:8888/test-push-server-auth.git`, + [ + `http://${localhost}:8888/test-push-server-auth.git`, + { + headers: {}, + }, + ], ]) expect(onAuthSuccessArgs).toEqual([ - `http://${localhost}:8888/test-push-server-auth.git`, - { - username: 'testuser', - password: 'testpassword', - }, + [ + `http://${localhost}:8888/test-push-server-auth.git`, + { + username: 'testuser', + password: 'testpassword', + }, + ], ]) - expect(onAuthFailureArgs).toBeNull() + expect(onAuthFailureArgs).toEqual([]) }) + it('onAuthFailure', async () => { // Setup const { fs, gitdir } = await makeFixture('test-push') @@ -333,14 +333,10 @@ describe('push', () => { value: `http://${localhost}:8888/test-push-server-auth.git`, }) // Test - let fillCalled = false - let approvedCalled = false - let rejectedCalled = false let err - let onAuthArgs = null - let onAuthSuccessArgs = null - let onAuthFailureArgs = null - + const onAuthArgs = [] + const onAuthSuccessArgs = [] + const onAuthFailureArgs = [] try { await push({ fs, @@ -349,20 +345,31 @@ describe('push', () => { remote: 'auth', ref: 'master', async onAuth(...args) { - fillCalled = true - onAuthArgs = args + onAuthArgs.push(args) return { username: 'testuser', password: 'NoT_rIgHt', } }, async onAuthSuccess(...args) { - approvedCalled = true - onAuthSuccessArgs = args + onAuthSuccessArgs.push(args) }, async onAuthFailure(...args) { - rejectedCalled = true - onAuthFailureArgs = args + onAuthFailureArgs.push(args) + switch (onAuthFailureArgs.length) { + case 1: + return { + username: 'testuser', + password: 'St1ll_NoT_rIgHt', + } + case 2: + return { + headers: { + Authorization: 'Bearer Big Bear', + 'X-Authorization': 'supersecret', + }, + } + } }, }) } catch (e) { @@ -370,19 +377,182 @@ describe('push', () => { } expect(err).toBeDefined() expect(err.code).toBe(E.HTTPError) - expect(fillCalled).toBe(true) - expect(approvedCalled).toBe(false) - expect(rejectedCalled).toBe(true) expect(onAuthArgs).toEqual([ - `http://${localhost}:8888/test-push-server-auth.git`, + [ + `http://${localhost}:8888/test-push-server-auth.git`, + { + headers: {}, + }, + ], ]) - expect(onAuthSuccessArgs).toBeNull() + expect(onAuthSuccessArgs).toEqual([]) expect(onAuthFailureArgs).toEqual([ - `http://${localhost}:8888/test-push-server-auth.git`, - { - username: 'testuser', - password: 'NoT_rIgHt', + [ + `http://${localhost}:8888/test-push-server-auth.git`, + { + headers: { + Authorization: 'Basic dGVzdHVzZXI6Tm9UX3JJZ0h0', + }, + username: 'testuser', + password: 'NoT_rIgHt', + }, + ], + [ + `http://${localhost}:8888/test-push-server-auth.git`, + { + headers: { + Authorization: 'Basic dGVzdHVzZXI6U3QxbGxfTm9UX3JJZ0h0', + }, + username: 'testuser', + password: 'St1ll_NoT_rIgHt', + }, + ], + [ + `http://${localhost}:8888/test-push-server-auth.git`, + { + headers: { + Authorization: 'Bearer Big Bear', + 'X-Authorization': 'supersecret', + }, + }, + ], + ]) + }) + + it('onAuthFailure then onAuthSuccess', async () => { + // Setup + const { fs, gitdir } = await makeFixture('test-push') + await setConfig({ + fs, + gitdir, + path: 'remote.auth.url', + value: `http://${localhost}:8888/test-push-server-auth.git`, + }) + // Test + const onAuthArgs = [] + const onAuthSuccessArgs = [] + const onAuthFailureArgs = [] + await push({ + fs, + http, + gitdir, + remote: 'auth', + ref: 'master', + async onAuth(...args) { + onAuthArgs.push(args) + return { + username: 'testuser', + password: 'NoT_rIgHt', + } + }, + async onAuthSuccess(...args) { + onAuthSuccessArgs.push(args) }, + async onAuthFailure(...args) { + onAuthFailureArgs.push(args) + switch (onAuthFailureArgs.length) { + case 1: + return { + username: 'testuser', + password: 'St1ll_NoT_rIgHt', + } + case 2: + return { + username: 'testuser', + password: 'testpassword', + } + } + }, + }) + expect(onAuthArgs).toEqual([ + [ + `http://${localhost}:8888/test-push-server-auth.git`, + { + headers: {}, + }, + ], + ]) + expect(onAuthSuccessArgs).toEqual([ + [ + `http://${localhost}:8888/test-push-server-auth.git`, + { + username: 'testuser', + password: 'testpassword', + }, + ], + ]) + expect(onAuthFailureArgs).toEqual([ + [ + `http://${localhost}:8888/test-push-server-auth.git`, + { + headers: { + Authorization: 'Basic dGVzdHVzZXI6Tm9UX3JJZ0h0', + }, + username: 'testuser', + password: 'NoT_rIgHt', + }, + ], + [ + `http://${localhost}:8888/test-push-server-auth.git`, + { + headers: { + Authorization: 'Basic dGVzdHVzZXI6U3QxbGxfTm9UX3JJZ0h0', + }, + username: 'testuser', + password: 'St1ll_NoT_rIgHt', + }, + ], + ]) + }) + + it('onAuth + cancel', async () => { + // Setup + const { fs, gitdir } = await makeFixture('test-push') + await setConfig({ + fs, + gitdir, + path: 'remote.auth.url', + value: `http://${localhost}:8888/test-push-server-auth.git`, + }) + // Test + let err + const onAuthArgs = [] + const onAuthSuccessArgs = [] + const onAuthFailureArgs = [] + try { + await push({ + fs, + http, + gitdir, + remote: 'auth', + ref: 'master', + async onAuth(...args) { + onAuthArgs.push(args) + return { + cancel: true, + } + }, + async onAuthSuccess(...args) { + onAuthSuccessArgs.push(args) + }, + async onAuthFailure(...args) { + onAuthFailureArgs.push(args) + }, + }) + } catch (e) { + err = e + } + expect(err).toBeDefined() + expect(err.code).toBe(E.UserCancelledError) + expect(onAuthArgs).toEqual([ + [ + `http://${localhost}:8888/test-push-server-auth.git`, + { + headers: {}, + }, + ], ]) + expect(onAuthSuccessArgs).toEqual([]) + expect(onAuthFailureArgs).toEqual([]) }) }) diff --git a/docs/onAuth.md b/docs/onAuth.md index 8db3835a7..298238a75 100644 --- a/docs/onAuth.md +++ b/docs/onAuth.md @@ -10,24 +10,47 @@ Authentication is normally required for pushing to a git repository. It may also be required to clone or fetch from a private repository. Git does all its authentication using HTTPS Basic Authentication. -An `onAuth` function is called with a `url` and should return a credential object: +An `onAuth` function is called with a `url` and an `auth` object and should return a GitAuth object: ```ts /** * @callback AuthCallback * @param {string} url - * @returns {GitAuth | Promise} + * @param {GitAuth} auth - Might have some values if the URL itself originally contained a username or password. + * @returns {GitAuth | void | Promise} */ /** * @typedef {Object} GitAuth * @property {string} [username] * @property {string} [password] - * @property {string} [token] - * @property {string} [oauth2format] + * @property {Object} [headers] + * @property {boolean} cancel - Tells git to throw a `UserCancelledError` (instead of an `HTTPError`). */ ``` +## Example + +```js +await git.clone({ + ..., + onAuth: url => { + let auth = lookupSavedPassword(url) + if (auth) return auth + + if (confirm('This repo is password protected. Ready to enter a username & password?')) { + auth = { + username: prompt('Enter username'), + password: prompt('Enter password'), + } + return auth + } else { + return { cancel: true } + } + } +}) +``` + ## Option 1: Username & Password Return an object with `{ username, password }`. @@ -36,57 +59,62 @@ However, there are some things to watch out for. If you have two-factor authentication (2FA) enabled on your account, you probably cannot push or pull using your regular username and password. -Instead, you may have to use option 2... - -## Option 2: Personal Access Token +Instead, you may have to use a Personal Access Token. (Bitbucket calls them "App Passwords".) -(Note: Bitbucket calls them "App Passwords".) +### Personal Access Tokens - [Instructions for GitHub](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/) - [Instructions for Bitbucket](https://confluence.atlassian.com/bitbucket/app-passwords-828781300.html) - [Instructions for GitLab](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html) -In this situation, you want to return an object with `{ username, token }`. -(Note the username is optional for GitHub.) +In this situation, you want to return an object with `{ username, password }` where `password` is the Personal Access Token. +Note that GitHub actually lets you specify the token as the `username` and leave the password blank, which is convenient but none of the other hosting providers do this that I'm aware of. -## Option 3: OAuth2 Token +### OAuth2 Tokens If you are writing a third-party app that interacts with GitHub/GitLab/Bitbucket, you may be obtaining OAuth2 tokens from the service via a feature like "Login with GitHub". Depending on the OAuth2 token's grants, you can use those tokens for pushing and pulling from git repos as well. -Unfortunately, all the major git hosting companies have chosen different conventions for converting -OAuth2 tokens into Basic Authentication headers! Therefore it is necessary to specify which company's -convention you are interacting with via an `oauth2format` parameter. +In this situation, you want to return an object with `{ username, password }` where `username` and `password` depend on where the repo is hosted. -Currently, the following values are understood: +Unfortunately, all the major git hosting companies have chosen different conventions for converting OAuth2 tokens into Basic Authentication headers! -| oauth2format | Basic Auth username | Basic Auth password | -| ------------ | ------------------- | ------------------- | -| 'github' | `token` | 'x-oauth-basic' | -| 'bitbucket' | 'x-token-auth' | `token` | -| 'gitlab' | 'oauth2' | `token` | +| | `username` | `password` | +| ---------- | -------------- | ---------------- | +| GitHub | `token` | 'x-oauth-basic' | +| GitHub App | `token` | 'x-access-token' | +| BitBucket | 'x-token-auth' | `token` | +| GitLab | 'oauth2' | `token` | -I will gladly accept pull requests to add support for more companies' conventions. -Here is what using OAuth2 authentication looks like. +I will gladly accept pull requests to document more companies' conventions. -In this situation, you want to return an object with `{ token, oauth2format }`. -Note when using OAuth2 tokens, you do NOT include a `username` or `password`. +Since it is a rarely used feature, I'm not including the conversion table directly in isomorphic-git anymore. +But if there's interest in maintaining this table as some kind of function, I'm considering starting an `@isomorphic-git/quirksmode` package to handle these kinds of hosting-provider specific oddities. -## Example +## Option 2: Headers + +This is the super flexible option. Just return the HTTP headers you want to add as an object with `{ headers }`. +If you can provide `{ username, password, headers }` if you want. (Although if `headers` includes an `Authentication` property that overwrites what you would normally get from `username`/`password`.) + +To re-implement the default Basic Auth behavior, do something like this: ```js -git.clone({ - ..., - onAuth: url => { - let auth = lookupSavedPassword(url) - if (!auth.username) { - auth.username = prompt('Enter username') - } - if (!auth.password) { - auth.password = prompt('Enter password') - } - return auth +let auth = { + headers: { + Authentication: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` } -}) +} +``` + +If you are using a custom proxy server that has its own authentication in addition to the destination authentication, you could inject it like so: + +```js +let auth = { + username, + password, + headers: { + 'X-Authentication': `Bearer ${token}` + } +} ``` diff --git a/docs/onAuthFailure.md b/docs/onAuthFailure.md index 35fb4e940..8be887ea6 100644 --- a/docs/onAuthFailure.md +++ b/docs/onAuthFailure.md @@ -3,36 +3,47 @@ title: onAuthFailure sidebar_label: onAuthFailure --- -The `onAuthFailure` callback is called when credentials fail. This is helpful to know if say, you have saved password and want to offer to delete ones that fail. +The `onAuthFailure` callback is called when credentials fail. +This is helpful to know if you were using a saved password in the `onAuth` callback, then you may want to offer the user the option to delete the currently saved password. +It also gives you an opportunity to retry the request with new credentials. -An `onAuthFailure` function is called with a `url` and an `auth` object. +As long as your `onAuthFailure` function returns credentials, it will keep trying. +This is the main reason we don't re-use the `onAuth` callback for this purpose. If we did, then a naive `onAuth` callback that simply returned saved credentials might loop indefinitely. + +An `onAuthFailure` function is called with a `url` and an `auth` object and can return a GitAuth object: ```js /** * @callback AuthFailureCallback * @param {string} url - * @param {GitAuth} auth - * @returns {void | Promise} + * @param {GitAuth} auth The credentials that failed + * @returns {GitAuth | void | Promise} */ /** * @typedef {Object} GitAuth * @property {string} [username] * @property {string} [password] - * @property {string} [token] - * @property {string} [oauth2format] + * @property {Object} [headers] + * @property {boolean} cancel - Tells git to throw a `UserCancelledError` (instead of an `HTTPError`). */ ``` ## Example ```js -const git = require('isomorphic-git') -git.clone({ +await git.clone({ ..., onAuthFailure: (url, auth) => { - if (confirm('Access was denied. Delete saved password?')) { - forgetSavedPassword(url) + forgetSavedPassword(url) + if (confirm('Access was denied. Try again?')) { + auth = { + username: prompt('Enter username'), + password: prompt('Enter password'), + } + return auth + } else { + return { cancel: true } } } }) diff --git a/docs/onAuthSuccess.md b/docs/onAuthSuccess.md index d057b25ff..b8b296c76 100644 --- a/docs/onAuthSuccess.md +++ b/docs/onAuthSuccess.md @@ -3,7 +3,7 @@ title: onAuthSuccess sidebar_label: onAuthSuccess --- -The `onAuthSuccess` callback is called when credentials fail. This is helpful to know if say, you want to offer to save a password but only after it succeeds. +The `onAuthSuccess` callback is called when credentials work. This is helpful to know if you want to offer to save the credentials, but only if they are valid. An `onAuthSuccess` function is called with a `url` and an `auth` object. @@ -19,16 +19,15 @@ An `onAuthSuccess` function is called with a `url` and an `auth` object. * @typedef {Object} GitAuth * @property {string} [username] * @property {string} [password] - * @property {string} [token] - * @property {string} [oauth2format] + * @property {Object} [headers] + * @property {boolean} cancel - Tells git to throw a `UserCancelledError` (instead of an `HTTPError`). */ ``` ## Example ```js -const git = require('isomorphic-git') -git.clone({ +await git.clone({ ..., onAuthSuccess: (url, auth) => { if (confirm('Remember password?')) { diff --git a/package-lock.json b/package-lock.json index f91b0cd66..6ee84f4cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1017,9 +1017,9 @@ } }, "@isomorphic-git/cors-proxy": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@isomorphic-git/cors-proxy/-/cors-proxy-2.5.0.tgz", - "integrity": "sha512-XVMi0XIKkIb1yH02FgdOILJdKCgZff/QSOvmlmrNAl8Nm4DYLhDiNhLIkR4vVSNiAT0Z1OHFsA3b7EG7G2ZUNw==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@isomorphic-git/cors-proxy/-/cors-proxy-2.6.1.tgz", + "integrity": "sha512-zhqGjDvL8sUL3g/t0Pv7jLNpB1iGlSfECFV1UZVqx7CQMrorYS0gVBMtsJSEkP/EflsZTEG9jX65cmWsoi9fWQ==", "dev": true, "requires": { "cross-env": "^5.2.0", @@ -1027,7 +1027,7 @@ "micro": "^9.3.3", "micro-cors": "0.1.1", "minimisted": "^2.0.0", - "node-fetch": "^2.3.0", + "node-fetch": "^2.6.0", "tree-kill": "^1.2.1" }, "dependencies": { @@ -3062,18 +3062,18 @@ } }, "apache-crypt": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/apache-crypt/-/apache-crypt-1.2.1.tgz", - "integrity": "sha1-1vxyqm0n2ZyVqU/RiNcx7v/6Zjw=", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/apache-crypt/-/apache-crypt-1.2.4.tgz", + "integrity": "sha512-Icze5ny5W5uv3xgMgl8U+iGmRCC0iIDrb2PVPuRBtL3Zy1Y5TMewXP1Vtc4r5X9eNNBEk7KYPu0Qby9m/PmcHg==", "dev": true, "requires": { - "unix-crypt-td-js": "^1.0.0" + "unix-crypt-td-js": "^1.1.4" } }, "apache-md5": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/apache-md5/-/apache-md5-1.1.2.tgz", - "integrity": "sha1-7klza2ObTxCLbp5ibG2pkwa0FpI=", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/apache-md5/-/apache-md5-1.1.5.tgz", + "integrity": "sha512-sbLEIMQrkV7RkIruqTPXxeCMkAAycv4yzTkBzRgOR1BrR5UB7qZtupqxkersTJSf0HZ3sbaNRrNV80TnnM7cUw==", "dev": true }, "aproba": { @@ -6225,12 +6225,11 @@ } }, "dir-glob": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.0.0.tgz", - "integrity": "sha512-37qirFDz8cA5fimp9feo43fSuRo2gHwaIn6dXL8Ber1dGwUosDrGZeCCXq57WnIqE4aQ+u3eQZzsk1yOzhdwag==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.2.2.tgz", + "integrity": "sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==", "dev": true, "requires": { - "arrify": "^1.0.1", "path-type": "^3.0.0" } }, @@ -9315,9 +9314,9 @@ } }, "git-http-mock-server": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/git-http-mock-server/-/git-http-mock-server-1.2.0.tgz", - "integrity": "sha512-5jfG1UtyTtdmaLcRwAqmBHngKvD9/xd8yXGmIN8sBR9hyKGnbF/bcb6xfWj6r9igp55kfGxxyeGy1I3q+6I1+Q==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/git-http-mock-server/-/git-http-mock-server-1.2.1.tgz", + "integrity": "sha512-rju1CppSFC5F8VMD/F/fRAg2RK4ng94cu1vMUNOm7FIOVKbY+bTqWHmSgYIVl/DEcAOfjybiVLDI9SPzMcZcSg==", "dev": true, "requires": { "basic-auth": "^2.0.0", @@ -23094,9 +23093,9 @@ "dev": true }, "unix-crypt-td-js": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unix-crypt-td-js/-/unix-crypt-td-js-1.0.0.tgz", - "integrity": "sha1-HAgkFQSBvHoB1J6Y8exmjYJBLzs=", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/unix-crypt-td-js/-/unix-crypt-td-js-1.1.4.tgz", + "integrity": "sha512-8rMeVYWSIyccIJscb9NdCfZKSRBKYTeVnwmiRYT2ulE3qd1RaDQ0xQDP+rI3ccIWbhu/zuo5cgN8z73belNZgw==", "dev": true }, "unpipe": { diff --git a/package.json b/package.json index 675f0901e..e1edbea6c 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "@babel/plugin-transform-async-to-generator": "7.5.0", "@babel/preset-env": "7.5.5", "@babel/runtime": "7.5.5", - "@isomorphic-git/cors-proxy": "2.5.0", + "@isomorphic-git/cors-proxy": "2.6.1", "@isomorphic-git/lightning-fs": "^3.3.0", "@isomorphic-git/pgp-plugin": "0.0.7", "@semantic-release/exec": "3.3.6", @@ -107,7 +107,7 @@ "eslint-plugin-prettier": "^3.1.2", "eslint-plugin-promise": "^4.2.1", "eslint-plugin-standard": "^4.0.1", - "git-http-mock-server": "1.2.0", + "git-http-mock-server": "1.2.1", "github-comment": "1.0.1", "inquirer": "^7.0.0", "jasmine-core": "3.4.0", diff --git a/src/api/clone.js b/src/api/clone.js index 67453fce5..05c3b0dec 100644 --- a/src/api/clone.js +++ b/src/api/clone.js @@ -15,8 +15,8 @@ import { join } from '../utils/join.js' * @param {ProgressCallback} [args.onProgress] - optional progress event callback * @param {MessageCallback} [args.onMessage] - optional message event callback * @param {AuthCallback} [args.onAuth] - optional auth fill callback - * @param {AuthSuccessCallback} [args.onAuthSuccess] - optional auth approved callback * @param {AuthFailureCallback} [args.onAuthFailure] - optional auth rejected callback + * @param {AuthSuccessCallback} [args.onAuthSuccess] - optional auth approved callback * @param {string} args.dir - The [working tree](dir-vs-gitdir.md) directory path * @param {string} [args.gitdir=join(dir,'.git')] - [required] The [git directory](dir-vs-gitdir.md) path * @param {string} args.url - The URL of the remote repository diff --git a/src/api/fetch.js b/src/api/fetch.js index ec1b47c1c..899e6c57b 100644 --- a/src/api/fetch.js +++ b/src/api/fetch.js @@ -27,8 +27,8 @@ import { join } from '../utils/join.js' * @param {ProgressCallback} [args.onProgress] - optional progress event callback * @param {MessageCallback} [args.onMessage] - optional message event callback * @param {AuthCallback} [args.onAuth] - optional auth fill callback - * @param {AuthSuccessCallback} [args.onAuthSuccess] - optional auth approved callback * @param {AuthFailureCallback} [args.onAuthFailure] - optional auth rejected callback + * @param {AuthSuccessCallback} [args.onAuthSuccess] - optional auth approved callback * @param {string} [args.dir] - The [working tree](dir-vs-gitdir.md) directory path * @param {string} [args.gitdir=join(dir,'.git')] - [required] The [git directory](dir-vs-gitdir.md) path * @param {string} [args.url] - The URL of the remote repository. The default is the value set in the git config for that remote. diff --git a/src/api/getRemoteInfo.js b/src/api/getRemoteInfo.js index bc9bb31d1..484ab6d9a 100644 --- a/src/api/getRemoteInfo.js +++ b/src/api/getRemoteInfo.js @@ -24,8 +24,8 @@ import { assertParameter } from '../utils/assertParameter.js' * @param {object} args * @param {HttpClient} args.http - an HTTP client * @param {AuthCallback} [args.onAuth] - optional auth fill callback - * @param {AuthSuccessCallback} [args.onAuthSuccess] - optional auth approved callback * @param {AuthFailureCallback} [args.onAuthFailure] - optional auth rejected callback + * @param {AuthSuccessCallback} [args.onAuthSuccess] - optional auth approved callback * @param {string} args.url - The URL of the remote repository. Will be gotten from gitconfig if absent. * @param {string} [args.corsProxy] - Optional [CORS proxy](https://www.npmjs.com/%40isomorphic-git/cors-proxy). Overrides value in repo config. * @param {boolean} [args.forPush = false] - By default, the command queries the 'fetch' capabilities. If true, it will ask for the 'push' capabilities. @@ -56,7 +56,6 @@ export async function getRemoteInfo({ try { assertParameter('url', url) - let auth = {} const remote = await GitRemoteHTTP.discover({ http, onAuth, @@ -65,10 +64,9 @@ export async function getRemoteInfo({ corsProxy, service: forPush ? 'git-receive-pack' : 'git-upload-pack', url, - auth, headers, }) - auth = remote.auth // hack to get new credentials from CredentialManager API + // Note: remote.capabilities, remote.refs, and remote.symrefs are Set and Map objects, // but one of the objectives of the public API is to always return JSON-compatible objects // so we must JSONify them. diff --git a/src/api/pull.js b/src/api/pull.js index 356c77499..bbd996761 100644 --- a/src/api/pull.js +++ b/src/api/pull.js @@ -18,8 +18,8 @@ import { normalizeCommitterObject } from '../utils/normalizeCommitterObject.js' * @param {ProgressCallback} [args.onProgress] - optional progress event callback * @param {MessageCallback} [args.onMessage] - optional message event callback * @param {AuthCallback} [args.onAuth] - optional auth fill callback - * @param {AuthSuccessCallback} [args.onAuthSuccess] - optional auth approved callback * @param {AuthFailureCallback} [args.onAuthFailure] - optional auth rejected callback + * @param {AuthSuccessCallback} [args.onAuthSuccess] - optional auth approved callback * @param {string} args.dir] - The [working tree](dir-vs-gitdir.md) directory path * @param {string} [args.gitdir=join(dir,'.git')] - [required] The [git directory](dir-vs-gitdir.md) path * @param {string} [args.ref] - Which branch to fetch. By default this is the currently checked out branch. diff --git a/src/api/push.js b/src/api/push.js index 107607935..509de1f89 100644 --- a/src/api/push.js +++ b/src/api/push.js @@ -23,8 +23,8 @@ import { join } from '../utils/join.js' * @param {ProgressCallback} [args.onProgress] - optional progress event callback * @param {MessageCallback} [args.onMessage] - optional message event callback * @param {AuthCallback} [args.onAuth] - optional auth fill callback - * @param {AuthSuccessCallback} [args.onAuthSuccess] - optional auth approved callback * @param {AuthFailureCallback} [args.onAuthFailure] - optional auth rejected callback + * @param {AuthSuccessCallback} [args.onAuthSuccess] - optional auth approved callback * @param {string} [args.dir] - The [working tree](dir-vs-gitdir.md) directory path * @param {string} [args.gitdir=join(dir,'.git')] - [required] The [git directory](dir-vs-gitdir.md) path * @param {string} [args.ref] - Which branch to push. By default this is the currently checked out branch. @@ -47,7 +47,7 @@ import { join } from '../utils/join.js' * dir: '/tutorial', * remote: 'origin', * ref: 'master', - * onAuth: () => ({ token: process.env.GITHUB_TOKEN }), + * onAuth: () => ({ username: process.env.GITHUB_TOKEN }), * }) * console.log(pushResult) * diff --git a/src/commands/checkout.js b/src/commands/checkout.js index 4093543b3..020b892ad 100644 --- a/src/commands/checkout.js +++ b/src/commands/checkout.js @@ -308,7 +308,7 @@ async function analyze({ fs, onProgress, dir, gitdir, ref, force, filepaths }) { await onProgress({ phase: 'Analyzing workdir', loaded: ++count }) } - // This is a kind of silly pattern but it worked so well for me in calculateBasicAuthUsernamePasswordPair.js + // This is a kind of silly pattern but it worked so well for me in the past // and it makes intuitively demonstrating exhaustiveness so *easy*. // This checks for the presense and/or absense of each of the 3 entries, // converts that to a 3-bit binary representation, and then handles diff --git a/src/commands/clone.js b/src/commands/clone.js index f1ada2b15..ee02b5853 100644 --- a/src/commands/clone.js +++ b/src/commands/clone.js @@ -15,8 +15,8 @@ import { addRemote } from './addRemote.js' * @param {ProgressCallback} [args.onProgress] * @param {MessageCallback} [args.onMessage] * @param {AuthCallback} [args.onAuth] - * @param {AuthSuccessCallback} [args.onAuthSuccess] * @param {AuthFailureCallback} [args.onAuthFailure] + * @param {AuthSuccessCallback} [args.onAuthSuccess] * @param {string} [args.dir] * @param {string} args.gitdir * @param {string} args.url diff --git a/src/commands/fetch.js b/src/commands/fetch.js index a104b923f..ddab80c29 100644 --- a/src/commands/fetch.js +++ b/src/commands/fetch.js @@ -40,8 +40,8 @@ import { writeUploadPackRequest } from '../wire/writeUploadPackRequest.js' * @param {ProgressCallback} [args.onProgress] * @param {MessageCallback} [args.onMessage] * @param {AuthCallback} [args.onAuth] - * @param {AuthSuccessCallback} [args.onAuthSuccess] * @param {AuthFailureCallback} [args.onAuthFailure] + * @param {AuthSuccessCallback} [args.onAuthSuccess] * @param {string} args.gitdir * @param {string|void} [args.url] * @param {string} [args.corsProxy] @@ -108,7 +108,6 @@ export async function fetch({ corsProxy = await config.get('http.corsProxy') } - let auth = {} const GitRemoteHTTP = GitRemoteManager.getRemoteHelperFor({ url }) const remoteHTTP = await GitRemoteHTTP.discover({ http, @@ -118,10 +117,9 @@ export async function fetch({ corsProxy, service: 'git-upload-pack', url, - auth, headers, }) - auth = remoteHTTP.auth // hack to get new credentials from CredentialManager API + const auth = remoteHTTP.auth // hack to get new credentials from CredentialManager API const remoteRefs = remoteHTTP.refs // For the special case of an empty repository with no refs, return null. if (remoteRefs.size === 0) { diff --git a/src/commands/pull.js b/src/commands/pull.js index e79c08d55..bc39015aa 100644 --- a/src/commands/pull.js +++ b/src/commands/pull.js @@ -14,8 +14,8 @@ import { E, GitError } from '../models/GitError.js' * @param {ProgressCallback} [args.onProgress] - optional progress event callback * @param {MessageCallback} [args.onMessage] - optional message event callback * @param {AuthCallback} [args.onAuth] - optional auth fill callback - * @param {AuthSuccessCallback} [args.onAuthSuccess] - optional auth approved callback * @param {AuthFailureCallback} [args.onAuthFailure] - optional auth rejected callback + * @param {AuthSuccessCallback} [args.onAuthSuccess] - optional auth approved callback * @param {string} args.dir * @param {string} args.gitdir * @param {string} args.ref - Which branch to fetch. By default this is the currently checked out branch. diff --git a/src/commands/push.js b/src/commands/push.js index 40503d0f2..ae759180e 100644 --- a/src/commands/push.js +++ b/src/commands/push.js @@ -26,8 +26,8 @@ import { writeReceivePackRequest } from '../wire/writeReceivePackRequest.js' * @param {ProgressCallback} [args.onProgress] * @param {MessageCallback} [args.onMessage] * @param {AuthCallback} [args.onAuth] - * @param {AuthSuccessCallback} [args.onAuthSuccess] * @param {AuthFailureCallback} [args.onAuthFailure] + * @param {AuthSuccessCallback} [args.onAuthSuccess] * @param {string} args.gitdir * @param {string} [args.ref] * @param {string} [args.remoteRef] @@ -98,7 +98,7 @@ export async function push({ const oid = _delete ? '0000000000000000000000000000000000000000' : await GitRefManager.resolve({ fs, gitdir, ref: fullRef }) - let auth = {} + const GitRemoteHTTP = GitRemoteManager.getRemoteHelperFor({ url }) const httpRemote = await GitRemoteHTTP.discover({ http, @@ -108,10 +108,9 @@ export async function push({ corsProxy, service: 'git-receive-pack', url, - auth, headers, }) - auth = httpRemote.auth // hack to get new credentials from CredentialManager API + const auth = httpRemote.auth // hack to get new credentials from CredentialManager API let fullRemoteRef if (!remoteRef) { fullRemoteRef = fullRef diff --git a/src/internal-apis.js b/src/internal-apis.js index 49e0457c6..8eb95e0b6 100644 --- a/src/internal-apis.js +++ b/src/internal-apis.js @@ -29,7 +29,6 @@ export * from './storage/readObject' export * from './storage/writeObject' export * from './utils/calculateBasicAuthHeader' -export * from './utils/calculateBasicAuthUsernamePasswordPair' export * from './utils/collect' export * from './utils/comparePath' export * from './utils/flatFileListToDirectoryStructure' diff --git a/src/managers/GitRemoteHTTP.js b/src/managers/GitRemoteHTTP.js index de6c5040f..4ebc70af9 100644 --- a/src/managers/GitRemoteHTTP.js +++ b/src/managers/GitRemoteHTTP.js @@ -2,7 +2,6 @@ import '../typedefs.js' import { E, GitError } from '../models/GitError.js' import { calculateBasicAuthHeader } from '../utils/calculateBasicAuthHeader.js' -import { calculateBasicAuthUsernamePasswordPair } from '../utils/calculateBasicAuthUsernamePasswordPair.js' import { collect } from '../utils/collect.js' import { extractAuthFromUrl } from '../utils/extractAuthFromUrl.js' import { parseRefsAdResponse } from '../wire/parseRefsAdResponse.js' @@ -15,6 +14,17 @@ const corsProxify = (corsProxy, url) => ? `${corsProxy}${url}` : `${corsProxy}/${url.replace(/^https?:\/\//, '')}` +const updateHeaders = (headers, auth) => { + // Update the basic auth header + if (auth.username || auth.password) { + headers.Authorization = calculateBasicAuthHeader(auth) + } + // but any manually provided headers take precedence + if (auth.headers) { + Object.assign(headers, auth.headers) + } +} + export class GitRemoteHTTP { static async capabilities() { return ['discover', 'connect'] @@ -25,12 +35,11 @@ export class GitRemoteHTTP { * @param {HttpClient} args.http * @param {ProgressCallback} [args.onProgress] * @param {AuthCallback} [args.onAuth] - * @param {AuthSuccessCallback} [args.onAuthSuccess] * @param {AuthFailureCallback} [args.onAuthFailure] + * @param {AuthSuccessCallback} [args.onAuthSuccess] * @param {string} [args.corsProxy] * @param {string} args.service * @param {string} args.url - * @param {GitAuth} [args.auth] * @param {Object} [args.headers] */ static async discover({ @@ -41,67 +50,59 @@ export class GitRemoteHTTP { onAuthFailure, corsProxy, service, - url, - auth, + url: _origUrl, headers, }) { - const _origUrl = url - const urlAuth = extractAuthFromUrl(url) - if (urlAuth) { - url = urlAuth.url - // To try to be backwards compatible with simple-get's behavior, which uses Node's http.request - // setting an Authorization header will override what is in the URL. - // Ergo manually specified auth parameters will override those in the URL. - // However, since the oauth2 option is incompatible with usernames and passwords, rather than throw an - // E.MixUsernamePasswordOauth2formatTokenError error, we'll avoid that situation by ignoring the username - // and/or password in the url. - if (!auth.oauth2format) { - auth.username = auth.username || urlAuth.username - auth.password = auth.password || urlAuth.password - } - } - if (corsProxy) { - url = corsProxify(corsProxy, url) - } - // headers['Accept'] = `application/x-${service}-advertisement` - // If the username came from the URL, we want to allow the password to be missing. - // This is because Github allows using the token as the username with an empty password - // so that is a style of git clone URL we might encounter and we don't want to throw a "Missing password or token" error. - // Also, we don't want to prematurely throw an error before the credentialManager plugin has - // had an opportunity to provide the password. - const _auth = calculateBasicAuthUsernamePasswordPair(auth, !!urlAuth) - if (_auth) { - headers.Authorization = calculateBasicAuthHeader(_auth) + let { url, auth } = extractAuthFromUrl(_origUrl) + const proxifiedURL = corsProxy ? corsProxify(corsProxy, url) : url + if (auth.username || auth.password) { + headers.Authorization = calculateBasicAuthHeader(auth) } - let res = await http({ - onProgress, - method: 'GET', - url: `${url}/info/refs?service=${service}`, - headers, - }) - // 401 is the "correct" response. 203 is Non-Authoritative Information and comes from Azure DevOps, which - // apparently doesn't realize this is a git request and is returning the HTML for the "Azure DevOps Services | Sign In" page. - if ((res.statusCode === 401 || res.statusCode === 203) && onAuth) { - // Acquire credentials and try again - // TODO: read `useHttpPath` value from git config and pass as 2nd argument? - auth = await onAuth(_origUrl) - const _auth = calculateBasicAuthUsernamePasswordPair(auth) - if (_auth) { - headers.Authorization = calculateBasicAuthHeader(_auth) - } + + let res + let tryAgain + let providedAuthBefore = false + do { res = await http({ onProgress, method: 'GET', - url: `${url}/info/refs?service=${service}`, + url: `${proxifiedURL}/info/refs?service=${service}`, headers, }) - // Tell credential manager if the credentials were no good - if (res.statusCode === 401 && onAuthFailure) { - await onAuthFailure(_origUrl, auth) - } else if (res.statusCode === 200 && onAuthSuccess) { - await onAuthSuccess(_origUrl, auth) + + // the default loop behavior + tryAgain = false + + // 401 is the "correct" response for access denied. 203 is Non-Authoritative Information and comes from Azure DevOps, which + // apparently doesn't realize this is a git request and is returning the HTML for the "Azure DevOps Services | Sign In" page. + if (res.statusCode === 401 || res.statusCode === 203) { + // On subsequent 401s, call `onAuthFailure` instead of `onAuth`. + // This is so that naive `onAuth` callbacks that return a fixed value don't create an infinite loop of retrying. + const getAuth = providedAuthBefore ? onAuthFailure : onAuth + if (getAuth) { + // Acquire credentials and try again + // TODO: read `useHttpPath` value from git config and pass along? + auth = await getAuth(url, { + ...auth, + headers: { ...headers }, + }) + if (auth && auth.cancel) { + throw new GitError(E.UserCancelledError) + } else if (auth) { + updateHeaders(headers, auth) + providedAuthBefore = true + tryAgain = true + } + } + } else if ( + res.statusCode === 200 && + providedAuthBefore && + onAuthSuccess + ) { + await onAuthSuccess(url, auth) } - } + } while (tryAgain) + if (res.statusCode !== 200) { throw new GitError(E.HTTPError, { statusCode: res.statusCode, @@ -124,6 +125,7 @@ export class GitRemoteHTTP { const preview = response.length < 256 ? response : response.slice(0, 256) + '...' // For backwards compatibility, try to parse it anyway. + // TODO: maybe just throw instead of trying? try { const remoteHTTP = await parseRefsAdResponse([data], { service }) remoteHTTP.auth = auth @@ -147,29 +149,17 @@ export class GitRemoteHTTP { body, headers, }) { + // We already have the "correct" auth value at this point, but + // we need to strip out the username/password from the URL yet again. const urlAuth = extractAuthFromUrl(url) - if (urlAuth) { - url = urlAuth.url - // To try to be backwards compatible with simple-get's behavior, which uses Node's http.request - // setting an Authorization header will override what is in the URL. - // Ergo manually specified auth parameters will override those in the URL. - auth.username = auth.username || urlAuth.username - auth.password = auth.password || urlAuth.password - } - if (corsProxy) { - url = corsProxify(corsProxy, url) - } + if (urlAuth) url = urlAuth.url + + if (corsProxy) url = corsProxify(corsProxy, url) + headers['content-type'] = `application/x-${service}-request` headers.accept = `application/x-${service}-result` - // If the username came from the URL, we want to allow the password to be missing. - // This is because Github allows using the token as the username with an empty password - // so that is a style of git clone URL we might encounter and we don't want to throw a "Missing password or token" error. - // Also, we don't want to prematurely throw an error before the credentialManager plugin has - // had an opportunity to provide the password. - auth = calculateBasicAuthUsernamePasswordPair(auth, !!urlAuth) - if (auth) { - headers.Authorization = calculateBasicAuthHeader(auth) - } + updateHeaders(headers, auth) + const res = await http({ onProgress, method: 'POST', diff --git a/src/models/GitError.js b/src/models/GitError.js index cc2000e0f..0ad24c31d 100644 --- a/src/models/GitError.js +++ b/src/models/GitError.js @@ -50,18 +50,6 @@ const messages = { AcquireLockFileFail: `Unable to acquire lockfile "{ filename }". Exhausted tries.`, DoubleReleaseLockFileFail: `Cannot double-release lockfile "{ filename }".`, InternalFail: `An internal error caused this command to fail. Please file a bug report at https://github.com/isomorphic-git/isomorphic-git/issues with this error message: { message }`, - UnknownOauth2Format: `I do not know how { company } expects its Basic Auth headers to be formatted for OAuth2 usage. If you do, you can use the regular username and password parameters to set the basic auth header yourself.`, - MissingPasswordTokenError: `Missing password or token`, - MissingUsernameError: `Missing username`, - MixPasswordTokenError: `Cannot mix "password" with "token"`, - MixUsernamePasswordTokenError: `Cannot mix "username" and "password" with "token"`, - MissingTokenError: `Missing token`, - MixUsernameOauth2formatMissingTokenError: `Cannot mix "username" with "oauth2format". Missing token.`, - MixPasswordOauth2formatMissingTokenError: `Cannot mix "password" with "oauth2format". Missing token.`, - MixUsernamePasswordOauth2formatMissingTokenError: `Cannot mix "username" and "password" with "oauth2format". Missing token.`, - MixUsernameOauth2formatTokenError: `Cannot mix "username" with "oauth2format" and "token"`, - MixPasswordOauth2formatTokenError: `Cannot mix "password" with "oauth2format" and "token"`, - MixUsernamePasswordOauth2formatTokenError: `Cannot mix "username" and "password" with "oauth2format" and "token"`, MaxSearchDepthExceeded: `Maximum search depth of { depth } exceeded.`, PushRejectedNonFastForward: `Push rejected because it was not a simple fast-forward. Use "force: true" to override.`, PushRejectedTagExists: `Push rejected because tag already exists. Use "force: true" to override.`, @@ -75,6 +63,7 @@ const messages = { CheckoutConflictError: `Your local changes to the following files would be overwritten by checkout: { filepaths }`, NoteAlreadyExistsError: `A note object { note } already exists on object { oid }. Use 'force: true' parameter to overwrite existing notes.`, GitPushError: `One or more branches were not updated: { prettyDetails }`, + UserCancelledError: `The operation was canceled.`, } export const E = { @@ -174,30 +163,6 @@ export const E = { DoubleReleaseLockFileFail: `DoubleReleaseLockFileFail`, /** @type {'InternalFail'} */ InternalFail: `InternalFail`, - /** @type {'UnknownOauth2Format'} */ - UnknownOauth2Format: `UnknownOauth2Format`, - /** @type {'MissingPasswordTokenError'} */ - MissingPasswordTokenError: `MissingPasswordTokenError`, - /** @type {'MissingUsernameError'} */ - MissingUsernameError: `MissingUsernameError`, - /** @type {'MixPasswordTokenError'} */ - MixPasswordTokenError: `MixPasswordTokenError`, - /** @type {'MixUsernamePasswordTokenError'} */ - MixUsernamePasswordTokenError: `MixUsernamePasswordTokenError`, - /** @type {'MissingTokenError'} */ - MissingTokenError: `MissingTokenError`, - /** @type {'MixUsernameOauth2formatMissingTokenError'} */ - MixUsernameOauth2formatMissingTokenError: `MixUsernameOauth2formatMissingTokenError`, - /** @type {'MixPasswordOauth2formatMissingTokenError'} */ - MixPasswordOauth2formatMissingTokenError: `MixPasswordOauth2formatMissingTokenError`, - /** @type {'MixUsernamePasswordOauth2formatMissingTokenError'} */ - MixUsernamePasswordOauth2formatMissingTokenError: `MixUsernamePasswordOauth2formatMissingTokenError`, - /** @type {'MixUsernameOauth2formatTokenError'} */ - MixUsernameOauth2formatTokenError: `MixUsernameOauth2formatTokenError`, - /** @type {'MixPasswordOauth2formatTokenError'} */ - MixPasswordOauth2formatTokenError: `MixPasswordOauth2formatTokenError`, - /** @type {'MixUsernamePasswordOauth2formatTokenError'} */ - MixUsernamePasswordOauth2formatTokenError: `MixUsernamePasswordOauth2formatTokenError`, /** @type {'MaxSearchDepthExceeded'} */ MaxSearchDepthExceeded: `MaxSearchDepthExceeded`, /** @type {'PushRejectedNonFastForward'} */ @@ -224,6 +189,8 @@ export const E = { NoteAlreadyExistsError: `NoteAlreadyExistsError`, /** @type {'GitPushError'} */ GitPushError: `GitPushError`, + /** @type {'UserCancelledError'} */ + UserCancelledError: `UserCancelledError`, } function renderTemplate(template, values) { diff --git a/src/typedefs.js b/src/typedefs.js index d3dec8f51..3d5ed7b63 100644 --- a/src/typedefs.js +++ b/src/typedefs.js @@ -89,21 +89,6 @@ * @property {function(): Promise} stat */ -/** - * @typedef {Object} GitAuth - * @property {string} [username] - * @property {string} [password] - * @property {string} [token] - * @property {string} [oauth2format] - */ - -/** - * @typedef {Object} GitProgressEvent - * @property {string} phase - * @property {number} loaded - * @property {number} total - */ - /** * @typedef {Object} CallbackFsClient * @property {function} readFile - https://nodejs.org/api/fs.html#fs_fs_readfile_path_options_callback @@ -145,27 +130,43 @@ * @returns {void | Promise} */ +/** + * @typedef {Object} GitProgressEvent + * @property {string} phase + * @property {number} loaded + * @property {number} total + */ + /** * @callback ProgressCallback * @param {GitProgressEvent} progress * @returns {void | Promise} */ +/** + * @typedef {Object} GitAuth + * @property {string} [username] + * @property {string} [password] + * @property {Object} [headers] + * @property {boolean} [cancel] Tells git to throw a `UserCancelledError` (instead of an `HTTPError`). + */ + /** * @callback AuthCallback * @param {string} url - * @returns {GitAuth | Promise} + * @param {GitAuth} auth Might have some values if the URL itself originally contained a username or password. + * @returns {GitAuth | void | Promise} */ /** - * @callback AuthSuccessCallback + * @callback AuthFailureCallback * @param {string} url - * @param {GitAuth} auth - * @returns {void | Promise} + * @param {GitAuth} auth The credentials that failed + * @returns {GitAuth | void | Promise} */ /** - * @callback AuthFailureCallback + * @callback AuthSuccessCallback * @param {string} url * @param {GitAuth} auth * @returns {void | Promise} diff --git a/src/utils/calculateBasicAuthHeader.js b/src/utils/calculateBasicAuthHeader.js index 02a1ad4f6..60f71a7f0 100644 --- a/src/utils/calculateBasicAuthHeader.js +++ b/src/utils/calculateBasicAuthHeader.js @@ -1,3 +1,3 @@ -export function calculateBasicAuthHeader({ username, password }) { +export function calculateBasicAuthHeader({ username = '', password = '' }) { return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` } diff --git a/src/utils/calculateBasicAuthUsernamePasswordPair.js b/src/utils/calculateBasicAuthUsernamePasswordPair.js deleted file mode 100644 index 9955167d0..000000000 --- a/src/utils/calculateBasicAuthUsernamePasswordPair.js +++ /dev/null @@ -1,37 +0,0 @@ -import { E, GitError } from '../models/GitError.js' - -import { oauth2 } from './oauth2' - -export function calculateBasicAuthUsernamePasswordPair( - { username, password, token, oauth2format } = {}, - allowEmptyPassword = false -) { - // This checks for the presense and/or absense of each of the 4 parameters, - // converts that to a 4-bit binary representation, and then handles - // every possible combination (2^4 or 16 cases) with a lookup table. - const key = [!!username, !!password, !!token, !!oauth2format] - .map(Number) - .join('') - // See the truth table on https://isomorphic-git.github.io/docs/authentication.html - // prettier-ignore - switch (key) { - case '0000': return null - case '1000': - if (allowEmptyPassword) return { username, password: '' } - else throw new GitError(E.MissingPasswordTokenError) - case '0100': throw new GitError(E.MissingUsernameError) - case '1100': return { username, password } - case '0010': return { username: token, password: '' } // Github's alternative format - case '1010': return { username, password: token } - case '0110': throw new GitError(E.MixPasswordTokenError) - case '1110': throw new GitError(E.MixUsernamePasswordTokenError) - case '0001': throw new GitError(E.MissingTokenError) - case '1001': throw new GitError(E.MixUsernameOauth2formatMissingTokenError) - case '0101': throw new GitError(E.MixPasswordOauth2formatMissingTokenError) - case '1101': throw new GitError(E.MixUsernamePasswordOauth2formatMissingTokenError) - case '0011': return oauth2(oauth2format, token) - case '1011': throw new GitError(E.MixUsernameOauth2formatTokenError) - case '0111': throw new GitError(E.MixPasswordOauth2formatTokenError) - case '1111': throw new GitError(E.MixUsernamePasswordOauth2formatTokenError) - } -} diff --git a/src/utils/extractAuthFromUrl.js b/src/utils/extractAuthFromUrl.js index 887c7c7f2..2aa8faea0 100644 --- a/src/utils/extractAuthFromUrl.js +++ b/src/utils/extractAuthFromUrl.js @@ -5,9 +5,12 @@ export function extractAuthFromUrl(url) { // and compute the Authorization header. // Note: I tried using new URL(url) but that throws a security exception in Edge. :rolleyes: let userpass = url.match(/^https?:\/\/([^/]+)@/) - if (userpass == null) return null + // No credentials, return the url unmodified and an empty auth object + if (userpass == null) return { url, auth: {} } userpass = userpass[1] const [username, password] = userpass.split(':') + // Remove credentials from URL url = url.replace(`${userpass}@`, '') - return { url, username, password } + // Has credentials, return the fetch-safe URL and the parsed credentials + return { url, auth: { username, password } } }