Skip to content

Commit

Permalink
tokenrequest: add support for authorization code flow with proof key …
Browse files Browse the repository at this point in the history
…for code exchange
  • Loading branch information
liouk committed May 23, 2023
1 parent 9536341 commit 81eaca5
Show file tree
Hide file tree
Showing 3 changed files with 505 additions and 16 deletions.
126 changes: 126 additions & 0 deletions pkg/oauth/tokenrequest/callback_server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package tokenrequest

import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strconv"

"k8s.io/klog/v2"
)

type callbackResult struct {
token string
err error
}

type callbackHandlerFunc func(*http.Request) (string, error)

type callbackServer struct {
server *http.Server
tcpListener net.Listener
listenAddr *net.TCPAddr

// the channel to deliver the access token or error
resultChan chan callbackResult

// function to call when the callback redirect is received; this should be
// used to request an access token based on the received callback
callbackHandler callbackHandlerFunc
}

func newCallbackServer(port int) (*callbackServer, error) {
callbackServer := &callbackServer{
resultChan: make(chan callbackResult, 1),
}

loopbackAddr, err := getLoopbackAddr()
if err != nil {
return nil, err
}

callbackServer.tcpListener, err = net.Listen("tcp", net.JoinHostPort(loopbackAddr, strconv.Itoa(port)))
if err != nil {
return nil, err
}

listenAddr, ok := callbackServer.tcpListener.Addr().(*net.TCPAddr)
if !ok {
return nil, fmt.Errorf("listener is not of TCP type: %v", callbackServer.tcpListener.Addr())
}
callbackServer.listenAddr = listenAddr

mux := http.NewServeMux()
mux.Handle("/callback", callbackServer)

callbackServer.server = &http.Server{
Handler: mux,
}

return callbackServer, nil
}

func (c *callbackServer) ListenAddr() *net.TCPAddr {
return c.listenAddr
}

func (c *callbackServer) SetCallbackHandler(callbackHandler callbackHandlerFunc) {
c.callbackHandler = callbackHandler
}

func (c *callbackServer) Start() {
err := c.server.Serve(c.tcpListener)
if err != nil && err != http.ErrServerClosed {
klog.V(4).Infof("callback server failed: %v", err)
c.resultChan <- callbackResult{err: err}
}

close(c.resultChan)
}

func (c *callbackServer) Shutdown(ctx context.Context) {
err := c.server.Shutdown(ctx)
if err != nil {
klog.V(4).Infof("failed to shutdown callback server: %v", err)
}
}

func (c *callbackServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if c.callbackHandler == nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("access token request failed; please return to your terminal"))
c.resultChan <- callbackResult{err: fmt.Errorf("no callback handler set")}
return
}

token, err := c.callbackHandler(r)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("access token request failed; please return to your terminal"))
c.resultChan <- callbackResult{err: err}
return
}

w.Write([]byte("access token received successfully; please return to your terminal"))
c.resultChan <- callbackResult{token: token}
}

// getLoopbackAddr returns the first address from the host's network interfaces which is a loopback address
func getLoopbackAddr() (string, error) {
interfaces, err := net.InterfaceAddrs()
if err != nil {
return "", err
}

for _, iface := range interfaces {
if ipaddr, ok := iface.(*net.IPNet); ok {
if ipaddr.IP.IsLoopback() {
return ipaddr.IP.String(), nil
}
}
}

return "", errors.New("no loopback network interfaces found")
}
118 changes: 103 additions & 15 deletions pkg/oauth/tokenrequest/request_token.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package tokenrequest

import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
Expand Down Expand Up @@ -39,6 +40,9 @@ const (
// openShiftCLIClientID is the name of the CLI OAuth client, copied from pkg/oauth/apiserver/auth.go
openShiftCLIClientID = "openshift-challenging-client"

// openShiftCLIBrowserClientID the name of the CLI client for logging in through a browser
openShiftCLIBrowserClientID = "openshift-cli-client"

// pkce_s256 is sha256 hash per RFC7636, copied from github.com/RangelReale/osincli/pkce.go
pkce_s256 = "S256"

Expand All @@ -55,12 +59,44 @@ type RequestTokenOptions struct {
OsinConfig *osincli.ClientConfig
Issuer string
TokenFlow bool

AuthorizationURLHandler AuthorizationURLHandlerFunc
LocalCallbackServer *callbackServer
}

// AuthorizationURLHandlerFunc defines how the authorization URL of the OAuth Code Grant flow
// should be handled; for example use this function to take the URL and open it in a browser.
type AuthorizationURLHandlerFunc func(url *url.URL) error

// RequestToken uses the cmd arguments to locate an openshift oauth server and attempts to authenticate via an
// OAuth code flow and challenge handling. It returns the access token if it gets one or an error if it does not.
func RequestToken(clientCfg *restclient.Config, challengeHandlers ...challengehandlers.ChallengeHandler) (string, error) {
return NewRequestTokenOptions(clientCfg, false, challengeHandlers...).RequestToken()
o := NewRequestTokenOptions(clientCfg, false, challengeHandlers...)
if err := o.SetDefaultOsinConfig(openShiftCLIClientID, nil); err != nil {
return "", err
}

return o.RequestToken()
}

// RequestTokenWithAuthorization uses the OAuth Authorization Code Grant flow in order to obtain an authorization code
// and exchange it for an access token. It creates and starts a local HTTP server on the loopback address and the
// specified port, and it invokes the specified authorization handler func so that the user can define how the
// authorization URL should be handled (e.g. opened in a browser). The URL uses a suitable OAuth client with a
// redirect to the local HTTP server which contains the authorization request that can be exchanged for an
// access token. If the port specified is 0, a random available port will be used.
func RequestTokenWithAuthorization(clientCfg *restclient.Config, authzUrlHandler AuthorizationURLHandlerFunc, callbackPort int) (string, error) {
o, err := NewRequestTokenOptions(clientCfg, false).WithLocalCallback(authzUrlHandler, callbackPort)
if err != nil {
return "", err
}

redirectUrl := fmt.Sprintf("http://%s/callback", o.LocalCallbackServer.ListenAddr())
if err := o.SetDefaultOsinConfig(openShiftCLIBrowserClientID, &redirectUrl); err != nil {
return "", err
}

return o.RequestToken()
}

func NewRequestTokenOptions(
Expand All @@ -83,9 +119,22 @@ func NewRequestTokenOptions(
}
}

// WithLocalCallback sets up the RequestTokenOptions with an AuthorizationURLHanderFunc and an
// unstarted local callback server on the specified port.
func (o *RequestTokenOptions) WithLocalCallback(handleAuthzURL AuthorizationURLHandlerFunc, localCallbackPort int) (*RequestTokenOptions, error) {
var err error
o.AuthorizationURLHandler = handleAuthzURL
o.LocalCallbackServer, err = newCallbackServer(localCallbackPort)
if err != nil {
return nil, err
}

return o, nil
}

// SetDefaultOsinConfig overwrites RequestTokenOptions.OsinConfig with the default CLI
// OAuth client and PKCE support if the server supports S256 / a code flow is being used
func (o *RequestTokenOptions) SetDefaultOsinConfig() error {
func (o *RequestTokenOptions) SetDefaultOsinConfig(clientId string, redirectUrl *string) error {
if o.OsinConfig != nil {
return fmt.Errorf("osin config is already set to: %#v", *o.OsinConfig)
}
Expand Down Expand Up @@ -115,11 +164,16 @@ func (o *RequestTokenOptions) SetDefaultOsinConfig() error {

// use the metadata to build the osin config
config := &osincli.ClientConfig{
ClientId: openShiftCLIClientID,
ClientId: clientId,
AuthorizeUrl: metadata.AuthorizationEndpoint,
TokenUrl: metadata.TokenEndpoint,
RedirectUrl: oauthdiscovery.OpenShiftOAuthTokenImplicitURL(metadata.Issuer),
}

if redirectUrl != nil {
config.RedirectUrl = *redirectUrl
}

if !o.TokenFlow && sets.NewString(metadata.CodeChallengeMethodsSupported...).Has(pkce_s256) {
if err := osincli.PopulatePKCE(config); err != nil {
return err
Expand All @@ -139,19 +193,15 @@ func (o *RequestTokenOptions) SetDefaultOsinConfig() error {
// The caller is responsible for setting up the entire OsinConfig if the value is not nil.
func (o *RequestTokenOptions) RequestToken() (string, error) {
defer func() {
// Always release the handler
if err := o.Handler.Release(); err != nil {
// Release errors shouldn't fail the token request, just log
klog.V(4).Infof("error releasing handler: %v", err)
if o.Handler != nil {
// Always release the handler
if err := o.Handler.Release(); err != nil {
// Release errors shouldn't fail the token request, just log
klog.V(4).Infof("error releasing handler: %v", err)
}
}
}()

if o.OsinConfig == nil {
if err := o.SetDefaultOsinConfig(); err != nil {
return "", err
}
}

// we are going to use this transport to talk
// with a server that may not be the api server
// thus we need to include the system roots
Expand All @@ -168,8 +218,14 @@ func (o *RequestTokenOptions) RequestToken() (string, error) {
return "", err
}
client.Transport = rt

authorizeRequest := client.NewAuthorizeRequest(osincli.CODE) // assume code flow to start with

if o.LocalCallbackServer != nil {
// OAuth2 Authorization Code Grant flow via web browser
return browserFlow(o, client, authorizeRequest)
}

var oauthTokenFunc func(redirectURL string) (accessToken string, oauthError error)
if o.TokenFlow {
// access_token in fragment or error parameter
Expand All @@ -193,7 +249,7 @@ func (o *RequestTokenOptions) RequestToken() (string, error) {

for {
// Make the request
resp, err := request(rt, requestURL, requestHeaders)
resp, err := request(client.Transport, requestURL, requestHeaders)
if err != nil {
return "", err
}
Expand All @@ -212,7 +268,11 @@ func (o *RequestTokenOptions) RequestToken() (string, error) {
klog.V(2).Infof("%v", err)
tokenPromptErr.ErrStatus.Details = &metav1.StatusDetails{
Causes: []metav1.StatusCause{
{Message: fmt.Sprintf("You must obtain an API token by visiting %s/request", o.OsinConfig.TokenUrl)},
{Message: fmt.Sprintf(
"You must obtain an API token by visiting %s/request\n\n%s",
o.OsinConfig.TokenUrl,
`Alternatively, use "oc login --web" to login via your browser. See "oc login --help" for more information.`,
)},
},
}
return "", tokenPromptErr
Expand Down Expand Up @@ -332,6 +392,34 @@ func oauthCodeFlow(client *osincli.Client, authorizeRequest *osincli.AuthorizeRe
return "", nil // no code parameter so this is not part of the OAuth flow
}

return requestAccessToken(client, authorizeRequest, req)
}

func browserFlow(o *RequestTokenOptions, client *osincli.Client, authorizeRequest *osincli.AuthorizeRequest) (string, error) {

o.LocalCallbackServer.SetCallbackHandler(func(callback *http.Request) (string, error) {
// once the redirect callback is received, use it to request an access token
// from the oauth server
return requestAccessToken(client, authorizeRequest, callback)
})

go func() { o.LocalCallbackServer.Start() }()
defer func() { o.LocalCallbackServer.Shutdown(context.Background()) }()

if err := o.AuthorizationURLHandler(authorizeRequest.GetAuthorizeUrl()); err != nil {
return "", err
}

result := <-o.LocalCallbackServer.resultChan
if result.err != nil {
return "", result.err
}

return result.token, nil
}

func requestAccessToken(client *osincli.Client, authorizeRequest *osincli.AuthorizeRequest, req *http.Request) (string, error) {

// any errors after this are fatal because we are committed to an OAuth flow now
authorizeData, err := authorizeRequest.HandleRequest(req)
if err != nil {
Expand Down

0 comments on commit 81eaca5

Please sign in to comment.