{{.Title}}
{{.Message}}
// Package oauth implement an oauth client defined in e.g. rfc 6749 // However, we try to follow some recommendations from the v2.1 oauth draft RFC // Some specific things we implement here: // - PKCE (RFC 7636) package oauth import ( "context" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "html/template" "net" "net/http" "net/url" "sync" "time" httpw "github.com/eduvpn/eduvpn-common/internal/http" "github.com/eduvpn/eduvpn-common/internal/util" "github.com/go-errors/errors" ) // genState generates a random base64 string to be used for state // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-4.1.1 // "state": OPTIONAL. An opaque value used by the client to maintain // state between the request and callback. The authorization server // includes this value when redirecting the user agent back to the // client. // We implement it similarly to the verifier. func genState() (string, error) { bs, err := util.MakeRandomByteSlice(32) if err != nil { return "", err } // For consistency, we also use raw url encoding here return base64.RawURLEncoding.EncodeToString(bs), nil } // genChallengeS256 generates a sha256 base64 challenge from a verifier // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-7.8 func genChallengeS256(verifier string) string { hash := sha256.Sum256([]byte(verifier)) // We use raw url encoding as the challenge does not accept padding return base64.RawURLEncoding.EncodeToString(hash[:]) } // genVerifier generates a verifier // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-4.1.1 // The code_verifier is a unique high-entropy cryptographically random // string generated for each authorization request, using the unreserved // characters [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~", with a // minimum length of 43 characters and a maximum length of 128 // characters. // We implement it according to the note: // // NOTE: The code verifier SHOULD have enough entropy to make it // impractical to guess the value. It is RECOMMENDED that the output of // a suitable random number generator be used to create a 32-octet // sequence. The octet sequence is then base64url-encoded to produce a // 43-octet URL safe string to use as the code verifier. // // See: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 func genVerifier() (string, error) { random, err := util.MakeRandomByteSlice(32) if err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(random), nil } // OAuth defines the main structure for this package. type OAuth struct { // The cached client id so we don't have to pass it around ClientID string `json:"client_id"` // The HTTP client that is used httpClient *httpw.Client // BaseAuthorizationURL is the URL where authorization should take place BaseAuthorizationURL string `json:"base_authorization_url"` // TokenURL is the URL where tokens should be obtained TokenURL string `json:"token_url"` // session is the internal in progress OAuth session session exchangeSession // Token is where the access and refresh tokens are stored along with the timestamps // It is protected by a lock token *tokenLock } // exchangeSession is a structure that gets passed to the callback for easy access to the current state. type exchangeSession struct { // ClientID is the ID of the OAuth client ClientID string // State is the expected URL state parameter State string // Verifier is the preimage of the challenge Verifier string // Listener is the listener where the servers 'listens' on Listener net.Listener // ErrChan is used to send the error from the handler ErrChan chan error } // AccessToken gets the OAuth access token used for contacting the server API // It returns the access token as a string, possibly obtained fresh using the Refresh Token // If the token cannot be obtained, an error is returned and the token is an empty string. func (oauth *OAuth) AccessToken() (string, error) { tl := oauth.token if tl == nil { return "", errors.New("No token structure available") } return tl.Access() } // setupListener sets up an OAuth listener // If it was unsuccessful it returns an error. // @see https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-07.html#section-8.4.2 // "Loopback Interface Redirection". func (oauth *OAuth) setupListener() error { // create a listener lst, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return errors.WrapPrefix(err, "net.Listen failed", 0) } oauth.session.Listener = lst return nil } // tokensWithCallback gets the OAuth tokens using a local web server // If it was unsuccessful it returns an error. func (oauth *OAuth) tokensWithCallback() error { if oauth.session.Listener == nil { return errors.New("failed getting tokens with callback: no listener") } mux := http.NewServeMux() // server /callback over the listener address s := &http.Server{ Handler: mux, // Define a default 60 second header read timeout to protect against a Slowloris Attack // A bit overkill maybe for a local server but good to define anyways ReadHeaderTimeout: 60 * time.Second, } defer s.Shutdown(context.Background()) //nolint:errcheck // Use a sync.Once to only handle one request up until we shutdown the server var once sync.Once mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { once.Do(func() { oauth.Handler(w, r) }) }) go func() { if err := s.Serve(oauth.session.Listener); err != http.ErrServerClosed { oauth.session.ErrChan <- errors.WrapPrefix(err, "failed getting tokens with callback", 0) } }() return <-oauth.session.ErrChan } // tokenResponse fills the OAuth token response structure by the response // The URL that is input here is used for additional context // It returns this structure and an error if there is one func (oauth *OAuth) tokenResponse(response []byte, url string) (*TokenResponse, error) { if oauth.token == nil { return nil, errors.New("No oauth structure when filling token") } res := TokenResponse{} err := json.Unmarshal(response, &res) if err != nil { return nil, errors.WrapPrefix(err, "failed filling OAuth tokens from "+url, 0) } return &res, nil } // SetTokenExpired marks the tokens as expired by setting the expired timestamp to the current time. func (oauth *OAuth) SetTokenExpired() { if oauth.token != nil { oauth.token.SetExpired() } } // SetTokenRenew sets the tokens for renewal by completely clearing the structure. func (oauth *OAuth) SetTokenRenew() { if oauth.token != nil { oauth.token.Update(Token{}) } } func (oauth *OAuth) Token() Token { t := Token{} if oauth.token != nil { t = oauth.token.Get() } return t } // tokensWithAuthCode gets the access and refresh tokens using the authorization code // Access tokens: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-1.4 // Refresh tokens: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-1.3.2 // If it was unsuccessful it returns an error. func (oauth *OAuth) tokensWithAuthCode(authCode string) error { // Make sure the verifier is set as the parameter // so that the server can verify that we are the actual owner of the authorization code u := oauth.TokenURL port, err := oauth.ListenerPort() if err != nil { return err } data := url.Values{ "client_id": {oauth.session.ClientID}, "code": {authCode}, "code_verifier": {oauth.session.Verifier}, "grant_type": {"authorization_code"}, "redirect_uri": {fmt.Sprintf("http://127.0.0.1:%d/callback", port)}, } h := http.Header{ "content-type": {"application/x-www-form-urlencoded"}, } opts := &httpw.OptionalParams{Headers: h, Body: data} now := time.Now() // We are sure that we have a http client because we have initialized it when starting the exchange _, body, err := oauth.httpClient.PostWithOpts(u, opts) if err != nil { return err } tr, err := oauth.tokenResponse(body, u) if err != nil { return err } if tr == nil { return errors.New("No token response after authorization code") } oauth.token.UpdateResponse(*tr, now) return nil } func (oauth *OAuth) UpdateTokens(t Token) { if oauth.token == nil { oauth.token = &tokenLock{t: &tokenRefresher{Refresher: oauth.refreshResponse}} } oauth.token.Update(t) } // refreshResponse gets the refresh token response with a refresh token // This response contains the access and refresh tokens, together with a timestamp // Access tokens: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-1.4 // Refresh tokens: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-04#section-1.3.2 // If it was unsuccessful it returns an error. func (oauth *OAuth) refreshResponse(r string) (*TokenResponse, time.Time, error) { u := oauth.TokenURL if oauth.token == nil { return nil, time.Time{}, errors.New("No oauth token structure in refresh") } if oauth.ClientID == "" { return nil, time.Time{}, errors.New("No client ID was cached for refresh") } data := url.Values{ "client_id": {oauth.ClientID}, "refresh_token": {r}, "grant_type": {"refresh_token"}, } h := http.Header{ "content-type": {"application/x-www-form-urlencoded"}, } opts := &httpw.OptionalParams{Headers: h, Body: data} now := time.Now() // Test if we have a http client and if not recreate one if oauth.httpClient == nil { oauth.httpClient = httpw.NewClient() } _, body, err := oauth.httpClient.PostWithOpts(u, opts) if err != nil { return nil, time.Time{}, err } tr, err := oauth.tokenResponse(body, u) return tr, now, err } // responseTemplate is the HTML template for the OAuth authorized response // this template was dapted from: https://github.com/eduvpn/apple/blob/5b18f834be7aebfed00570ae0c2f7bcbaf1c69cc/EduVPN/Helpers/Mac/OAuthRedirectHTTPHandler.m#L25 const responseTemplate string = `
{{.Message}}