1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175
|
// Package device facilitates performing OAuth Device Authorization Flow for client applications
// such as CLIs that can not receive redirects from a web site.
//
// First, RequestCode should be used to obtain a CodeResponse.
//
// Next, the user will need to navigate to VerificationURI in their web browser on any device and fill
// in the UserCode.
//
// While the user is completing the web flow, the application should invoke PollToken, which blocks
// the goroutine until the user has authorized the app on the server.
//
// https://docs.github.com/en/free-pro-team@latest/developers/apps/authorizing-oauth-apps#device-flow
package device
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/cli/oauth/api"
)
var (
// ErrUnsupported is thrown when the server does not implement Device flow.
ErrUnsupported = errors.New("device flow not supported")
// ErrTimeout is thrown when polling the server for the granted token has timed out.
ErrTimeout = errors.New("authentication timed out")
)
type httpClient interface {
PostForm(string, url.Values) (*http.Response, error)
}
// CodeResponse holds information about the authorization-in-progress.
type CodeResponse struct {
// The user verification code is displayed on the device so the user can enter the code in a browser.
UserCode string
// The verification URL where users need to enter the UserCode.
VerificationURI string
// The optional verification URL that includes the UserCode.
VerificationURIComplete string
// The device verification code is 40 characters and used to verify the device.
DeviceCode string
// The number of seconds before the DeviceCode and UserCode expire.
ExpiresIn int
// The minimum number of seconds that must pass before you can make a new access token request to
// complete the device authorization.
Interval int
}
// RequestCode initiates the authorization flow by requesting a code from uri.
func RequestCode(c httpClient, uri string, clientID string, scopes []string) (*CodeResponse, error) {
resp, err := api.PostForm(c, uri, url.Values{
"client_id": {clientID},
"scope": {strings.Join(scopes, " ")},
})
if err != nil {
return nil, err
}
verificationURI := resp.Get("verification_uri")
if verificationURI == "" {
// Google's "OAuth 2.0 for TV and Limited-Input Device Applications" uses `verification_url`.
verificationURI = resp.Get("verification_url")
}
if resp.StatusCode == 401 || resp.StatusCode == 403 || resp.StatusCode == 404 || resp.StatusCode == 422 ||
(resp.StatusCode == 200 && verificationURI == "") ||
(resp.StatusCode == 400 && resp.Get("error") == "device_flow_disabled") ||
(resp.StatusCode == 400 && resp.Get("error") == "unauthorized_client") {
return nil, ErrUnsupported
}
if resp.StatusCode != 200 {
return nil, resp.Err()
}
intervalSeconds, err := strconv.Atoi(resp.Get("interval"))
if err != nil {
return nil, fmt.Errorf("could not parse interval=%q as integer: %w", resp.Get("interval"), err)
}
expiresIn, err := strconv.Atoi(resp.Get("expires_in"))
if err != nil {
return nil, fmt.Errorf("could not parse expires_in=%q as integer: %w", resp.Get("expires_in"), err)
}
return &CodeResponse{
DeviceCode: resp.Get("device_code"),
UserCode: resp.Get("user_code"),
VerificationURI: verificationURI,
VerificationURIComplete: resp.Get("verification_uri_complete"),
Interval: intervalSeconds,
ExpiresIn: expiresIn,
}, nil
}
const defaultGrantType = "urn:ietf:params:oauth:grant-type:device_code"
// PollToken polls the server at pollURL until an access token is granted or denied.
//
// Deprecated: use Wait.
func PollToken(c httpClient, pollURL string, clientID string, code *CodeResponse) (*api.AccessToken, error) {
return Wait(context.Background(), c, pollURL, WaitOptions{
ClientID: clientID,
DeviceCode: code,
})
}
// WaitOptions specifies parameters to poll the server with until authentication completes.
type WaitOptions struct {
// ClientID is the app client ID value.
ClientID string
// ClientSecret is the app client secret value. Optional: only pass if the server requires it.
ClientSecret string
// DeviceCode is the value obtained from RequestCode.
DeviceCode *CodeResponse
// GrantType overrides the default value specified by OAuth 2.0 Device Code. Optional.
GrantType string
newPoller pollerFactory
}
// Wait polls the server at uri until authorization completes.
func Wait(ctx context.Context, c httpClient, uri string, opts WaitOptions) (*api.AccessToken, error) {
checkInterval := time.Duration(opts.DeviceCode.Interval) * time.Second
expiresIn := time.Duration(opts.DeviceCode.ExpiresIn) * time.Second
grantType := opts.GrantType
if opts.GrantType == "" {
grantType = defaultGrantType
}
makePoller := opts.newPoller
if makePoller == nil {
makePoller = newPoller
}
_, poll := makePoller(ctx, checkInterval, expiresIn)
for {
if err := poll.Wait(); err != nil {
return nil, err
}
values := url.Values{
"client_id": {opts.ClientID},
"device_code": {opts.DeviceCode.DeviceCode},
"grant_type": {grantType},
}
// Google's "OAuth 2.0 for TV and Limited-Input Device Applications" requires `client_secret`.
if opts.ClientSecret != "" {
values.Add("client_secret", opts.ClientSecret)
}
// TODO: pass tctx down to the HTTP layer
resp, err := api.PostForm(c, uri, values)
if err != nil {
return nil, err
}
var apiError *api.Error
token, err := resp.AccessToken()
if err == nil {
return token, nil
} else if !(errors.As(err, &apiError) && apiError.Code == "authorization_pending") {
return nil, err
}
}
}
|