Skip to content

Instantly share code, notes, and snippets.

@Integralist
Last active April 6, 2023 13:35
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Integralist/76f8be7cd5bb6e75587d58146daf0ab5 to your computer and use it in GitHub Desktop.
Save Integralist/76f8be7cd5bb6e75587d58146daf0ab5 to your computer and use it in GitHub Desktop.
[CLI PKCE with Auth0 or KeyCloak (inc code examples + sequence diagram)] #auth #auth0 #pkce #cli #keycloak
// Demonstrated with a proof-of-concept developed for the Fastly CLI.
package authenticate
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/fastly/cli/pkg/cmd"
"github.com/fastly/cli/pkg/config"
fsterr "github.com/fastly/cli/pkg/errors"
"github.com/fastly/cli/pkg/text"
"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/fastly/cli/issues/new?labels=bug&template=bug_report.md"
// Auth0CLIAppURL is the Auth0 device code URL.
const Auth0CLIAppURL = "https://<YOUR_DOMAIN>.us.auth0.com"
// Auth0ClientID is the Auth0 Client ID.
const Auth0ClientID = "<CLIENT_ID>"
// Auth0Audience is the unique identifier of the API your app wants to access.
const Auth0Audience = "https://<YOUR_API>/"
// Auth0RedirectURL is the endpoint Auth0 will pass an authorization code to.
const Auth0RedirectURL = "http://localhost:8080/callback"
// NewRootCommand returns a new command registered in the parent.
func NewRootCommand(parent cmd.Registerer, globals *config.Data) *RootCommand {
var c RootCommand
c.Globals = globals
c.CmdClause = parent.Command("authenticate", "Authenticate with Fastly (returns temporary, auto-rotated, API token)")
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 Fastly", 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,
}
}
// NOTE: your id_token might not contain a custom claim with its own access token inside (YMMV).
fmt.Println("session token:", ar.sessionToken)
return nil
}
type server struct {
result chan authorizationResult
router *http.ServeMux
verifier *oidc.S256Verifier
}
func (s *server) startServer() error {
err := http.ListenAndServe(":8080", s.router)
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
}
_, err = verifyJWTSignature(j.AccessToken)
if err != nil {
s.result <- authorizationResult{
err: err,
}
return
}
claims, err := verifyJWTSignature(j.IDToken)
if err != nil {
s.result <- authorizationResult{
err: err,
}
return
}
// NOTE: This is only for the Fastly CLI setup.
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 Fastly 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/authorize?audience=%s"+
"&scope=openid"+
"&response_type=code&client_id=%s"+
"&code_challenge=%s"+
"&code_challenge_method=S256&redirect_uri=%s",
Auth0CLIAppURL, Auth0Audience, Auth0ClientID, challenge, Auth0RedirectURL)
return authorizationURL, nil
}
func getJWT(codeVerifier, authorizationCode string) (JWT, error) {
path := "/oauth/token"
payload := fmt.Sprintf(
"grant_type=authorization_code&client_id=%s&code_verifier=%s&code=%s&redirect_uri=%s",
Auth0ClientID,
codeVerifier,
authorizationCode,
"http://localhost:8080", // NOTE: not redirected to, just a security check.
)
req, err := http.NewRequest("POST", Auth0CLIAppURL+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
}
// NOTE: I use the identifier `j` to avoid overlap with the `jwt` package.
var j JWT
err = json.Unmarshal(body, &j)
if err != nil {
return JWT{}, err
}
return j, nil
}
// JWT is the API response for an Auth0 Token request.
type JWT struct {
// AccessToken can be exchanged for a Fastly 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"`
// 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()
// 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, Auth0CLIAppURL+"/.well-known/jwks.json", "")
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 claims, fmt.Errorf("failed to verify signature of access token: %w", err)
}
return claims, nil
}
func extractSessionToken(claims map[string]any) (string, error) {
if i, ok := claims["ui_token"]; ok {
if m, ok := i.(map[string]any); ok {
if v, ok := m["access_token"]; ok {
if t, ok := v.(string); ok {
if t != "" {
return t, nil
}
}
}
}
}
return "", fmt.Errorf("failed to extract session token from JWT custom claim")
}
// NOTE: https://keycloak.ext.awsuse2.dev.k8s.secretcdn.net/realms/fastly/.well-known/openid-configuration
package authenticate
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/fastly/cli/pkg/cmd"
"github.com/fastly/cli/pkg/config"
fsterr "github.com/fastly/cli/pkg/errors"
"github.com/fastly/cli/pkg/profile"
"github.com/fastly/cli/pkg/text"
"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/fastly/cli/issues/new?labels=bug&template=bug_report.md"
// AuthProviderCLIAppURL is the auth provider's device code URL.
const AuthProviderCLIAppURL = "https://keycloak.<YOUR_DOMAIN>"
// AuthProviderClientID is the auth provider's Client ID.
const AuthProviderClientID = "<CLIENT_ID>"
// AuthProviderAudience is the unique identifier of the API your app wants to access.
const AuthProviderAudience = "https://<YOUR_API>/"
// 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, globals *config.Data) *RootCommand {
var c RootCommand
c.Globals = globals
c.CmdClause = parent.Command("authenticate", "Authenticate with Fastly (returns temporary, auto-rotated, API token)")
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 Fastly", 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)
profileName, _ := profile.Default(c.Globals.File.Profiles)
if profileName == "" {
// FIXME: Return a more appropriate remediation.
return fsterr.RemediationError{
Inner: fmt.Errorf("no profiles available"),
Remediation: fsterr.ProfileRemediation,
}
}
ps, ok := profile.Edit(profileName, c.Globals.File.Profiles, func(p *config.Profile) {
p.Token = ar.sessionToken
})
if !ok {
return fsterr.RemediationError{
Inner: fmt.Errorf("failed to update default profile with new session token"),
Remediation: "Run `fastly profile update` and manually paste in the session token.",
}
}
c.Globals.File.Profiles = ps
if err := c.Globals.File.Write(c.Globals.Path); err != nil {
c.Globals.ErrLog.Add(err)
return fmt.Errorf("error saving config file: %w", err)
}
// FIXME: Don't just update the default profile.
// Allow user to configure this via a --profile flag.
return nil
}
type server struct {
result chan authorizationResult
router *http.ServeMux
verifier *oidc.S256Verifier
}
func (s *server) startServer() error {
// TODO: Consider using a random port to avoid local network conflicts.
// Chat with authentication provider about how to use a random port.
err := http.ListenAndServe(":8080", s.router)
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
}
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 Fastly 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/fastly/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/fastly/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
}
var j JWT
err = json.Unmarshal(body, &j)
if err != nil {
return JWT{}, err
}
return j, nil
}
// JWT is the API response for a Token request.
type JWT struct {
// AccessToken can be exchanged for a Fastly 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"`
// 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/fastly/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
}
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")
}
# Render this with https://sequencediagram.org/
title CLI PKCE Auth Flow
participant User
participant Terminal
participant CLI
participant Local HTTP Server
participant KeyCloak
participant Okta
User->Terminal: **Opens Terminal**
Terminal->CLI: **Types command:**\n""fastly authenticate""
CLI-->CLI: ""oidc.NewCodeVerifier()""
CLI-->Local HTTP Server: **Start local HTTP server**
CLI->KeyCloak: **Opens Web Browser to KeyCloak's 'Device Code' URL**\n""https://example.whatever.com\n/realms/fastly/protocol/openid-connect\n/auth?audience=...etc""
CLI-->CLI: 🚫 **Blocks execution until JWT received**
KeyCloak-->KeyCloak: **Displays Login UI**
User->KeyCloak: **Enters email address**
KeyCloak-->KeyCloak: **Identifies Provider as Okta**
KeyCloak-->Okta: **Redirect user to Okta**\n""https://whatever.oktapreview.com/app/\nwhateversamlfastlycontrol_1/\nwhatever/sso/saml""
User->Okta: **Logs into Okta**
Okta->Local HTTP Server: **Redirect user to Local Server callback**\n""http://localhost:8080/callback?\nsession_state=<>&code=<>""
Local HTTP Server-->Local HTTP Server: **Exchange ""code"" param and ""code_verifier""\nfor a JWT and validate its signature, then\nextract a session token from the JWT.**
Local HTTP Server->CLI: **Send session token**
Local HTTP Server-->Local HTTP Server: **Display success message to user.\nAsk them to close browser window\nand return to their terminal.**
CLI-->CLI: ✅ **Unblocks execution\nPersist session token to profile config**
CLI->Terminal: **Display success message to user**
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment