-
Notifications
You must be signed in to change notification settings - Fork 588
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Hook up webflow auth during credential checks #94
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice! This is great.
Should we show a UI before we start the authentication?
I think this would be a better experience, we could show a notification telling the user they need to authenticate, with a button to do so. Skipping to the GitHub permissions page would make sense then 👍
There's an example of using the notification UI here: https://github.com/Microsoft/vscode-pull-request-github/blob/master/src/github/pullRequestManager.ts#L290
I'm also wondering if we should show another notification in VSCode after authentication. The redirect page already says the status, but it might be nice to have this in the VSCode UI as well?
I'm really not partial to storing tokens in the settings, I'd rather store them in the system credential manager. I assume there's some handy node library to do that for us?
yeah storing them in settings is quite bad. I believe https://www.npmjs.com/package/keytar could be used for this
src/authentication/configuration.ts
Outdated
this.onDidChange = this.emitter.event; | ||
} | ||
|
||
update(username: string | undefined, token: string | undefined) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is looking good! I don't think we have a formalized style guide on TS that we use, but I'll share my opinions anyway 😄
- Nice to have a return type on methods, even if it's
void
- I think marking public member variables explicitly as
public
is easier to read - I also prefer using
const
for variable declarations except when the variable will be reassigned to, in which case I uselet
. We're not very consistent about this as a team, this is just personal preference - I think this makes it easier to read since you (and the compiler) then have expectations about how variables should be used - Avoid
any
if possible/not extremely laborious
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for this! I agree, explicit visibility and constness markers make things much easier to navigate. I'm using tslint and tslint-config-prettier locally to guide me through the language and nag me about the things I should/should not be doing, so I can configure it to follow these rules. Is tslint something you use as a team?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup, we use tslint. In VSCode we have a pretty small set of style rules and a larger number of rules dictating what can be imported where: https://github.com/Microsoft/vscode/blob/master/tslint.json
Adding a tslint.json
to this project sounds like a good idea to me
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can add a basic tslint config file first.
} | ||
|
||
export class VSCodeConfiguration extends Configuration { | ||
private hosts: { [key: string]: any }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have a Map type that I think could be used here, perhaps private hosts: Map<string, IHostConfiguration>
? Internally it looks very similar to the type wrote here. The syntax looks like
const map = new Map<T, T>();
map.set(someKey, someValue)
map.get(someKey)
(Reset would just be creating a new map, I think)
The current data structure is a bit confusing to me, since it seems to map the host to a number, and then the number to the whole host configuration object. If two different maps are needed, I think it would be clearer to have separate variables for them, but in this case I think it can be simplified to one map of host string to host configuration object
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does Map
easily convert to an array?
I was struggling to find a type that could give me key access but easily turn into an array for saving into the configuration file (as that takes a simple []
).
What I did here is a bit of a hack - it seems the typescript { [key: string]: any }
syntax basically just adds an indexer to the underlying object is, so it's basically an array with an extra indexer (so I just store the index of the object to look it up in the array).
I first implemented a Dictionary type, and then discovered this key access syntax and kept two fields, one with the array of all the hosts and another with the keys and indexes to the array, but then discovered I could do everything in one object, which is what is there now. I agree it's really hard to read, I'll see if Map does the trick.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, I see! I don't think there's any method that directly gives an array primitive from the map, but there are a couple of ways to iterate through it that could be used to create an array:
const flattenedMap = [];
map.forEach(value => flattenedMap.push(value));
or
Array.from(map.values());
super.update(config.username, config.token); | ||
} | ||
|
||
listenForVSCodeChanges() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should this be called in the constructor?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, probably. Not sure where my head was on that one 🤔
src/authentication/webflow.ts
Outdated
} | ||
let expected = SCOPES.split(' '); | ||
let serverScopes = new Set(scopes.split(', ')); | ||
var ret = expected.filter(x => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
another way to write this is
expected.every(x => serverScopes.has(x))
package-lock.json
Outdated
@@ -50,18 +50,71 @@ | |||
"integrity": "sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow==", | |||
"dev": true | |||
}, | |||
"@types/body-parser": { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
could you also stage the changes to package.json
?
Rachel already left good suggestions/comments, here are my 2 cents.
Having some UI before the authentication is a good idea and we can use a specific tree node in Pull Request tree view, or a statusbar item and together with a registered command to trigger the auth workflow. For example, Live Share uses a status bar item to indicate the auth status The Azure Account extension provides a command If we use a status bar item, then it can show authenticated user info once the auth part is done. If we show a specific login tree node in Pull Request Tree View then we don't need to anything specific, the login node will be removed and replaced with categories when the user logs in.
If I remember correctly the oauth/permission page needs a redirect page and it's static (ignore this if I'm wrong), what if the port we use is occupied? |
src/extension.ts
Outdated
repository.onDidRunGitStatus(async e => { | ||
if (repositoryInitialized) { | ||
return; | ||
} | ||
|
||
let remotes = repository.remotes.filter(remote => remote.host); | ||
let remote = remotes.find(remote => remote.remoteName === 'origin'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We may want to avoid relying on origin
or probably making this a configuration (for example, github.defaultRemoteName
). While working on VSCode code base, I use upstream
for Microsoft/vscode and rebornix
for my own fork, so it will fail if we check origin
.
It's a corner case I believe but all our code logic right now doesn't rely on origin
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I agree, hardcoding origin
is bad. Your logic right now relies on looking for a remote that is pointing to github.com
; this logic finds either one that's called origin
or the first remote if origin
doesn't exist.
This means it will likely find rebornix
for you before it finds upstream
, if you cloned your fork first and then added the upstream
remote. It's highly dependent on the clone/remote order. Definitely not ideal :P
On GHfVS, I think we look for remotes and then check whether the repository they point to is a fork, in which case we look up the fork parent and use that by default for PRs. I think we also let you switch between PR sources for a fork/upstream combo (i.e., you can see the PRs from upstream or you can see PRs from a fork, for long-lived project forks that have their own community)
The reason I don't want to scan for github.com
is because github enterprise instances will work just as well with these features, and we can't really rely on looking at the name of the server. I probably need to add a check for a specific header that dotcom or enterprise always return, to make sure the server is usable (besides the whole "which remote do I use" thing)
A configuration option is probably good enough to start with to avoid these issues.
src/authentication/configuration.ts
Outdated
username: string | undefined; | ||
token: string | undefined; | ||
onDidChange: vscode.Event<IConfiguration>; | ||
private emitter: vscode.EventEmitter<IConfiguration>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We prefer to have private members to start with _
.
src/github/credentials.ts
Outdated
}); | ||
return this._octokits[remote.url]; | ||
const webflow = new WebFlow(new VSCodeAppConfiguration(), remote.host); | ||
let creds = this._configuration as IHostConfiguration; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems we only need IHostConfiguration
in CredentialStore
, we can unrestrict the type of this._configuration
to be IHostConfiguration
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, yeah, good catch, this was me carefully walking around your existing Configuration implementation by avoiding changing signatures. All the configuration stuff needs cleaning up :P
src/authentication/configuration.ts
Outdated
import * as vscode from 'vscode'; | ||
|
||
export interface IAppConfiguration { | ||
clientId: string | undefined; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not a big deal but our existing code is using tabs ;)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Darnit, I noticed that and configured prettier to use tabs but looks like spaces still snuck in. Fixicating...
Yeah, that's a problem I go over in #93. I think the ideal way is for the login to be handled by a webapp on a separate server instead of localhost, bypassing the need for local ports entirely - the local app talks to the server via a websocket, and the server and github talk to each other. That also mitigates the problem of distributing client secrets (as the server is the one keeping them) |
Oh awesome, thanks, I'll take a look!
Yeah, I was thinking how to redirect the user "eye" back to the editor. A notification would probably do it 👍 |
I noticed that there's yarn support (and now I know what all the *.json files mean, yay for knowledge), so I've rebased all the code on top of master and redid the package changes for npm and for yarn. This version is using an external webapp to authenticate, so it doesn't need half the dependencies the previous version used (much simpler code, too!). I've address the style feedback, so the next step is to add the ui notification, make sure the remote selection code isn't regressing behaviour, and then make the response page pretty. |
…ports it Make sure we only try to sign in once to a given server.
Also show a message when the user is signed in successfully.
Almost all the functionality is in (:tada:), I've added a list of tasks done and not done on the issue body, which I'm tackling next. I don't think I forgot anything, if I did let me know! Right now this is using the websocket approach, but I think that we can actually use a protocol handler with the same model and skip the web socket entirely, if it turns out that web sockets cause problems. Web sockets basically go through the same pipe as https requests, so I feel that if a web socket doesn't work, https wouldn't either and you'd be kinda stuck either way, so not sure I want to change that right now. |
// see if the system keychain has something we can use | ||
const data = await fill(host); | ||
if (data) { | ||
const login = await server.validate(data.username, data.password); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm using personal access token for https remotes so this line is being executed as I have username/token saved in git credentials. However this line never returns. Once I commented out this part and it fell back to the oauth login, everything works as expected.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting, I wonder what's going on. It might be throwing somewhere and getting swallowed. I'll try to repro locally.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I fixed this, there were some very silly bugs in that code.
Delay the credential check and doing sign in until the first interaction of the user with something that requires API access, so basically it will only kick in when the user expands one of the PR lists.
A token with a "user" scope will work just as well as one with "user:email".
I think this is done! Here's how the flow looks now: The webbrowser UI is being worked on so it'll look better soon 😄 The credentials are still being stored in the settings file, but I think I'd rather do the work of moving them to the system credential manager on a separate PR, as there might be some other complications with that and I don't want to delay this more. I'd also like to add a command to trigger the login, probably something that can be done separately. I'm also unsure whether the sign in notification will work or if it's too subtle. It will pop up for anything that needs to call octokit (because getting an octokit instance triggers the whole credential loading thing). UX is hard! |
package.json
Outdated
"default": [], | ||
"description": "Host tokens", | ||
"items": { | ||
"type": "object" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can also add a "properties"
property within items, which helps intellisense in the settings editor
something like
"properties": {
"host": {
"type": "string",
"description": "The host name to access GitHub."
},
"username": {
"type": "string",
"description": "The host name to access GitHub."
},
"token": {
"type": "string",
"description": "GitHub access token."
}
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh cool, didn't know that, thanks!
export const HostHelper = class { | ||
public static getApiHost(host: IHostConfiguration | vscode.Uri): vscode.Uri { | ||
const hostUri: vscode.Uri = host instanceof vscode.Uri ? host : vscode.Uri.parse(host.host); | ||
if (hostUri.authority === 'github.com') { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is it possible for there to be a case mismatch?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hmmmmm, good question. possibly, yes, I can test that quick
src/github/pullRequestManager.ts
Outdated
@@ -14,7 +14,8 @@ import { IPullRequestManager, IPullRequestModel, IPullRequestsPagingOptions, PRT | |||
import { PullRequestGitHelper } from "./pullRequestGitHelper"; | |||
import { PullRequestModel } from "./pullRequestModel"; | |||
import { parserCommentDiffHunk } from "../common/diffHunk"; | |||
import { Configuration } from "../configuration"; | |||
import { Configuration } from '../authentication/configuration'; | |||
import { GitHubManager } from '../authentication/githubserver'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
../authentication/githubServer
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ooof, good catch, that would fail on linux :/
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks great! I had some small comments but I think this looks good to merge
I went through and normalized all the hostnames to lowercase, there are other places where there could be case mismatches. Tested that out with another repo I have with a bunch of different remotes (to which I added a few more), and it matches things correctly for all of them.
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM.
This is a Proof of Concept of implementing #93 with the webflow + localhost approach. I didn't want to rewrite the existing
Configuration
type (and I initially did this as a separate extension), so this has a separate implementation of the configuration class that keeps the token data - which is of course not really a good way to do it, but for this prototype it's probably good enough.I've also added validating tokens for their required scopes, and triggering a "permissions upgrade" webflow if they don't. That also happens for any tokens that are read from the git credential helper.
This is how it works:
Some thoughts:
When and how should the authentication trigger? Right now it triggers automatically when the PRManager gets initialized, which means it will just happen as soon as VSCode opens and popup on a user's face before they have a chance to think.
I'm really not partial to storing tokens in the settings, I'd rather store them in the system credential manager. I assume there's some handy node library to do that for us?
I'm really new to both typescript and node applications in general so any feedback on the code as well as the overall design of the process is very much appreciated!
Tasks