1
0
mirror of https://github.com/smallstep/cli.git synced 2025-08-09 03:22:43 +03:00

device authorization grant flow first pass

This commit is contained in:
max furman
2022-06-09 15:08:17 -07:00
parent 994d8ba5f4
commit 1a13c635ea

View File

@@ -1,6 +1,7 @@
package oauth
import (
"bytes"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
@@ -141,6 +142,11 @@ $ step oauth --client-id my-client-id --client-secret my-client-secret \
--provider https://example.org
'''
Use the Device Authorization Grant flow for input constrained clients:
'''
$ step oauth --client-id my-client-id --client-secret my-client-secret --device
'''
Use additional authentication parameters:
'''
$ step oauth --client-id my-client-id --client-secret my-client-secret \
@@ -157,8 +163,18 @@ $ step oauth --client-id my-client-id --client-secret my-client-secret \
Usage: "Email to authenticate",
},
cli.BoolFlag{
Name: "console, c",
Usage: "Complete the flow while remaining only inside the terminal",
Name: "console, c",
Usage: `Complete the flow while remaining only inside the terminal.
NOTE: This flag instructs the CLI to retrieve a token using the Out Of Band flow,
which has been deprecated. In an upcoming release this flag will be updated to
use the Device Authorization Grant flow (https://datatracker.ietf.org/doc/html/rfc8628#section-3.2).
Please use the '--device' flag to use the new flow in the interim.`,
},
cli.BoolFlag{
Name: "device",
Usage: `Complete the flow using the Device Authorization Grant
(https://datatracker.ietf.org/doc/html/rfc8628#section-3.2) flow`,
},
cli.StringFlag{
Name: "client-id",
@@ -176,6 +192,10 @@ $ step oauth --client-id my-client-id --client-secret my-client-secret \
Name: "authorization-endpoint",
Usage: "OAuth Authorization Endpoint",
},
cli.StringFlag{
Name: "device-authorization-endpoint",
Usage: "OAuth Device Authorization Endpoint",
},
cli.StringFlag{
Name: "token-endpoint",
Usage: "OAuth Token Endpoint",
@@ -264,10 +284,15 @@ OpenID standard defines the following values, but your provider may support some
}
func oauthCmd(c *cli.Context) error {
if c.Bool("console") && c.Bool("device") {
return errs.MutuallyExclusiveFlags(c, "console", "device")
}
opts := &options{
Provider: c.String("provider"),
Email: c.String("email"),
Console: c.Bool("console"),
Device: c.Bool("device"),
Implicit: c.Bool("implicit"),
CallbackListener: c.String("listen"),
CallbackListenerURL: c.String("listen-url"),
@@ -300,6 +325,7 @@ func oauthCmd(c *cli.Context) error {
}
authzEp := ""
deviceAuthzEp := ""
tokenEp := ""
if c.IsSet("authorization-endpoint") {
if !c.IsSet("token-endpoint") {
@@ -309,6 +335,14 @@ func oauthCmd(c *cli.Context) error {
authzEp = c.String("authorization-endpoint")
tokenEp = c.String("token-endpoint")
}
if c.IsSet("device-authorization-endpoint") {
if !c.IsSet("token-endpoint") {
return errors.New("flag '--device-authorization-endpoint' requires flag '--token-endpoint'")
}
opts.Provider = ""
deviceAuthzEp = c.String("device-authorization-endpoint")
tokenEp = c.String("token-endpoint")
}
do2lo := false
issuer := ""
@@ -370,7 +404,7 @@ func oauthCmd(c *cli.Context) error {
authParams.Add(k, v)
}
o, err := newOauth(opts.Provider, clientID, clientSecret, authzEp, tokenEp, scope, prompt, authParams, opts)
o, err := newOauth(opts.Provider, clientID, clientSecret, authzEp, deviceAuthzEp, tokenEp, scope, prompt, authParams, opts)
if err != nil {
return err
}
@@ -385,6 +419,8 @@ func oauthCmd(c *cli.Context) error {
}
case opts.Console:
tok, err = o.DoManualAuthorization()
case opts.Device:
tok, err = o.DoDeviceAuthorization()
default:
tok, err = o.DoLoopbackAuthorization()
}
@@ -422,6 +458,7 @@ type options struct {
Provider string
Email string
Console bool
Device bool
Implicit bool
CallbackListener string
CallbackListenerURL string
@@ -462,6 +499,7 @@ type oauth struct {
redirectURI string
tokenEndpoint string
authzEndpoint string
deviceAuthzEndpoint string
userInfoEndpoint string // For testing
state string
codeChallenge string
@@ -477,7 +515,7 @@ type oauth struct {
tokCh chan *token
}
func newOauth(provider, clientID, clientSecret, authzEp, tokenEp, scope, prompt string, authParams url.Values, opts *options) (*oauth, error) {
func newOauth(provider, clientID, clientSecret, authzEp, deviceAuthzEp, tokenEp, scope, prompt string, authParams url.Values, opts *options) (*oauth, error) {
state, err := randutil.Alphanumeric(32)
if err != nil {
return nil, err
@@ -502,6 +540,7 @@ func newOauth(provider, clientID, clientSecret, authzEp, tokenEp, scope, prompt
scope: scope,
prompt: prompt,
authzEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
deviceAuthzEndpoint: "https://oauth2.googleapis.com/device/code",
tokenEndpoint: "https://www.googleapis.com/oauth2/v4/token",
userInfoEndpoint: "https://www.googleapis.com/oauth2/v3/userinfo",
loginHint: opts.Email,
@@ -520,22 +559,45 @@ func newOauth(provider, clientID, clientSecret, authzEp, tokenEp, scope, prompt
}, nil
default:
userinfoEp := ""
if authzEp == "" && tokenEp == "" {
d, err := disco(provider)
if err != nil {
return nil, err
}
if opts.Device {
if deviceAuthzEp == "" && 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["device_authorization_endpoint"]; !ok {
return nil, errors.New("missing 'device_authorization_endpoint' in provider metadata")
}
if _, ok := d["token_endpoint"]; !ok {
return nil, errors.New("missing 'token_endpoint' in provider metadata")
}
deviceAuthzEp = d["device_authorization_endpoint"].(string)
tokenEp = d["token_endpoint"].(string)
userinfoEp = d["token_endpoint"].(string)
}
if _, ok := d["token_endpoint"]; !ok {
return nil, errors.New("missing 'token_endpoint' in provider metadata")
} else {
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")
}
if _, ok := d["device_authorization_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)
}
authzEp = d["authorization_endpoint"].(string)
tokenEp = d["token_endpoint"].(string)
userinfoEp = d["token_endpoint"].(string)
}
return &oauth{
provider: provider,
clientID: clientID,
@@ -543,6 +605,7 @@ func newOauth(provider, clientID, clientSecret, authzEp, tokenEp, scope, prompt
scope: scope,
prompt: prompt,
authzEndpoint: authzEp,
deviceAuthzEndpoint: deviceAuthzEp,
tokenEndpoint: tokenEp,
userInfoEndpoint: userinfoEp,
loginHint: opts.Email,
@@ -702,6 +765,114 @@ func (o *oauth) DoManualAuthorization() (*token, error) {
return tok, nil
}
type identifyDeviceResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
// NOTE Google returns `verification_url` which is incorrect
// according to the spec (https://datatracker.ietf.org/doc/html/rfc8628#section-3.2)
// but we'll try to accomodate for that here.
VerificationURL string `json:"verification_url"`
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
// DoDeviceAuthorization gets a token from the IDP using the OAuth 2.0
// Device Authorization Grant. https://datatracker.ietf.org/doc/html/rfc8628
func (o *oauth) DoDeviceAuthorization() (*token, error) {
// Identify the Device
data := url.Values{}
data.Set("client_id", o.clientID)
data.Set("client_secret", o.clientSecret)
data.Set("scope", o.scope)
resp, err := http.PostForm(o.deviceAuthzEndpoint, data)
if err != nil {
return nil, errors.WithStack(err)
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
var e struct {
Error string
}
if err := json.NewDecoder(bytes.NewReader(b)).Decode(&e); err != nil {
return nil, errors.Wrapf(err, "could not parse http body: %s", string(b))
}
}
var idr identifyDeviceResponse
if err := json.NewDecoder(bytes.NewReader(b)).Decode(&idr); err != nil {
return nil, errors.WithStack(err)
}
switch {
case idr.VerificationURI != "":
break
case idr.VerificationURL != "":
// NOTE this is a hack for Google, because their API returns the attribute
// 'verification_url` rather than `verification_uri`.
idr.VerificationURI = idr.VerificationURL
default:
return nil, errors.Errorf("device code response from server missing 'verification_uri' parameter. http body response: %s", string(b))
}
fmt.Fprintln(os.Stderr, "Go to the following website:")
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, idr.VerificationURI)
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "and enter the activation code below:")
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, idr.UserCode)
fmt.Fprintln(os.Stderr)
// Poll the Token endpoint until the user completes the flow.
data = url.Values{}
data.Set("client_id", o.clientID)
data.Set("client_secret", o.clientSecret)
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
data.Set("device_code", idr.DeviceCode)
var tok token
for {
resp, err := http.PostForm(o.tokenEndpoint, data)
if err != nil {
return nil, errors.WithStack(err)
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errors.WithStack(err)
}
isTokenReceived := false
tok = token{}
switch {
case resp.StatusCode == http.StatusOK:
if err := json.NewDecoder(bytes.NewReader(b)).Decode(&tok); err != nil {
return nil, errors.WithStack(err)
}
isTokenReceived = true
case resp.StatusCode >= http.StatusBadRequest && resp.StatusCode < http.StatusInternalServerError:
time.Sleep(time.Duration(idr.Interval) * time.Second)
default:
return nil, errors.New(string(b))
}
if isTokenReceived {
break
}
}
return &tok, nil
}
// DoTwoLeggedAuthorization performs two-legged OAuth using the jwt-bearer
// grant type.
func (o *oauth) DoTwoLeggedAuthorization(issuer string) (*token, error) {