You've already forked step-ca-cli
mirror of
https://github.com/smallstep/cli.git
synced 2025-08-09 03:22:43 +03:00
867 lines
25 KiB
Go
867 lines
25 KiB
Go
package oauth
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/pkg/errors"
|
|
"github.com/smallstep/cli/command"
|
|
"github.com/smallstep/cli/crypto/randutil"
|
|
"github.com/smallstep/cli/errs"
|
|
"github.com/smallstep/cli/exec"
|
|
"github.com/smallstep/cli/flags"
|
|
"github.com/smallstep/cli/jose"
|
|
"github.com/urfave/cli"
|
|
)
|
|
|
|
// These are the OAuth2.0 client IDs from the Step CLI. This application is
|
|
// using the OAuth2.0 flow for installed applications described on
|
|
// https://developers.google.com/identity/protocols/OAuth2InstalledApp
|
|
//
|
|
// The Step CLI app and these client IDs do not have any APIs or services are
|
|
// enabled and it should be only used for OAuth 2.0 authorization.
|
|
//
|
|
// Due to the fact that the app cannot keep the client_secret confidential,
|
|
// incremental authorization with installed apps are not supported by Google.
|
|
//
|
|
// Google is also distributing the client ID and secret on the cloud SDK
|
|
// available here https://cloud.google.com/sdk/docs/quickstarts
|
|
const (
|
|
defaultClientID = "1087160488420-8qt7bavg3qesdhs6it824mhnfgcfe8il.apps.googleusercontent.com"
|
|
defaultClientNotSoSecret = "udTrOT3gzrO7W9fDPgZQLfYJ"
|
|
|
|
// The URN for getting verification token offline
|
|
oobCallbackUrn = "urn:ietf:wg:oauth:2.0:oob"
|
|
// The URN for token request grant type jwt-bearer
|
|
jwtBearerUrn = "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
|
)
|
|
|
|
type token struct {
|
|
AccessToken string `json:"access_token"`
|
|
IDToken string `json:"id_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
TokenType string `json:"token_type"`
|
|
Err string `json:"error,omitempty"`
|
|
ErrDesc string `json:"error_description,omitempty"`
|
|
}
|
|
|
|
func init() {
|
|
cmd := cli.Command{
|
|
Name: "oauth",
|
|
Usage: "authorization and single sign-on using OAuth & OIDC",
|
|
UsageText: `
|
|
**step oauth** [**--provider**=<provider>] [**--client-id**=<client-id> **--client-secret**=<client-secret>]
|
|
[**--scope**=<scope> ...] [**--bare** [**--oidc**]] [**--header** [**--oidc**]]
|
|
|
|
**step oauth** **--authorization-endpoint**=<authorization-endpoint> **--token-endpoint**=<token-endpoint>
|
|
**--client-id**=<client-id> **--client-secret**=<client-secret> [**--scope**=<scope> ...] [**--bare** [**--oidc**]] [**--header** [**--oidc**]]
|
|
|
|
**step oauth** [**--account**=<account>] [**--authorization-endpoint**=<authorization-endpoint> **--token-endpoint**=<token-endpoint>]
|
|
[**--scope**=<scope> ...] [**--bare** [**--oidc**]] [**--header** [**--oidc**]]
|
|
|
|
**step oauth** **--account**=<account> **--jwt** [**--scope**=<scope> ...] [**--header**] [**-bare**]`,
|
|
Description: `**step oauth** command implements the OAuth 2.0 authorization flow.
|
|
|
|
OAuth is an open standard for access delegation, commonly used as a way for
|
|
Internet users to grant websites or applications access to their information on
|
|
other websites but without giving them the passwords. This mechanism is used by
|
|
companies such as Amazon, Google, Facebook, Microsoft and Twitter to permit the
|
|
users to share information about their accounts with third party applications or
|
|
websites. Learn more at https://en.wikipedia.org/wiki/OAuth.
|
|
|
|
This command by default performs he authorization flow with a preconfigured
|
|
Google application, but a custom one can be set combining the flags
|
|
**--client-id**, **--client-secret**, and **--provider**. The provider value
|
|
must be set to the OIDC discovery document (.well-known/openid-configuration)
|
|
endpoint. If Google is used this flag is not necessary, but the appropriate
|
|
value would be be https://accounts.google.com or
|
|
https://accounts.google.com/.well-known/openid-configuration
|
|
|
|
## EXAMPLES
|
|
|
|
Do the OAuth 2.0 flow using the default client:
|
|
'''
|
|
$ step oauth
|
|
'''
|
|
|
|
Redirect to localhost instead of 127.0.0.1:
|
|
'''
|
|
$ step oauth --listen localhost:0
|
|
'''
|
|
|
|
Redirect to a fixed port instead of random one:
|
|
'''
|
|
$ step oauth --listen :10000
|
|
'''
|
|
|
|
Get just the access token:
|
|
'''
|
|
$ step oauth --bare
|
|
'''
|
|
|
|
Get just the OIDC token:
|
|
'''
|
|
$ step oauth --oidc --bare
|
|
'''
|
|
|
|
Use a custom OAuth2.0 server:
|
|
''''
|
|
$ step oauth --client-id my-client-id --client-secret my-client-secret \
|
|
--provider https://example.org
|
|
'''`,
|
|
Flags: []cli.Flag{
|
|
cli.StringFlag{
|
|
Name: "provider, idp",
|
|
Usage: "OAuth provider for authentication",
|
|
Value: "google",
|
|
},
|
|
cli.StringFlag{
|
|
Name: "email, e",
|
|
Usage: "Email to authenticate",
|
|
},
|
|
cli.BoolFlag{
|
|
Name: "console, c",
|
|
Usage: "Complete the flow while remaining only inside the terminal",
|
|
},
|
|
cli.StringFlag{
|
|
Name: "client-id",
|
|
Usage: "OAuth Client ID",
|
|
},
|
|
cli.StringFlag{
|
|
Name: "client-secret",
|
|
Usage: "OAuth Client Secret",
|
|
},
|
|
cli.StringFlag{
|
|
Name: "account",
|
|
Usage: "JSON file containing account details",
|
|
},
|
|
cli.StringFlag{
|
|
Name: "authorization-endpoint",
|
|
Usage: "OAuth Authorization Endpoint",
|
|
},
|
|
cli.StringFlag{
|
|
Name: "token-endpoint",
|
|
Usage: "OAuth Token Endpoint",
|
|
},
|
|
cli.BoolFlag{
|
|
Name: "header",
|
|
Usage: "Output HTTP Authorization Header (suitable for use with curl)",
|
|
},
|
|
cli.BoolFlag{
|
|
Name: "oidc",
|
|
Usage: "Output OIDC Token instead of OAuth Access Token",
|
|
},
|
|
cli.BoolFlag{
|
|
Name: "bare",
|
|
Usage: "Only output the token",
|
|
},
|
|
cli.StringSliceFlag{
|
|
Name: "scope",
|
|
Usage: "OAuth scopes",
|
|
},
|
|
cli.BoolFlag{
|
|
Name: "jwt",
|
|
Usage: "Generate a JWT Auth token instead of an OAuth Token (only works with service accounts)",
|
|
},
|
|
cli.StringFlag{
|
|
Name: "listen",
|
|
Usage: "Callback listener <address> (e.g. \":10000\")",
|
|
},
|
|
cli.BoolFlag{
|
|
Name: "implicit",
|
|
Usage: "Uses the implicit flow to authenticate the user. Requires **--insecure** and **--client-id** flags.",
|
|
Hidden: true,
|
|
},
|
|
cli.BoolFlag{
|
|
Name: "insecure",
|
|
Usage: "Allows the use of insecure flows.",
|
|
Hidden: true,
|
|
},
|
|
flags.RedirectURL,
|
|
},
|
|
Action: oauthCmd,
|
|
}
|
|
|
|
command.Register(cmd)
|
|
}
|
|
|
|
func oauthCmd(c *cli.Context) error {
|
|
opts := &options{
|
|
Provider: c.String("provider"),
|
|
Email: c.String("email"),
|
|
Console: c.Bool("console"),
|
|
Implicit: c.Bool("implicit"),
|
|
CallbackListener: c.String("listen"),
|
|
TerminalRedirect: c.String("redirect-url"),
|
|
}
|
|
if err := opts.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if (opts.Provider != "google" || c.IsSet("authorization-endpoint")) && !c.IsSet("client-id") {
|
|
return errors.New("flag '--client-id' required with '--provider'")
|
|
}
|
|
|
|
var clientID, clientSecret string
|
|
if opts.Implicit {
|
|
if !c.Bool("insecure") {
|
|
return errs.RequiredInsecureFlag(c, "implicit")
|
|
}
|
|
if !c.IsSet("client-id") {
|
|
return errs.RequiredWithFlag(c, "implicit", "client-id")
|
|
}
|
|
} else {
|
|
clientID = defaultClientID
|
|
clientSecret = defaultClientNotSoSecret
|
|
}
|
|
if c.IsSet("client-id") {
|
|
clientID = c.String("client-id")
|
|
clientSecret = c.String("client-secret")
|
|
}
|
|
|
|
authzEp := ""
|
|
tokenEp := ""
|
|
if c.IsSet("authorization-endpoint") {
|
|
if !c.IsSet("token-endpoint") {
|
|
return errors.New("flag '--authorization-endpoint' requires flag '--token-endpoint'")
|
|
}
|
|
opts.Provider = ""
|
|
authzEp = c.String("authorization-endpoint")
|
|
tokenEp = c.String("token-endpoint")
|
|
}
|
|
|
|
do2lo := false
|
|
issuer := ""
|
|
// This code supports Google service accounts. Probably maybe also support JWKs?
|
|
if c.IsSet("account") {
|
|
opts.Provider = ""
|
|
filename := c.String("account")
|
|
b, err := ioutil.ReadFile(filename)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "error reading account from %s", filename)
|
|
}
|
|
account := make(map[string]interface{})
|
|
if err = json.Unmarshal(b, &account); err != nil {
|
|
return errors.Wrapf(err, "error reading %s: unsupported format", filename)
|
|
}
|
|
|
|
if _, ok := account["installed"]; ok {
|
|
details := account["installed"].(map[string]interface{})
|
|
authzEp = details["auth_uri"].(string)
|
|
tokenEp = details["token_uri"].(string)
|
|
clientID = details["client_id"].(string)
|
|
clientSecret = details["client_secret"].(string)
|
|
} else if accountType, ok := account["type"]; ok && "service_account" == accountType {
|
|
authzEp = account["auth_uri"].(string)
|
|
tokenEp = account["token_uri"].(string)
|
|
clientID = account["private_key_id"].(string)
|
|
clientSecret = account["private_key"].(string)
|
|
issuer = account["client_email"].(string)
|
|
do2lo = true
|
|
} else {
|
|
return errors.Wrapf(err, "error reading %s: unsupported account type", filename)
|
|
}
|
|
}
|
|
|
|
scope := "openid email"
|
|
if c.IsSet("scope") {
|
|
scope = strings.Join(c.StringSlice("scope"), " ")
|
|
}
|
|
|
|
o, err := newOauth(opts.Provider, clientID, clientSecret, authzEp, tokenEp, scope, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var tok *token
|
|
if do2lo {
|
|
if c.Bool("jwt") {
|
|
tok, err = o.DoJWTAuthorization(issuer, scope)
|
|
} else {
|
|
tok, err = o.DoTwoLeggedAuthorization(issuer)
|
|
}
|
|
} else if opts.Console {
|
|
tok, err = o.DoManualAuthorization()
|
|
} else {
|
|
tok, err = o.DoLoopbackAuthorization()
|
|
}
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if c.Bool("header") {
|
|
if c.Bool("oidc") {
|
|
fmt.Println("Authorization: Bearer", tok.IDToken)
|
|
} else {
|
|
fmt.Println("Authorization: Bearer", tok.AccessToken)
|
|
}
|
|
} else {
|
|
if c.Bool("bare") {
|
|
if c.Bool("oidc") {
|
|
fmt.Println(tok.IDToken)
|
|
} else {
|
|
fmt.Println(tok.AccessToken)
|
|
}
|
|
} else {
|
|
b, err := json.MarshalIndent(tok, "", " ")
|
|
if err != nil {
|
|
return errors.Wrapf(err, "error marshaling token data")
|
|
}
|
|
fmt.Println(string(b))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type options struct {
|
|
Provider string
|
|
Email string
|
|
Console bool
|
|
Implicit bool
|
|
CallbackListener string
|
|
TerminalRedirect string
|
|
}
|
|
|
|
// Validate validates the options.
|
|
func (o *options) Validate() error {
|
|
if o.Provider != "google" && !strings.HasPrefix(o.Provider, "https://") {
|
|
return errors.New("use a valid provider: google")
|
|
}
|
|
if o.CallbackListener != "" {
|
|
if _, _, err := net.SplitHostPort(o.CallbackListener); err != nil {
|
|
return errors.Wrapf(err, "invalid value '%s' for flag '--listen'", o.CallbackListener)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type oauth struct {
|
|
provider string
|
|
clientID string
|
|
clientSecret string
|
|
scope string
|
|
loginHint string
|
|
redirectURI string
|
|
tokenEndpoint string
|
|
authzEndpoint string
|
|
userInfoEndpoint string // For testing
|
|
state string
|
|
codeChallenge string
|
|
nonce string
|
|
implicit bool
|
|
CallbackListener string
|
|
terminalRedirect string
|
|
errCh chan error
|
|
tokCh chan *token
|
|
}
|
|
|
|
func newOauth(provider, clientID, clientSecret, authzEp, tokenEp, scope string, opts *options) (*oauth, error) {
|
|
state, err := randutil.Alphanumeric(32)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
challenge, err := randutil.Alphanumeric(64)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nonce, err := randutil.Hex(64) // 256 bits
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
switch provider {
|
|
case "google":
|
|
return &oauth{
|
|
provider: provider,
|
|
clientID: clientID,
|
|
clientSecret: clientSecret,
|
|
scope: scope,
|
|
authzEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
tokenEndpoint: "https://www.googleapis.com/oauth2/v4/token",
|
|
userInfoEndpoint: "https://www.googleapis.com/oauth2/v3/userinfo",
|
|
loginHint: opts.Email,
|
|
state: state,
|
|
codeChallenge: challenge,
|
|
nonce: nonce,
|
|
implicit: opts.Implicit,
|
|
CallbackListener: opts.CallbackListener,
|
|
terminalRedirect: opts.TerminalRedirect,
|
|
errCh: make(chan error),
|
|
tokCh: make(chan *token),
|
|
}, nil
|
|
default:
|
|
userinfoEp := ""
|
|
if authzEp == "" && tokenEp == "" {
|
|
d, err := disco(provider)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if _, ok := d["authorization_endpoint"]; !ok {
|
|
return nil, errors.New("missing 'authorization_endpoint' in provider metadata")
|
|
}
|
|
if _, ok := d["token_endpoint"]; !ok {
|
|
return nil, errors.New("missing 'token_endpoint' in provider metadata")
|
|
}
|
|
authzEp = d["authorization_endpoint"].(string)
|
|
tokenEp = d["token_endpoint"].(string)
|
|
userinfoEp = d["token_endpoint"].(string)
|
|
}
|
|
return &oauth{
|
|
provider: provider,
|
|
clientID: clientID,
|
|
clientSecret: clientSecret,
|
|
scope: scope,
|
|
authzEndpoint: authzEp,
|
|
tokenEndpoint: tokenEp,
|
|
userInfoEndpoint: userinfoEp,
|
|
loginHint: opts.Email,
|
|
state: state,
|
|
codeChallenge: challenge,
|
|
nonce: nonce,
|
|
implicit: opts.Implicit,
|
|
CallbackListener: opts.CallbackListener,
|
|
terminalRedirect: opts.TerminalRedirect,
|
|
errCh: make(chan error),
|
|
tokCh: make(chan *token),
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
func disco(provider string) (map[string]interface{}, error) {
|
|
url, err := url.Parse(provider)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// TODO: OIDC and OAuth specify two different ways of constructing this
|
|
// URL. This is the OIDC way. Probably want to try both. See
|
|
// https://tools.ietf.org/html/rfc8414#section-5
|
|
if !strings.Contains(url.Path, "/.well-known/openid-configuration") {
|
|
url.Path = path.Join(url.Path, "/.well-known/openid-configuration")
|
|
}
|
|
resp, err := http.Get(url.String())
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "error retrieving %s", url.String())
|
|
}
|
|
defer resp.Body.Close()
|
|
b, err := ioutil.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "error retrieving %s", url.String())
|
|
}
|
|
details := make(map[string]interface{})
|
|
if err = json.Unmarshal(b, &details); err != nil {
|
|
return nil, errors.Wrapf(err, "error reading %s: unsupported format", url.String())
|
|
}
|
|
return details, err
|
|
}
|
|
|
|
// NewServer creates http server
|
|
func (o *oauth) NewServer() (*httptest.Server, error) {
|
|
if o.CallbackListener == "" {
|
|
return httptest.NewServer(o), nil
|
|
}
|
|
host, port, err := net.SplitHostPort(o.CallbackListener)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if host == "" {
|
|
host = "127.0.0.1"
|
|
}
|
|
l, err := net.Listen("tcp", net.JoinHostPort(host, port))
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "error listening on %s", o.CallbackListener)
|
|
}
|
|
srv := &httptest.Server{
|
|
Listener: l,
|
|
Config: &http.Server{Handler: o},
|
|
}
|
|
srv.Start()
|
|
|
|
// Update host to use for example localhost
|
|
if host != "127.0.0.1" {
|
|
_, p, err := net.SplitHostPort(l.Addr().String())
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "error parsing %s", l.Addr().String())
|
|
}
|
|
srv.URL = "http://" + host + ":" + p
|
|
}
|
|
|
|
return srv, nil
|
|
}
|
|
|
|
// DoLoopbackAuthorization performs the log in into the identity provider
|
|
// opening a browser and using a redirect_uri in a loopback IP address
|
|
// (http://127.0.0.1:port or http://[::1]:port).
|
|
func (o *oauth) DoLoopbackAuthorization() (*token, error) {
|
|
srv, err := o.NewServer()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
o.redirectURI = srv.URL
|
|
defer srv.Close()
|
|
|
|
// Get auth url and open it in a browser
|
|
authURL, err := o.Auth()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := exec.OpenInBrowser(authURL); err != nil {
|
|
fmt.Fprintln(os.Stderr, "Cannot open a web browser on your platform.")
|
|
fmt.Fprintln(os.Stderr)
|
|
fmt.Fprintln(os.Stderr, "Open a local web browser and visit:")
|
|
fmt.Fprintln(os.Stderr)
|
|
fmt.Fprintln(os.Stderr, authURL)
|
|
fmt.Fprintln(os.Stderr)
|
|
} else {
|
|
fmt.Fprintln(os.Stderr, "Your default web browser has been opened to visit:")
|
|
fmt.Fprintln(os.Stderr)
|
|
fmt.Fprintln(os.Stderr, authURL)
|
|
fmt.Fprintln(os.Stderr)
|
|
}
|
|
|
|
// Wait for response and return the token
|
|
select {
|
|
case tok := <-o.tokCh:
|
|
return tok, nil
|
|
case err := <-o.errCh:
|
|
return nil, err
|
|
case <-time.After(2 * time.Minute):
|
|
return nil, errors.New("oauth command timed out, please try again")
|
|
}
|
|
}
|
|
|
|
// DoManualAuthorization performs the log in into the identity provider
|
|
// allowing the user to open a browser on a different system and then entering
|
|
// the authorization code on the Step CLI.
|
|
func (o *oauth) DoManualAuthorization() (*token, error) {
|
|
o.redirectURI = oobCallbackUrn
|
|
authURL, err := o.Auth()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fmt.Fprintln(os.Stderr, "Open a local web browser and visit:")
|
|
fmt.Fprintln(os.Stderr)
|
|
fmt.Fprintln(os.Stderr, authURL)
|
|
fmt.Fprintln(os.Stderr)
|
|
|
|
// Read from the command line
|
|
fmt.Fprint(os.Stderr, "Enter verification code: ")
|
|
reader := bufio.NewReader(os.Stdin)
|
|
code, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return nil, errors.WithStack(err)
|
|
}
|
|
|
|
tok, err := o.Exchange(o.tokenEndpoint, code)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tok.Err != "" || tok.ErrDesc != "" {
|
|
return nil, errors.Errorf("Error exchanging authorization code: %s. %s", tok.Err, tok.ErrDesc)
|
|
}
|
|
return tok, nil
|
|
}
|
|
|
|
// DoTwoLeggedAuthorization performs two-legged OAuth using the jwt-bearer
|
|
// grant type.
|
|
func (o *oauth) DoTwoLeggedAuthorization(issuer string) (*token, error) {
|
|
pemBytes := []byte(o.clientSecret)
|
|
block, _ := pem.Decode(pemBytes)
|
|
if block == nil {
|
|
return nil, fmt.Errorf("failed to read private key pem block")
|
|
}
|
|
priv, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "error parsing private key")
|
|
}
|
|
|
|
// Add claims
|
|
now := int(time.Now().Unix())
|
|
c := map[string]interface{}{
|
|
"aud": o.tokenEndpoint,
|
|
"nbf": now,
|
|
"iat": now,
|
|
"exp": now + 3600,
|
|
"iss": issuer,
|
|
"scope": o.scope,
|
|
}
|
|
|
|
so := new(jose.SignerOptions)
|
|
so.WithType("JWT")
|
|
so.WithHeader("kid", o.clientID)
|
|
|
|
// Sign JWT
|
|
signer, err := jose.NewSigner(jose.SigningKey{
|
|
Algorithm: "RS256",
|
|
Key: priv,
|
|
}, so)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "error creating JWT signer")
|
|
}
|
|
|
|
raw, err := jose.Signed(signer).Claims(c).CompactSerialize()
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "error serializing JWT")
|
|
}
|
|
|
|
// Construct the POST request to fetch the OAuth token.
|
|
params := url.Values{
|
|
"assertion": []string{string(raw)},
|
|
"grant_type": []string{jwtBearerUrn},
|
|
}
|
|
|
|
// Send the POST request and return token.
|
|
resp, err := http.PostForm(o.tokenEndpoint, params)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "error from token endpoint")
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var tok token
|
|
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
|
|
return nil, errors.WithStack(err)
|
|
}
|
|
|
|
return &tok, nil
|
|
}
|
|
|
|
// DoJWTAuthorization generates a JWT instead of an OAuth token. Only works for
|
|
// certain APIs. See https://developers.google.com/identity/protocols/OAuth2ServiceAccount#jwt-auth.
|
|
func (o *oauth) DoJWTAuthorization(issuer, aud string) (*token, error) {
|
|
pemBytes := []byte(o.clientSecret)
|
|
block, _ := pem.Decode(pemBytes)
|
|
if block == nil {
|
|
return nil, fmt.Errorf("failed to read private key pem block")
|
|
}
|
|
priv, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "error parsing private key")
|
|
}
|
|
|
|
// Add claims
|
|
now := int(time.Now().Unix())
|
|
c := map[string]interface{}{
|
|
"aud": aud,
|
|
"nbf": now,
|
|
"iat": now,
|
|
"exp": now + 3600,
|
|
"iss": issuer,
|
|
"sub": issuer,
|
|
}
|
|
|
|
so := new(jose.SignerOptions)
|
|
so.WithType("JWT")
|
|
so.WithHeader("kid", o.clientID)
|
|
|
|
// Sign JWT
|
|
signer, err := jose.NewSigner(jose.SigningKey{
|
|
Algorithm: "RS256",
|
|
Key: priv,
|
|
}, so)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "error creating JWT signer")
|
|
}
|
|
|
|
raw, err := jose.Signed(signer).Claims(c).CompactSerialize()
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "error serializing JWT")
|
|
}
|
|
|
|
tok := &token{string(raw), "", "", 3600, "Bearer", "", ""}
|
|
return tok, nil
|
|
}
|
|
|
|
// ServeHTTP is the handler that performs the OAuth 2.0 dance and returns the
|
|
// tokens using channels.
|
|
func (o *oauth) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|
if req.URL.Path != "/" {
|
|
http.NotFound(w, req)
|
|
return
|
|
}
|
|
|
|
q := req.URL.Query()
|
|
errStr := q.Get("error")
|
|
if errStr != "" {
|
|
o.badRequest(w, "Failed to authenticate: "+errStr)
|
|
return
|
|
}
|
|
|
|
if o.implicit {
|
|
o.implicitHandler(w, req)
|
|
return
|
|
}
|
|
|
|
code, state := q.Get("code"), q.Get("state")
|
|
if code == "" || state == "" {
|
|
fmt.Fprintf(os.Stderr, "Invalid request received: http://%s%s\n", req.RemoteAddr, req.URL.String())
|
|
fmt.Fprintf(os.Stderr, "You may have an app or browser plugin that needs to be turned off\n")
|
|
http.Error(w, "400 bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if code == "" {
|
|
o.badRequest(w, "Failed to authenticate: missing or invalid code")
|
|
return
|
|
}
|
|
|
|
if state == "" || state != o.state {
|
|
o.badRequest(w, "Failed to authenticate: missing or invalid state")
|
|
return
|
|
}
|
|
|
|
tok, err := o.Exchange(o.tokenEndpoint, code)
|
|
if err != nil {
|
|
o.badRequest(w, "Failed exchanging authorization code: "+err.Error())
|
|
return
|
|
}
|
|
if tok.Err != "" || tok.ErrDesc != "" {
|
|
o.badRequest(w, fmt.Sprintf("Failed exchanging authorization code: %s. %s", tok.Err, tok.ErrDesc))
|
|
return
|
|
}
|
|
|
|
if o.terminalRedirect != "" {
|
|
http.Redirect(w, req, o.terminalRedirect, 302)
|
|
} else {
|
|
o.success(w)
|
|
}
|
|
o.tokCh <- tok
|
|
}
|
|
|
|
func (o *oauth) implicitHandler(w http.ResponseWriter, req *http.Request) {
|
|
q := req.URL.Query()
|
|
if hash := q.Get("urlhash"); hash == "true" {
|
|
state := q.Get("state")
|
|
if state == "" || state != o.state {
|
|
o.badRequest(w, "Failed to authenticate: missing or invalid state")
|
|
return
|
|
}
|
|
accessToken := q.Get("access_token")
|
|
if accessToken == "" {
|
|
o.badRequest(w, "Failed to authenticate: missing access token")
|
|
return
|
|
}
|
|
|
|
if o.terminalRedirect != "" {
|
|
http.Redirect(w, req, o.terminalRedirect, 302)
|
|
} else {
|
|
o.success(w)
|
|
}
|
|
|
|
expiresIn, _ := strconv.Atoi(q.Get("expires_in"))
|
|
o.tokCh <- &token{
|
|
AccessToken: accessToken,
|
|
IDToken: q.Get("id_token"),
|
|
RefreshToken: q.Get("refresh_token"),
|
|
ExpiresIn: expiresIn,
|
|
TokenType: q.Get("token_type"),
|
|
}
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Header().Add("Content-Type", "text/html; charset=utf-8")
|
|
w.Write([]byte(`<html><head><title>Processing OAuth Request</title>`))
|
|
w.Write([]byte(`</head>`))
|
|
w.Write([]byte(`<script type="text/javascript">`))
|
|
w.Write([]byte(fmt.Sprintf(`function redirect(){var hash = window.location.hash.substr(1); document.location.href = "%s?urlhash=true&"+hash;}`, o.redirectURI)))
|
|
w.Write([]byte(`if (window.addEventListener) window.addEventListener("load", redirect, false); else if (window.attachEvent) window.attachEvent("onload", redirect); else window.onload = redirect;`))
|
|
w.Write([]byte("</script>"))
|
|
w.Write([]byte(`<body><p style='font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; font-size: 22px; color: #333; width: 400px; margin: 0 auto; text-align: center; line-height: 1.7; padding: 20px;'>`))
|
|
w.Write([]byte(`<strong style='font-size: 28px; color: #000;'>Success</strong><br />`))
|
|
w.Write([]byte(`Click <a href="javascript:redirect();">here</a> if your browser does not automatically redirect you`))
|
|
w.Write([]byte(`</p></body></html>`))
|
|
}
|
|
|
|
// Auth returns the OAuth 2.0 authentication url.
|
|
func (o *oauth) Auth() (string, error) {
|
|
u, err := url.Parse(o.authzEndpoint)
|
|
if err != nil {
|
|
return "", errors.WithStack(err)
|
|
}
|
|
|
|
q := u.Query()
|
|
q.Add("client_id", o.clientID)
|
|
q.Add("redirect_uri", o.redirectURI)
|
|
if o.implicit {
|
|
q.Add("response_type", "id_token token")
|
|
} else {
|
|
q.Add("response_type", "code")
|
|
q.Add("code_challenge_method", "S256")
|
|
s256 := sha256.Sum256([]byte(o.codeChallenge))
|
|
q.Add("code_challenge", base64.RawURLEncoding.EncodeToString(s256[:]))
|
|
}
|
|
q.Add("scope", o.scope)
|
|
q.Add("state", o.state)
|
|
q.Add("nonce", o.nonce)
|
|
if o.loginHint != "" {
|
|
q.Add("login_hint", o.loginHint)
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
return u.String(), nil
|
|
}
|
|
|
|
// Exchange exchanges the authorization code for refresh and access tokens.
|
|
func (o *oauth) Exchange(tokenEndpoint, code string) (*token, error) {
|
|
data := url.Values{}
|
|
data.Set("code", code)
|
|
data.Set("client_id", o.clientID)
|
|
data.Set("client_secret", o.clientSecret)
|
|
data.Set("redirect_uri", o.redirectURI)
|
|
data.Set("grant_type", "authorization_code")
|
|
data.Set("code_verifier", o.codeChallenge)
|
|
|
|
resp, err := http.PostForm(tokenEndpoint, data)
|
|
if err != nil {
|
|
return nil, errors.WithStack(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var tok token
|
|
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
|
|
return nil, errors.WithStack(err)
|
|
}
|
|
|
|
return &tok, nil
|
|
}
|
|
|
|
func (o *oauth) success(w http.ResponseWriter) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Header().Add("Content-Type", "text/plain; charset=utf-8")
|
|
w.Write([]byte(`<html><head><title>OAuth Request Successful</title>`))
|
|
w.Write([]byte(`</head><body><p style='font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; font-size: 22px; color: #333; width: 400px; margin: 0 auto; text-align: center; line-height: 1.7; padding: 20px;'>`))
|
|
w.Write([]byte(`<strong style='font-size: 28px; color: #000;'>Success</strong><br />Look for the token on the command line`))
|
|
w.Write([]byte(`</p></body></html>`))
|
|
}
|
|
|
|
func (o *oauth) badRequest(w http.ResponseWriter, msg string) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Header().Add("Content-Type", "text/plain; charset=utf-8")
|
|
w.Write([]byte(`<html><head><title>OAuth Request Unsuccessful</title>`))
|
|
w.Write([]byte(`</head><body><p style='font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; font-size: 22px; color: #333; width: 400px; margin: 0 auto; text-align: center; line-height: 1.7; padding: 20px;'>`))
|
|
w.Write([]byte(`<strong style='font-size: 28px; color: red;'>Failure</strong><br />`))
|
|
w.Write([]byte(msg))
|
|
w.Write([]byte(`</p></body></html>`))
|
|
o.errCh <- errors.New(msg)
|
|
}
|