-
Notifications
You must be signed in to change notification settings - Fork 205
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
Changes from 3 commits
86f29e2
985be37
5204624
369277c
d2f34f4
cc4a9bc
f986398
3083781
4fabbbb
aadbb3e
e2b1dfa
79ae954
300cbb2
7a3ba0d
149ea39
9e8c555
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
|
@@ -13,7 +13,9 @@ import ( | |||||||||
"go.flipt.io/flipt/internal/containers" | ||||||||||
"go.flipt.io/flipt/internal/gateway" | ||||||||||
"go.flipt.io/flipt/internal/server/auth" | ||||||||||
"go.flipt.io/flipt/internal/server/auth/method" | ||||||||||
authkubernetes "go.flipt.io/flipt/internal/server/auth/method/kubernetes" | ||||||||||
"go.flipt.io/flipt/internal/server/auth/method/oauth" | ||||||||||
authoidc "go.flipt.io/flipt/internal/server/auth/method/oidc" | ||||||||||
authtoken "go.flipt.io/flipt/internal/server/auth/method/token" | ||||||||||
"go.flipt.io/flipt/internal/server/auth/public" | ||||||||||
|
@@ -119,6 +121,15 @@ func authenticationGRPC( | |||||||||
logger.Debug("authentication method \"oidc\" server registered") | ||||||||||
} | ||||||||||
|
||||||||||
if authCfg.Methods.OAuth.Enabled { | ||||||||||
oauthServer := oauth.NewServer(logger, store, authCfg) | ||||||||||
register.Add(oauthServer) | ||||||||||
|
||||||||||
authOpts = append(authOpts, auth.WithServerSkipsAuthentication(oauthServer)) | ||||||||||
|
||||||||||
logger.Debug("authentication method \"oauth\" registered") | ||||||||||
} | ||||||||||
|
||||||||||
if authCfg.Methods.Kubernetes.Enabled { | ||||||||||
kubernetesServer, err := authkubernetes.New(logger, store, authCfg) | ||||||||||
if err != nil { | ||||||||||
|
@@ -212,15 +223,25 @@ func authenticationHTTPMount( | |||||||||
} | ||||||||||
|
||||||||||
if cfg.Methods.OIDC.Enabled { | ||||||||||
oidcmiddleware := authoidc.NewHTTPMiddleware(cfg.Session) | ||||||||||
oidcmiddleware := method.NewHTTPMiddleware(cfg.Session) | ||||||||||
muxOpts = append(muxOpts, | ||||||||||
runtime.WithMetadata(authoidc.ForwardCookies), | ||||||||||
runtime.WithMetadata(method.ForwardCookies), | ||||||||||
runtime.WithForwardResponseOption(oidcmiddleware.ForwardResponseOption), | ||||||||||
registerFunc(ctx, conn, rpcauth.RegisterAuthenticationMethodOIDCServiceHandler)) | ||||||||||
|
||||||||||
middleware = append(middleware, oidcmiddleware.Handler) | ||||||||||
} | ||||||||||
|
||||||||||
if cfg.Methods.OAuth.Enabled { | ||||||||||
oauthmiddleware := method.NewHTTPMiddleware(cfg.Session) | ||||||||||
muxOpts = append(muxOpts, | ||||||||||
runtime.WithMetadata(method.ForwardCookies), | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would if we should add a method to 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: flipt/internal/config/authentication.go Lines 117 to 120 in 2569b55
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||||||||||
runtime.WithForwardResponseOption(oauthmiddleware.ForwardResponseOption), | ||||||||||
registerFunc(ctx, conn, rpcauth.RegisterAuthenticationMethodOAuthServiceHandler)) | ||||||||||
|
||||||||||
middleware = append(middleware, oauthmiddleware.Handler) | ||||||||||
} | ||||||||||
|
||||||||||
if cfg.Methods.Kubernetes.Enabled { | ||||||||||
muxOpts = append(muxOpts, registerFunc(ctx, conn, rpcauth.RegisterAuthenticationMethodKubernetesServiceHandler)) | ||||||||||
} | ||||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,168 @@ | ||
package oauth | ||
|
||
import ( | ||
"context" | ||
"encoding/json" | ||
"net/http" | ||
"net/url" | ||
"strings" | ||
"time" | ||
|
||
"go.flipt.io/flipt/errors" | ||
"go.flipt.io/flipt/internal/config" | ||
storageauth "go.flipt.io/flipt/internal/storage/auth" | ||
"go.flipt.io/flipt/rpc/flipt/auth" | ||
"go.uber.org/zap" | ||
"google.golang.org/grpc" | ||
"google.golang.org/grpc/metadata" | ||
"google.golang.org/protobuf/types/known/timestamppb" | ||
) | ||
|
||
const ( | ||
storageMetadataGithubAccessToken = "io.flipt.auth.oauth.access_token" | ||
) | ||
|
||
var ( | ||
hostToAuthorizeBaseURL = map[string]string{ | ||
"github": "https://github.com/login/oauth/authorize", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
} | ||
hostToAccessTokenBaseURL = map[string]string{ | ||
"github": "https://github.com/login/oauth/access_token", | ||
} | ||
) | ||
|
||
// Server is an OAuth server side handler. | ||
type Server struct { | ||
logger *zap.Logger | ||
store storageauth.Store | ||
config config.AuthenticationConfig | ||
|
||
auth.UnimplementedAuthenticationMethodOAuthServiceServer | ||
} | ||
|
||
// NewServer constructs a Server. | ||
func NewServer( | ||
logger *zap.Logger, | ||
store storageauth.Store, | ||
config config.AuthenticationConfig, | ||
) *Server { | ||
return &Server{ | ||
logger: logger, | ||
store: store, | ||
config: config, | ||
} | ||
} | ||
|
||
// RegisterGRPC registers the server as an Server on the provided grpc server. | ||
func (s *Server) RegisterGRPC(server *grpc.Server) { | ||
auth.RegisterAuthenticationMethodOAuthServiceServer(server, s) | ||
} | ||
|
||
func callbackURL(host, oauthHost string) string { | ||
// strip trailing slash from host | ||
host = strings.TrimSuffix(host, "/") | ||
return host + "/auth/v1/method/oauth/" + oauthHost + "/callback" | ||
} | ||
|
||
// AuthorizeURL will return a URL for the client to redirect to for completion of the OAuth flow with GitHub. | ||
func (s *Server) AuthorizeURL(ctx context.Context, a *auth.OAuthAuthorizeRequest) (*auth.AuthorizeURLResponse, error) { | ||
oauthConfig := s.config.Methods.OAuth.Method.Hosts[a.Host] | ||
|
||
authorizeBaseURL := hostToAuthorizeBaseURL[a.Host] | ||
|
||
u, err := url.Parse(authorizeBaseURL) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
// Build OAuth authorize url. | ||
q := u.Query() | ||
|
||
q.Set("client_id", oauthConfig.ClientId) | ||
q.Set("scope", strings.Join(oauthConfig.Scopes, ":")) | ||
q.Set("redirect_uri", callbackURL(oauthConfig.RedirectAddress, a.Host)) | ||
q.Set("state", a.State) | ||
|
||
u.RawQuery = q.Encode() | ||
|
||
return &auth.AuthorizeURLResponse{ | ||
AuthorizeUrl: u.String(), | ||
}, nil | ||
} | ||
|
||
// OAuthCallback is the OAuth callback method for OAuth authentication. It will take in a SessionCode | ||
// which is the OAuth grant passed in by the OAuth service, and exchange the grant with an Authentication | ||
// that includes the access token. | ||
func (s *Server) OAuthCallback(ctx context.Context, oauth *auth.OAuthCallbackRequest) (*auth.OAuthCallbackResponse, error) { | ||
if oauth.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 oauth.State != state[0] { | ||
return nil, errors.ErrUnauthenticatedf("unexpected state parameter") | ||
} | ||
} | ||
|
||
oauthConfig := s.config.Methods.OAuth.Method.Hosts[oauth.Host] | ||
|
||
c := &http.Client{ | ||
Timeout: 5 * time.Second, | ||
} | ||
|
||
accessTokenURL := hostToAccessTokenBaseURL[oauth.Host] | ||
|
||
req, err := http.NewRequestWithContext(ctx, "POST", accessTokenURL, nil) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
q := req.URL.Query() | ||
q.Set("client_id", oauthConfig.ClientId) | ||
q.Set("client_secret", oauthConfig.ClientSecret) | ||
q.Set("code", oauth.Code) | ||
e := q.Encode() | ||
req.URL.RawQuery = e | ||
|
||
// We have to accept JSON from the GitHub server when requesting the access token. | ||
req.Header.Set("Accept", "application/json") | ||
|
||
resp, err := c.Do(req) | ||
if err != nil { | ||
return nil, err | ||
} | ||
defer resp.Body.Close() | ||
|
||
var oauthAccessTokenResponse struct { | ||
AccessToken string `json:"access_token,omitempty"` | ||
Scopes []string `json:"scopes,omitempty"` | ||
} | ||
|
||
if err := json.NewDecoder(resp.Body).Decode(&oauthAccessTokenResponse); err != nil { | ||
return nil, err | ||
} | ||
|
||
metadata := map[string]string{ | ||
storageMetadataGithubAccessToken: oauthAccessTokenResponse.AccessToken, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
} | ||
|
||
clientToken, a, err := s.store.CreateAuthentication(ctx, &storageauth.CreateAuthenticationRequest{ | ||
Method: auth.Method_METHOD_OAUTH, | ||
ExpiresAt: timestamppb.New(time.Now().UTC().Add(s.config.Session.TokenLifetime)), | ||
Metadata: metadata, | ||
}) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
return &auth.OAuthCallbackResponse{ | ||
ClientToken: clientToken, | ||
Authentication: a, | ||
}, nil | ||
} |
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.