-
Notifications
You must be signed in to change notification settings - Fork 1k
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
Comments
I just stumbled across this while looking for a golang package :-). Does someone know a existing library what can be used in the meantime? |
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. |
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,
}
} |
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
However oauth2 module doesn't give you any help to use PKCE making it easy to get wrong. |
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 |
There is a nice example here, credits to the writer: |
Comments welcome on proposal golang/go#59835 |
For anyone having issues with the workaround here - you should use 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)
} |
I ended up modifying this: 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 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")
} |
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 <[email protected]> Run-TryBot: Matt Hickford <[email protected]> TryBot-Result: Gopher Robot <[email protected]> Reviewed-by: Roland Shoemaker <[email protected]>
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
The text was updated successfully, but these errors were encountered: