Skip to content
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

feat(github): OAuth for GitHub #2016

Merged
merged 16 commits into from
Aug 22, 2023
Merged

feat(github): OAuth for GitHub #2016

merged 16 commits into from
Aug 22, 2023

Conversation

yquansah
Copy link
Contributor

@yquansah yquansah commented Aug 18, 2023

This PR is meant to provide functionality with logging into Flipt via GH OAuth.

  • Provides new configuration options for OAuth hosts
  • Adds the appropriate icons on the UI for logging in via GitHub
  • Abstracts the middleware for working with both OAuth and OIDC

Completes FLI-212
github-login

}

metadata := map[string]string{
storageMetadataGithubAccessToken: oauthAccessTokenResponse.AccessToken,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we're really going to want to store this.
Particularly in this way, which will leak it through the API.
Instead, we should just use it to get identity information and just put the pieces we get from Github in the metadata (profile information etc.). That is what we do in the OIDC method.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GeorgeMac Okay was thinking that too. I did it like this because I was wondering if we wanted to keep that access token around for making authorized requests to GH

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, once we have used it to finish the auth flow and get identity information we can discard it.
Somewhere down the line we might want to re-explore storing this, refresh tokens and so on.
But if we do, we would likely store it somewhere not API accessible.


var (
hostToAuthorizeBaseURL = map[string]string{
"github": "https://github.com/login/oauth/authorize",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just thinking out loud, the only thing that makes this GitHub specific and not 'general OAuth' support are URLS right? Also we may want to think about users who run GH Enterprise in their domain, but that could probably be added later

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kind of. I think what URLs you can access with the recieved access token, how you access them (query params vs e.g. body) and the shape of the responses of these endpoints can vary from one implementation to the next.

This is where OIDC has a standard around the UserInfo endpoint and solves that problem.
Plus they close the loop here on finishing the authorization leg in a standard way.

@@ -1,7 +1,8 @@
import {
faGitlab,
faGoogle,
faOpenid
faOpenid,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

npx prettier -w . in the ui folder should take care of all of these

@yquansah yquansah marked this pull request as ready for review August 20, 2023 15:27
@yquansah yquansah requested a review from a team as a code owner August 20, 2023 15:27
@codecov
Copy link

codecov bot commented Aug 20, 2023

Codecov Report

Merging #2016 (149ea39) into main (6a434e4) will decrease coverage by 0.50%.
Report is 2 commits behind head on main.
The diff coverage is 51.07%.

❗ Current head 149ea39 differs from pull request most recent head 9e8c555. Consider uploading reports for the commit 9e8c555 to get more accurate results

@@            Coverage Diff             @@
##             main    #2016      +/-   ##
==========================================
- Coverage   71.04%   70.54%   -0.50%     
==========================================
  Files          71       71              
  Lines        6768     6791      +23     
==========================================
- Hits         4808     4791      -17     
- Misses       1693     1732      +39     
- Partials      267      268       +1     
Files Changed Coverage Δ
internal/cmd/auth.go 0.00% <0.00%> (ø)
internal/server/auth/method/github/server.go 56.38% <56.38%> (ø)
internal/config/authentication.go 71.91% <72.72%> (+0.11%) ⬆️
internal/server/auth/method/oidc/server.go 68.46% <100.00%> (+0.40%) ⬆️

... and 1 file with indirect coverage changes

📣 We’re building smart automated test selection to slash your CI/CD build times. Learn more

Copy link
Contributor

@GeorgeMac GeorgeMac left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking awesome. Small handful of suggestions 👍


authOpts = append(authOpts, auth.WithServerSkipsAuthentication(githubServer))

logger.Debug("authentication method \"oauth\" registered")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
logger.Debug("authentication method \"oauth\" registered")
logger.Debug("authentication method \"github\" registered")

if cfg.Methods.Github.Enabled {
githubMiddleware := method.NewHTTPMiddleware(cfg.Session)
muxOpts = append(muxOpts,
runtime.WithMetadata(method.ForwardCookies),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would if we should add a method to AuthenticationConfig for whether or not session support needs enabling.

So we can do something like:

if cfg.SessionEnabled() {
    muxOpts = append(muxOpts, runtime.WithMetadata(method.ForwardCookies))
}

It could perform the same operation as what happens in the validate method right now:

func (c *AuthenticationConfig) validate() error {
var sessionEnabled bool
for _, info := range c.Methods.AllMethods() {
sessionEnabled = sessionEnabled || (info.Enabled && info.SessionCompatible)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea being that we dont append the middleware twice when both are enabled.

ClientSecret: config.Methods.Github.Method.ClientSecret,
Endpoint: oauth2GitHub.Endpoint,
RedirectURL: callbackURL(config.Methods.Github.Method.RedirectAddress),
Scopes: []string{strings.Join(config.Methods.Github.Method.Scopes, ":")},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems wrong to me. This feels like we're combining not the full scopes, but the noun and verb parts of a Github scope.
For example, how would the user configure both user:email and read:user? This sticks all the parts in the slice together with :.

I think we should just pass in the slice of scopes as is. Then the configurer has to do e.g. scopes: ["user:email", "read:user"].

Suggested change
Scopes: []string{strings.Join(config.Methods.Github.Method.Scopes, ":")},
Scopes: config.Methods.Github.Method.Scopes,

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. It would be up to the user to provide the scopes as is. I like that idea!

Comment on lines 93 to 107
if r.State != "" {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, errors.ErrUnauthenticatedf("missing state parameter")
}

state, ok := md["flipt_client_state"]
if !ok || len(state) < 1 {
return nil, errors.ErrUnauthenticatedf("missing state parameter")
}

if r.State != state[0] {
return nil, errors.ErrUnauthenticatedf("unexpected state parameter")
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be nice to DRY this up into a utility function.
It could operate over the callback request or this interface:

type Stater interface {
    GetState() string
}

Something like methods.CallbackValidateState()

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then we can share it between both callback functions.

Comment on lines 148 to 151
metadata := map[string]string{
storageMetadataGithubEmail: githubUserResponse.Email,
storageMetadataGithubName: githubUserResponse.Name,
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am unsure how Github responds, but it might be nice to check if these are empty? (might need to confirm if these can ever be returned empty).
Then we should only set the keys if they're not empty.
Just incase the frontend something odd with empty values here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My one guess is email could be empty without the user:email scope.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GeorgeMac Something I was definitely considering too. I can do a quick test to see if that is the case, but I believe it might be.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GeorgeMac It seems as though this always gets populated without providing the scope. Kinda weird 🤔, i'll track down what the actual meaning of this is.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it could be to do with your email visibility settings @yquansah
If you go to Settings > Emails > Keep my email address private and toggle that in GH, it might change the output.
They might still stick your private github email address in the space, so that might not be a problem.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the user can also allow less scopes than requested: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps#requested-scopes-and-granted-scopes

im not sure if email is one of those scopes that can be 'removed', but good to guard against I think

Comment on lines 158 to 171
func parts(path string) (provider, method, prefix string, ok bool) {
var oidcPrefix = "/auth/v1/method/oidc/"
if strings.HasPrefix(path, oidcPrefix) {
b, a, f := strings.Cut(path[len(oidcPrefix):], "/")
return b, a, oidcPrefix, f
}

var githubPrefix = "/auth/v1/method/github"
if strings.HasPrefix(path, githubPrefix) {
b, a, f := strings.Cut(path[len(githubPrefix):], "/")
return b, a, githubPrefix, f
}

return strings.Cut(path[len(prefix):], "/")
return "", "", "", false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pedant suggestion here, but could we make it return (prefix, provider, method string, ok bool).
Just to preserve the original order of the parts.

Also, maybe pick some more meaningful variables names than b, a and f. I am unsure what those refer to.

You could even:

const (
    oidcPrefix = "/auth/v1/method/oidc/"
    githubPrefix = "/auth/v1/method/github/"
)

func parts(path string) (prefix, provider, method string, ok bool) {
    if strings.HasPrefix(path, oidcPrefix) {
        prefix = oidcPrefix
        provider, method, ok = strings.Cut(path[len(oidcPrefix):], "/")
    } else if strings.HasPrefix(path, githubPrefix) {
        prefix = githubPrefix
        provider, method, ok = strings.Cut(path[len(githubPrefix):], "/")
    }
    
    return
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GeorgeMac Love it!

Comment on lines 225 to 229
if cfg.SessionEnabled() && (cfg.Methods.OIDC.Enabled || cfg.Methods.Github.Enabled) {
muxOpts = append(muxOpts, runtime.WithMetadata(method.ForwardCookies))
}

if cfg.Methods.OIDC.Enabled || cfg.Methods.Github.Enabled {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you might be able to boil this all down to the following.
SessionEnabled ensures at least one of these two methods are true (same as the .Enabled conditions here).
Also, SessionEnabled is only true, and is always true for OIDC or GitHub.

Suggested change
if cfg.SessionEnabled() && (cfg.Methods.OIDC.Enabled || cfg.Methods.Github.Enabled) {
muxOpts = append(muxOpts, runtime.WithMetadata(method.ForwardCookies))
}
if cfg.Methods.OIDC.Enabled || cfg.Methods.Github.Enabled {
if cfg.SessionEnabled() {
muxOpts = append(muxOpts, runtime.WithMetadata(method.ForwardCookies))

Copy link
Contributor

@GeorgeMac GeorgeMac left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple quick observations 👍

internal/server/auth/method/http.go Show resolved Hide resolved
internal/server/auth/method/http.go Outdated Show resolved Hide resolved

var githubUserResponse struct {
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we also get the user's avatar_url (https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28) to display as the user profile pic?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@markphelps Good call!

Comment on lines 148 to 151
metadata := map[string]string{
storageMetadataGithubEmail: githubUserResponse.Email,
storageMetadataGithubName: githubUserResponse.Name,
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the user can also allow less scopes than requested: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps#requested-scopes-and-granted-scopes

im not sure if email is one of those scopes that can be 'removed', but good to guard against I think

@markphelps markphelps added the needs docs Requires documentation updates label Aug 21, 2023
@yquansah yquansah force-pushed the github-oauth branch 2 times, most recently from 77b8424 to 575a7af Compare August 21, 2023 21:44
@@ -90,7 +92,7 @@ func (m Middleware) ForwardResponseOption(ctx context.Context, w http.ResponseWr
// The payload is then also encoded as a http cookie which is bound to the callback path.
func (m Middleware) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
provider, method, match := parts(r.URL.Path)
prefix, provider, method, match := parts(r.URL.Path)
Copy link
Contributor

@GeorgeMac GeorgeMac Aug 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Take it or leave it:
I reflected a bit on this, after you raised the very valid point that there there is the subtle difference of a provider for OIDC and not for Github, which makes this cutting and prefixing fiddly; I think there might be just a clearer way to express all this.

In hindsight, all this path matching is only really attempting to do two things:

  1. Match valid authorize request paths (one of OIDC and one for Google).
  2. Replace the authorize path entry with the word callback for the callback path.

I previously had overengineered this with pulling out the provider, but I cant see where that is getting used.

I wonder if the inconsistencies and fiddly reconstruction could all be simplified with just a bit of inline code that does just this:

e.g.

func (m Middleware) Handler(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		prefix, method := path.Split(r.URL.Path)
		if !((strings.HasPrefix(prefix, oidcPrefix) || strings.HasPrefix(prefix, ghPrefix)) && method == "authorize") {
			next.ServeHTTP(w, r)
			return
		}
		
		// ...
		
		
			// bind state cookie to callback handler
			Path:     path.Join(prefix, "callback"),

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then you can even take the trailing slash off both the OIDC and Github prefixes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GeorgeMac Awesome suggestion!

Copy link
Contributor

@GeorgeMac GeorgeMac left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh one important things needs doing. Otherwise, I think this is good to go.
Perhaps, along with a test case to cover this.

Comment on lines +127 to +130
userResp, err := c.Do(userReq)
if err != nil {
return nil, err
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should check for a 200 status code on this.

I assume there is a chance their non-200 status code returns valid json and we might authenticate the user in that situation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GeorgeMac Nice catch! Yeah let me do that.

Copy link
Collaborator

@markphelps markphelps left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks great! one minor request around unit test, no need for re-review

}

func callbackURL(host string) string {
// strip trailing slash from host
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be nice to add some basic unit tests here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@markphelps Let me add that really quick.

Copy link
Contributor

@GeorgeMac GeorgeMac left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sweet 💪

@yquansah yquansah merged commit 25fb0bb into main Aug 22, 2023
24 checks passed
@yquansah yquansah deleted the github-oauth branch August 22, 2023 15:39
@markphelps markphelps removed the needs docs Requires documentation updates label Sep 24, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants