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

Feature request: PKCE #603

Closed
tonimelisma opened this issue Oct 31, 2022 · 9 comments
Closed

Feature request: PKCE #603

tonimelisma opened this issue Oct 31, 2022 · 9 comments

Comments

@tonimelisma
Copy link

Many OAuth providers no longer support many flows without PKCE, yet the oauth2 library doesn't have any built-in support. Generating code code verifiers and challenges must be done with a third party library, both need to be attached via unspecified Auth URL Parameters, etc.

Would there be interest in merging a PR that provided

  • Functions to generate code verifiers and challenges
  • Included them in authentication and token exchange flows as part of standard data structures with clear PKCE semantics
  • Included working example code in documentation for above
@project0
Copy link

project0 commented Nov 9, 2022

I just stumbled across this while looking for a golang package :-). Does someone know a existing library what can be used in the meantime?

@tonimelisma
Copy link
Author

You can use https://github.com/nirasan/go-oauth-pkce-code-verifier for the PKCE part but you'll need to mash it up manually with this oauth2 library, which is mainly unmaintained nowadays.

@hickford
Copy link
Contributor

hickford commented Nov 10, 2022

Workaround: https://pkg.go.dev/golang.org/x/oauth2/authhandler#TokenSourceWithPKCE does some of the work, although you still need to generate the challenge and verifier:

func randomString(n int) string {
	data := make([]byte, n)
	if _, err := io.ReadFull(rand.Reader, data); err != nil {
		panic(err)
	}
	return base64.StdEncoding.EncodeToString(data)
}

func generatePKCEParams() *authhandler.PKCEParams {
	verifier := randomString(32)
	sha := sha256.Sum256([]byte(verifier))
	challenge := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(sha[:])
	return &authhandler.PKCEParams{
		Challenge:       challenge,
		ChallengeMethod: "S256",
		Verifier:        verifier,
	}
}

@hickford
Copy link
Contributor

Proof Key for Code Exchange by OAuth Public Clients (RFC 7636) is important to use OAuth securely, especially on public clients. OAuth 2.1 makes code_challenge and code required by default (dropping the PKCE name) https://datatracker.ietf.org/doc/id/draft-ietf-oauth-v2-1-00.html#section-9.8

The properties code_challenge and code_verifier are adopted from the OAuth 2.0 extension known as "Proof-Key for Code Exchange", or PKCE ([RFC7636]) where this technique was originally developed.

Clients MUST use code_challenge and code_verifier and authorization servers MUST enforce their use except under the conditions described in Section 7.6. In this case, using and enforcing code_challenge and code_verifier as described in the following is still RECOMMENDED.

However oauth2 module doesn't give you any help to use PKCE making it easy to get wrong.

@hickford
Copy link
Contributor

hickford commented Feb 8, 2023

Perhaps because the API is awkward, few users appear to be using PKCE:

Only 334 files found on GitHub search https://github.com/search?q=lang%3Ago+SetAuthURLParam+code_challenge&type=code

@atamgp
Copy link

atamgp commented Mar 8, 2023

There is a nice example here, credits to the writer:
https://chrisguitarguy.com/2022/12/07/oauth-pkce-with-go/

@hickford
Copy link
Contributor

Comments welcome on proposal golang/go#59835

@pears-one
Copy link

For anyone having issues with the workaround here - you should use base64.UrlEncoding for generating the code verifier.

func randomString(n int) string {
	data := make([]byte, n)
	if _, err := io.ReadFull(rand.Reader, data); err != nil {
		panic(err)
	}
	return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data)
} 

@Integralist
Copy link

Integralist commented Sep 4, 2023

I ended up modifying this:
https://gist.github.com/ogazitt/f749dad9cca8d0ac6607f93a42adf322 example

It worked fine for the most part but I needed to tweak a bunch of things and change dependencies.

Next (and not included in the below example) I'm going to add refresh token behaviour (which was missing from the OP's gist) using information I found here.

Below is my own example code which I've redacted a bunch of work-related stuff from, but in essence, it uses github.com/hashicorp/cap/oidc and github.com/hashicorp/cap/jwt...

package authenticate

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"
	"time"

	"github.com/hashicorp/cap/jwt"
	"github.com/hashicorp/cap/oidc"
	"github.com/skratchdot/open-golang/open"
)

// RootCommand is the parent command for all subcommands in this package.
// It should be installed under the primary root command.
type RootCommand struct {
	cmd.Base
}

// AuthRemediation is a generic remediation message for an error authorizing.
const AuthRemediation = "Please re-run the command. If the problem persists, please file an issue: https://github.com/<whatever>/cli/issues/new?labels=bug&template=bug_report.md"

// AuthProviderCLIAppURL is the auth provider's device code URL.
const AuthProviderCLIAppURL = "https://whatever.example.com"

// AuthProviderClientID is the auth provider's Client ID.
const AuthProviderClientID = "my-app"

// AuthProviderAudience is the unique identifier of the API your app wants to access.
const AuthProviderAudience = "https://api.example.com/"

// AuthProviderRedirectURL is the endpoint the auth provider will pass an authorization code to.
const AuthProviderRedirectURL = "http://localhost:8080/callback"

// NewRootCommand returns a new command registered in the parent.
func NewRootCommand(parent cmd.Registerer, g *global.Data) *RootCommand {
	var c RootCommand
	c.Globals = g
	c.CmdClause = parent.Command("authenticate", "Authenticate with ACME")
	return &c
}

// Exec implements the command interface.
func (c *RootCommand) Exec(_ io.Reader, out io.Writer) error {
	verifier, err := oidc.NewCodeVerifier()
	if err != nil {
		return fsterr.RemediationError{
			Inner:       fmt.Errorf("failed to generate a code verifier: %w", err),
			Remediation: AuthRemediation,
		}
	}

	result := make(chan authorizationResult)

	s := server{
		result:   result,
		router:   http.NewServeMux(),
		verifier: verifier,
	}
	s.routes()

	var serverErr error

	go func() {
		err := s.startServer()
		if err != nil {
			serverErr = err
		}
	}()

	if serverErr != nil {
		return serverErr
	}

	text.Info(out, "Starting localhost server to handle the authentication flow.")

	authorizationURL, err := generateAuthorizationURL(verifier)
	if err != nil {
		return fsterr.RemediationError{
			Inner:       fmt.Errorf("failed to generate an authorization URL: %w", err),
			Remediation: AuthRemediation,
		}
	}

	text.Break(out)
	text.Description(out, "We're opening the following URL in your default web browser so you may authenticate with ACME", authorizationURL)

	err = open.Run(authorizationURL)
	if err != nil {
		return fmt.Errorf("failed to open your default browser: %w", err)
	}

	ar := <-result
	if ar.err != nil || ar.sessionToken == "" {
		return fsterr.RemediationError{
			Inner:       fmt.Errorf("failed to authorize: %w", ar.err),
			Remediation: AuthRemediation,
		}
	}

	text.Success(out, "Session token (persisted to your local configuration): %s", ar.sessionToken)

	return nil
}

type server struct {
	result   chan authorizationResult
	router   *http.ServeMux
	verifier *oidc.S256Verifier
}

func (s *server) startServer() error {
	server := &http.Server{
		Addr:         ":8080",
		Handler:      s.router,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 10 * time.Second,
	}

	err := server.ListenAndServe()
	if err != nil {
		return fsterr.RemediationError{
			Inner:       fmt.Errorf("failed to start local server: %w", err),
			Remediation: AuthRemediation,
		}
	}
	return nil
}

func (s *server) routes() {
	s.router.HandleFunc("/callback", s.handleCallback())
}

func (s *server) handleCallback() http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		authorizationCode := r.URL.Query().Get("code")
		if authorizationCode == "" {
			fmt.Fprint(w, "ERROR: no authorization code returned\n")
			s.result <- authorizationResult{
				err: fmt.Errorf("no authorization code returned"),
			}
			return
		}

		// Exchange the authorization code and the code verifier for a JWT.
		// NOTE: I use the identifier `j` to avoid overlap with the `jwt` package.
		codeVerifier := s.verifier.Verifier()
		j, err := getJWT(codeVerifier, authorizationCode)
		if err != nil || j.AccessToken == "" || j.IDToken == "" {
			fmt.Fprint(w, "ERROR: failed to exchange code for JWT\n")
			s.result <- authorizationResult{
				err: fmt.Errorf("failed to exchange code for JWT"),
			}
			return
		}

		claims, err := verifyJWTSignature(j.AccessToken)
		if err != nil {
			s.result <- authorizationResult{
				err: err,
			}
			return
		}

		fmt.Printf("jwt: %+v\n\n", j)
		fmt.Printf("claims: %+v\n\n", claims)

		sessionToken, err := extractSessionToken(claims)
		if err != nil {
			s.result <- authorizationResult{
				err: err,
			}
			return
		}

		fmt.Fprint(w, "Authenticated successfully. Please close this page and return to the CLI in your terminal.")
		s.result <- authorizationResult{
			jwt:          j,
			sessionToken: sessionToken,
		}
	}
}

type authorizationResult struct {
	err          error
	jwt          JWT
	sessionToken string
}

func generateAuthorizationURL(verifier *oidc.S256Verifier) (string, error) {
	challenge, err := oidc.CreateCodeChallenge(verifier)
	if err != nil {
		return "", err
	}

	authorizationURL := fmt.Sprintf(
		"%s/realms/<ACME>/protocol/openid-connect/auth?audience=%s"+
			"&scope=openid"+
			"&response_type=code&client_id=%s"+
			"&code_challenge=%s"+
			"&code_challenge_method=S256&redirect_uri=%s",
		AuthProviderCLIAppURL, AuthProviderAudience, AuthProviderClientID, challenge, AuthProviderRedirectURL)

	return authorizationURL, nil
}

func getJWT(codeVerifier, authorizationCode string) (JWT, error) {
	path := "/realms/<ACME>/protocol/openid-connect/token"

	payload := fmt.Sprintf(
		"grant_type=authorization_code&client_id=%s&code_verifier=%s&code=%s&redirect_uri=%s",
		AuthProviderClientID,
		codeVerifier,
		authorizationCode,
		"http://localhost:8080/callback", // NOTE: not redirected to, just a security check.
	)

	req, err := http.NewRequest("POST", AuthProviderCLIAppURL+path, strings.NewReader(payload))
	if err != nil {
		return JWT{}, err
	}

	req.Header.Add("content-type", "application/x-www-form-urlencoded")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return JWT{}, err
	}
	defer res.Body.Close()

	if res.StatusCode != http.StatusOK {
		return JWT{}, fmt.Errorf("failed to exchange code for jwt (status: %s)", res.Status)
	}

	body, err := io.ReadAll(res.Body)
	if err != nil {
		return JWT{}, err
	}

	fmt.Printf("body: %#v\n\n", string(body))

	var j JWT
	err = json.Unmarshal(body, &j)
	if err != nil {
		return JWT{}, err
	}

	fmt.Printf("j: %#v\n\n", j)

	return j, nil
}

// JWT is the API response for a Token request.
//
// Access Token typically has a TTL of 5mins.
// Refresh Token typically has a TTL of 30mins.
type JWT struct {
	// AccessToken can be exchanged for an ACME API token.
	AccessToken string `json:"access_token"`
	// ExpiresIn indicates the lifetime (in seconds) of the access token.
	ExpiresIn int `json:"expires_in"`
	// IDToken contains user information that must be decoded and extracted.
	IDToken string `json:"id_token"`
	// RefreshExpiresIn indicates the lifetime (in seconds) of the refresh token.
	RefreshExpiresIn int `json:"refresh_expires_in"`
	// RefreshToken contains a token used to refresh the issued access token.
	RefreshToken string `json:"refresh_token"`
	// TokenType indicates which HTTP authentication scheme is used (e.g. Bearer).
	TokenType string `json:"token_type"`
}

func verifyJWTSignature(token string) (claims map[string]any, err error) {
	ctx := context.Background()
	path := "/realms/<ACME>/protocol/openid-connect/certs"

	// NOTE: The last argument is optional and is for validating the JWKs endpoint
	// (which we don't need to do, so we pass an empty string)
	keySet, err := jwt.NewJSONWebKeySet(ctx, AuthProviderCLIAppURL+path, "")
	if err != nil {
		return claims, fmt.Errorf("failed to verify signature of access token: %w", err)
	}

	claims, err = keySet.VerifySignature(ctx, token)
	if err != nil {
		return nil, fmt.Errorf("failed to verify signature of access token: %w", err)
	}

	return claims, nil
}

// This is specific to my work's setup and will be replaced with a separate API call to exchange our access token for a session token. But at this point you'll likely be doing something different so YMMV.
func extractSessionToken(claims map[string]any) (string, error) {
	if i, ok := claims["legacy_session_token"]; ok {
		if t, ok := i.(string); ok {
			if t != "" {
				return t, nil
			}
		}
	}
	return "", fmt.Errorf("failed to extract session token from JWT custom claim")
}

jbrichetto pushed a commit to openly-engineering/oauth2 that referenced this issue May 22, 2024
Fixes golang#603

Fixes golang/go#59835

Change-Id: Ica0cfef975ba9511e00f097498d33ba27dafca0d
GitHub-Last-Rev: f01f759
GitHub-Pull-Request: golang#625
Reviewed-on: https://go-review.googlesource.com/c/oauth2/+/463979
Reviewed-by: Cherry Mui <cherryyz@google.com>
Run-TryBot: Matt Hickford <matt.hickford@gmail.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Roland Shoemaker <roland@golang.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

6 participants