Last active
April 6, 2023 13:35
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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") | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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") | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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