diff --git a/README.md b/README.md index d30ca43b..06f2313b 100644 --- a/README.md +++ b/README.md @@ -22,37 +22,12 @@ Run `kubelogin`. ``` % kubelogin -2018/03/23 18:01:40 Reading config from /home/user/.kube/config -2018/03/23 18:01:40 Using current context: hello.k8s.local -2018/03/23 18:01:40 Using issuer: https://keycloak.example.com/auth/realms/hello -2018/03/23 18:01:40 Using client ID: kubernetes -2018/03/23 18:01:41 Starting OpenID Connect authentication: - -## Automatic (recommended) - -Open the following URL in the web browser: - -http://localhost:8000/ - -## Manual - -If you cannot access to localhost, instead open the following URL: - -https://keycloak.example.com/auth/realms/hello/protocol/openid-connect/auth?client_id=kubernetes&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob&response_type=code&scope=openid+email&state=******** - -Enter the code: -``` - -Open http://localhost:8000 in your browser. -If you cannot access to localhost, you can get the authorization code and enter it manually instead. - -Then, `kubelogin` will update your `~/.kube/config` with the ID token and refresh token. - -``` -2018/03/23 18:01:46 Exchanging code and token... -2018/03/23 18:01:46 Verifying ID token... -2018/03/23 18:01:46 You are logged in as foo@example.com (********) -2018/03/23 18:01:46 Updated /home/user/.kube/config +2018/08/10 10:36:38 Reading .kubeconfig +2018/08/10 10:36:38 Using current context: devops.hidetake.org +2018/08/10 10:36:41 Open http://localhost:8000 for authorization +2018/08/10 10:36:45 GET / +2018/08/10 10:37:07 GET /?state=...&session_state=...&code=ey... +2018/08/10 10:37:08 Updated .kubeconfig ``` Now your `~/.kube/config` looks like: @@ -99,9 +74,7 @@ This tutorial assumes you have created an OIDC client with the following: - Issuer URL: `https://keycloak.example.com/auth/realms/hello` - Client ID: `kubernetes` - Client Secret: `YOUR_CLIENT_SECRET` -- Allowed redirect URLs: - - `http://localhost:8000/` - - `urn:ietf:wg:oauth:2.0:oob` +- Allowed redirect URLs: `http://localhost:8000/` - Groups claim: `groups` (optional for group based access controll) ### 2. Setup Kubernetes API Server diff --git a/authn/authn.go b/authn/authn.go new file mode 100644 index 00000000..91705b5a --- /dev/null +++ b/authn/authn.go @@ -0,0 +1,61 @@ +package authn + +import ( + "context" + "fmt" + + oidc "github.com/coreos/go-oidc" + "github.com/int128/kubelogin/authz" + "golang.org/x/oauth2" +) + +// TokenSet is a set of tokens and claims. +type TokenSet struct { + IDToken string + RefreshToken string + Claims *Claims +} + +// Claims represents properties in the ID token. +type Claims struct { + Email string `json:"email"` +} + +// GetTokenSet retrieves a token from the OIDC provider. +func GetTokenSet(ctx context.Context, issuer string, clientID string, clientSecret string) (*TokenSet, error) { + provider, err := oidc.NewProvider(ctx, issuer) + if err != nil { + return nil, fmt.Errorf("Could not access OIDC issuer: %s", err) + } + flow := authz.BrowserAuthCodeFlow{ + Port: 8000, + Config: oauth2.Config{ + Endpoint: provider.Endpoint(), + ClientID: clientID, + ClientSecret: clientSecret, + Scopes: []string{oidc.ScopeOpenID, "email"}, + }, + } + token, err := flow.GetToken(ctx) + if err != nil { + return nil, fmt.Errorf("Could not get a token: %s", err) + } + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + return nil, fmt.Errorf("id_token is missing in the token response: %s", token) + } + verifier := provider.Verifier(&oidc.Config{ClientID: clientID}) + idToken, err := verifier.Verify(ctx, rawIDToken) + if err != nil { + return nil, fmt.Errorf("Could not verify the id_token: %s", err) + } + var claims Claims + if err := idToken.Claims(&claims); err != nil { + return nil, fmt.Errorf("Could not extract claims from the token response: %s", err) + } + return &TokenSet{ + IDToken: rawIDToken, + RefreshToken: token.RefreshToken, + Claims: &claims, + }, nil +} diff --git a/authz/authz.go b/authz/authz.go new file mode 100644 index 00000000..b9279eb9 --- /dev/null +++ b/authz/authz.go @@ -0,0 +1,12 @@ +package authz + +import ( + "context" + + "golang.org/x/oauth2" +) + +// Flow represents an authorization method. +type Flow interface { + GetToken(context.Context) (*oauth2.Token, error) +} diff --git a/authz/browser.go b/authz/browser.go new file mode 100644 index 00000000..29a9f0b0 --- /dev/null +++ b/authz/browser.go @@ -0,0 +1,97 @@ +package authz + +import ( + "context" + "fmt" + "log" + "net/http" + + "golang.org/x/oauth2" +) + +// BrowserAuthCodeFlow is a flow to get a token by browser interaction. +type BrowserAuthCodeFlow struct { + oauth2.Config + Port int // HTTP server port +} + +// GetToken returns a token. +func (f *BrowserAuthCodeFlow) GetToken(ctx context.Context) (*oauth2.Token, error) { + f.Config.RedirectURL = fmt.Sprintf("http://localhost:%d/", f.Port) + state, err := generateOAuthState() + if err != nil { + return nil, err + } + log.Printf("Open http://localhost:%d for authorization", f.Port) + code, err := f.getCode(ctx, &f.Config, state) + if err != nil { + return nil, err + } + token, err := f.Config.Exchange(ctx, code) + if err != nil { + return nil, fmt.Errorf("Could not exchange oauth code: %s", err) + } + return token, nil +} + +func (f *BrowserAuthCodeFlow) getCode(ctx context.Context, config *oauth2.Config, state string) (string, error) { + codeCh := make(chan string) + errCh := make(chan error) + server := http.Server{ + Addr: fmt.Sprintf(":%d", f.Port), + Handler: &handler{ + AuthCodeURL: config.AuthCodeURL(state), + Callback: func(code string, actualState string, err error) { + switch { + case err != nil: + errCh <- err + case actualState != state: + errCh <- fmt.Errorf("OAuth state did not match, should be %s but %s", state, actualState) + default: + codeCh <- code + } + }, + }, + } + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errCh <- err + } + }() + select { + case err := <-errCh: + server.Shutdown(ctx) + return "", err + case code := <-codeCh: + server.Shutdown(ctx) + return code, nil + } +} + +type handler struct { + AuthCodeURL string + Callback func(code string, state string, err error) +} + +func (s *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + log.Printf("%s %s", r.Method, r.RequestURI) + switch r.URL.Path { + case "/": + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + errorCode := r.URL.Query().Get("error") + errorDescription := r.URL.Query().Get("error_description") + switch { + case code != "": + s.Callback(code, state, nil) + fmt.Fprintf(w, "Back to command line.") + case errorCode != "": + s.Callback("", "", fmt.Errorf("OAuth Error: %s %s", errorCode, errorDescription)) + fmt.Fprintf(w, "Back to command line.") + default: + http.Redirect(w, r, s.AuthCodeURL, 302) + } + default: + http.Error(w, "Not Found", 404) + } +} diff --git a/authz/cli.go b/authz/cli.go new file mode 100644 index 00000000..4c56dead --- /dev/null +++ b/authz/cli.go @@ -0,0 +1,35 @@ +package authz + +import ( + "context" + "fmt" + "log" + + "golang.org/x/oauth2" +) + +// CLIAuthCodeFlow is a flow to get a token by keyboard interaction. +type CLIAuthCodeFlow struct { + oauth2.Config +} + +// GetToken returns a token by browser interaction. +func (f *CLIAuthCodeFlow) GetToken(ctx context.Context) (*oauth2.Token, error) { + f.Config.RedirectURL = "urn:ietf:wg:oauth:2.0:oob" + state, err := generateOAuthState() + if err != nil { + return nil, err + } + authCodeURL := f.Config.AuthCodeURL(state) + log.Printf("Open %s for authorization", authCodeURL) + fmt.Print("Enter code: ") + var code string + if _, err := fmt.Scanln(&code); err != nil { + return nil, err + } + token, err := f.Config.Exchange(ctx, code) + if err != nil { + return nil, fmt.Errorf("Could not exchange oauth code: %s", err) + } + return token, nil +} diff --git a/authz/state.go b/authz/state.go new file mode 100644 index 00000000..548f10e5 --- /dev/null +++ b/authz/state.go @@ -0,0 +1,15 @@ +package authz + +import ( + "crypto/rand" + "encoding/binary" + "fmt" +) + +func generateOAuthState() (string, error) { + var n uint64 + if err := binary.Read(rand.Reader, binary.LittleEndian, &n); err != nil { + return "", err + } + return fmt.Sprintf("%x", n), nil +} diff --git a/main.go b/main.go index 95a9075e..419b3cd0 100644 --- a/main.go +++ b/main.go @@ -1,8 +1,10 @@ package main import ( + "context" "log" + "github.com/int128/kubelogin/authn" "github.com/int128/kubelogin/kubeconfig" ) @@ -16,22 +18,24 @@ func main() { if err != nil { log.Fatalf("Could not load kubeconfig: %s", err) } - log.Printf("Using current-context: %s", cfg.CurrentContext) + log.Printf("Using current context: %s", cfg.CurrentContext) authInfo := kubeconfig.FindCurrentAuthInfo(cfg) if authInfo == nil { - log.Fatalf("Could not find the current-context: %s", cfg.CurrentContext) + log.Fatalf("Could not find current context: %s", cfg.CurrentContext) } - provider, err := kubeconfig.ToOIDCAuthProviderConfig(authInfo) + authProvider, err := kubeconfig.ToOIDCAuthProviderConfig(authInfo) if err != nil { - log.Fatalf("Could not find the OIDC auth-provider: %s", err) + log.Fatalf("Could not find auth-provider: %s", err) } - token, err := GetOIDCToken(provider.IDPIssuerURL(), provider.ClientID(), provider.ClientSecret()) + + ctx := context.Background() + token, err := authn.GetTokenSet(ctx, authProvider.IDPIssuerURL(), authProvider.ClientID(), authProvider.ClientSecret()) if err != nil { - log.Fatalf("OIDC authentication error: %s", err) + log.Fatalf("Authentication error: %s", err) } - provider.SetIDToken(token.IDToken) - provider.SetRefreshToken(token.RefreshToken) + authProvider.SetIDToken(token.IDToken) + authProvider.SetRefreshToken(token.RefreshToken) kubeconfig.Write(cfg, path) log.Printf("Updated %s", path) } diff --git a/oidc.go b/oidc.go deleted file mode 100644 index c22fdf0e..00000000 --- a/oidc.go +++ /dev/null @@ -1,130 +0,0 @@ -package main - -import ( - "context" - "crypto/rand" - "encoding/binary" - "fmt" - "log" - - "github.com/coreos/go-oidc" - "golang.org/x/oauth2" -) - -// OIDCToken is a token set -type OIDCToken struct { - IDToken string - RefreshToken string -} - -// GetOIDCToken returns a token retrieved by auth code grant -func GetOIDCToken(issuer string, clientID string, clientSecret string) (*OIDCToken, error) { - port := 8000 - provider, err := oidc.NewProvider(oauth2.NoContext, issuer) - if err != nil { - return nil, err - } - - state, err := generateState() - if err != nil { - return nil, err - } - - webBrowserConfig := oauth2.Config{ - ClientID: clientID, - ClientSecret: clientSecret, - RedirectURL: fmt.Sprintf("http://localhost:%d/", port), - Endpoint: provider.Endpoint(), - Scopes: []string{oidc.ScopeOpenID, "email"}, - } - - cliConfig := oauth2.Config{ - ClientID: clientID, - ClientSecret: clientSecret, - RedirectURL: "urn:ietf:wg:oauth:2.0:oob", - Endpoint: provider.Endpoint(), - Scopes: []string{oidc.ScopeOpenID, "email"}, - } - - showInstructionToGetToken(webBrowserConfig.RedirectURL, cliConfig.AuthCodeURL(state)) - token, err := getTokenByWebBrowserOrCLI(webBrowserConfig, cliConfig, state) - if err != nil { - return nil, err - } - - rawIDToken, ok := token.Extra("id_token").(string) - if !ok { - return nil, fmt.Errorf("id_token is missing in the token response: %s", token) - } - - log.Printf("Verifying ID token...") - verifier := provider.Verifier(&oidc.Config{ClientID: clientID}) - idToken, err := verifier.Verify(oauth2.NoContext, rawIDToken) - if err != nil { - return nil, err - } - - idTokenClaim := struct { - Email string `json:"email"` - }{} - if err := idToken.Claims(&idTokenClaim); err != nil { - return nil, err - } - - log.Printf("You are logged in as %s (%s)", idTokenClaim.Email, idToken.Subject) - return &OIDCToken{ - IDToken: rawIDToken, - RefreshToken: token.RefreshToken, - }, nil -} - -func getTokenByWebBrowserOrCLI(webBrowserConfig oauth2.Config, cliConfig oauth2.Config, state string) (*oauth2.Token, error) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - webBrowserAuthCodeCh := make(chan string) - cliAuthCodeCh := make(chan string) - errCh := make(chan error) - - go ReceiveAuthCodeFromWebBrowser(ctx, webBrowserConfig.AuthCodeURL(state), state, webBrowserAuthCodeCh, errCh) - go ReceiveAuthCodeFromCLI(ctx, cliAuthCodeCh, errCh) - - select { - case err := <-errCh: - return nil, err - - case authCode := <-webBrowserAuthCodeCh: - log.Printf("Exchanging code and token...") - return webBrowserConfig.Exchange(ctx, authCode) - - case authCode := <-cliAuthCodeCh: - log.Printf("Exchanging code and token...") - return cliConfig.Exchange(ctx, authCode) - } -} - -func showInstructionToGetToken(localhostURL string, cliAuthCodeURL string) { - log.Printf("Starting OpenID Connect authentication:") - fmt.Printf(` -## Automatic (recommended) - -Open the following URL in the web browser: - -%s - -## Manual - -If you cannot access to localhost, instead open the following URL: - -%s - -Enter the code: `, localhostURL, cliAuthCodeURL) -} - -func generateState() (string, error) { - var n uint64 - if err := binary.Read(rand.Reader, binary.LittleEndian, &n); err != nil { - return "", err - } - return fmt.Sprintf("%x", n), nil -} diff --git a/oidc_cli.go b/oidc_cli.go deleted file mode 100644 index acbe6904..00000000 --- a/oidc_cli.go +++ /dev/null @@ -1,16 +0,0 @@ -package main - -import ( - "context" - "fmt" -) - -// ReceiveAuthCodeFromCLI receives an auth code from CLI -func ReceiveAuthCodeFromCLI(ctx context.Context, authCodeCh chan<- string, errCh chan<- error) { - var authCode string - if _, err := fmt.Scanln(&authCode); err != nil { - errCh <- err - } else { - authCodeCh <- authCode - } -} diff --git a/oidc_http.go b/oidc_http.go deleted file mode 100644 index 7b9a5d4a..00000000 --- a/oidc_http.go +++ /dev/null @@ -1,70 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - "net/http" -) - -// ReceiveAuthCodeFromWebBrowser starts a server and receives an auth code -func ReceiveAuthCodeFromWebBrowser(ctx context.Context, authCodeURL string, state string, authCodeCh chan<- string, errCh chan<- error) { - server := http.Server{ - Addr: ":8000", - Handler: &AuthCodeGrantHandler{ - AuthCodeURL: authCodeURL, - State: state, - Resolve: func(authCode string) { authCodeCh <- authCode }, - Reject: func(err error) { errCh <- err }, - }, - } - - go func() { - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - errCh <- err - } - }() - - select { - case <-ctx.Done(): - server.Shutdown(context.Background()) - } -} - -// AuthCodeGrantHandler handles requests for OIDC auth code grant -type AuthCodeGrantHandler struct { - AuthCodeURL string - State string - Resolve func(string) - Reject func(error) -} - -func (s *AuthCodeGrantHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - log.Printf("%s %s", r.Method, r.RequestURI) - switch r.URL.Path { - case "/": - code := r.URL.Query().Get("code") - state := r.URL.Query().Get("state") - errorCode := r.URL.Query().Get("error") - errorDescription := r.URL.Query().Get("error_description") - switch { - case code != "" && state == s.State: - s.Resolve(code) - fmt.Fprintf(w, "Please back to the command line.") - - case code != "" && state != s.State: - s.Reject(fmt.Errorf("OIDC state did not match. expected=%s, actual=%s", s.State, state)) - fmt.Fprintf(w, "Please back to the command line.") - - case errorCode != "": - s.Reject(fmt.Errorf("OIDC error: %s %s", errorCode, errorDescription)) - fmt.Fprintf(w, "Please back to the command line.") - - default: - http.Redirect(w, r, s.AuthCodeURL, 302) - } - - default: - http.Error(w, "Not Found", 404) - } -}