pax_global_header 0000666 0000000 0000000 00000000064 14366704443 0014525 g ustar 00root root 0000000 0000000 52 comment=adf5f733ffd14993aad9f3ff02c757a737f7c63b
oauth-1.0.1/ 0000775 0000000 0000000 00000000000 14366704443 0012644 5 ustar 00root root 0000000 0000000 oauth-1.0.1/.github/ 0000775 0000000 0000000 00000000000 14366704443 0014204 5 ustar 00root root 0000000 0000000 oauth-1.0.1/.github/workflows/ 0000775 0000000 0000000 00000000000 14366704443 0016241 5 ustar 00root root 0000000 0000000 oauth-1.0.1/.github/workflows/ci.yml 0000664 0000000 0000000 00000000751 14366704443 0017362 0 ustar 00root root 0000000 0000000 on: [push, pull_request]
name: CI
jobs:
test:
strategy:
matrix:
go: [ '1.13', '1.15', '1.16', '1.17', '1.18', '1.19' ]
os: [ ubuntu-latest, macos-latest, windows-latest ]
fail-fast: false
name: Test suite
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v2
- name: Setup Go
uses: actions/setup-go@v1
with:
go-version: ${{ matrix.go }}
- name: Run tests
run: go test -v ./...
oauth-1.0.1/.github/workflows/lint.yml 0000664 0000000 0000000 00000001555 14366704443 0017740 0 ustar 00root root 0000000 0000000 name: Lint
on:
push:
paths:
- "**.go"
- go.mod
- go.sum
pull_request:
paths:
- "**.go"
- go.mod
- go.sum
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.19
uses: actions/setup-go@v3
with:
go-version: 1.19
- name: Check out code
uses: actions/checkout@v2
- name: Verify dependencies
env:
LINT_VERSION: 1.50.1
run: |
go mod verify
go mod download
curl -fsSL https://github.com/golangci/golangci-lint/releases/download/v${LINT_VERSION}/golangci-lint-${LINT_VERSION}-linux-amd64.tar.gz | \
tar xz --strip-components 1 --wildcards \*/golangci-lint
mkdir -p bin && mv golangci-lint bin/
- name: Run checks
run: bin/golangci-lint run --out-format=github-actions
oauth-1.0.1/.golangci.yml 0000664 0000000 0000000 00000000443 14366704443 0015231 0 ustar 00root root 0000000 0000000 linters:
enable:
- gofmt
- godot
- revive
linters-settings:
godot:
# comments to be checked: `declarations`, `toplevel`, or `all`
scope: declarations
# check that each sentence starts with a capital letter
capital: true
issues:
exclude-use-default: false
oauth-1.0.1/LICENSE 0000664 0000000 0000000 00000002055 14366704443 0013653 0 ustar 00root root 0000000 0000000 MIT License
Copyright (c) 2020 GitHub, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
oauth-1.0.1/README.md 0000664 0000000 0000000 00000004633 14366704443 0014131 0 ustar 00root root 0000000 0000000 # oauth
A library for Go client applications that need to perform OAuth authorization against a server, typically GitHub.com.

Traditionally, OAuth for web applications involves redirecting to a URI after the user authorizes an app. While web apps (and some native client apps) can receive a browser redirect, client apps such as CLI applications do not have such an option.
To accommodate client apps, this library implements the [OAuth Device Authorization Grant][oauth-device] which [GitHub.com now supports][gh-device]. With Device flow, the user is presented with a one-time code that they will have to enter in a web browser while authorizing the app on the server. Device flow is suitable for cases where the web browser may be running on a separate device than the client app itself; for example a CLI application could run within a headless, containerized instance, but the user may complete authorization using a browser on their phone.
To transparently enable OAuth authorization on _any GitHub host_ (e.g. GHES instances without OAuth “Device flow” support), this library also bundles an implementation of OAuth web application flow in which the client app starts a local server at `http://127.0.0.1:/` that acts as a receiver for the browser redirect. First, Device flow is attempted, and the localhost server is used as fallback. With the localhost server, the user's web browser must be running on the same machine as the client application itself.
## Usage
- [OAuth Device flow with fallback](./examples_test.go)
- [manual OAuth Device flow](./device/examples_test.go)
- [manual OAuth web application flow](./webapp/examples_test.go)
Applications that need more control over the user experience around authentication should directly interface with `github.com/cli/oauth/device` and `github.com/cli/oauth/webapp` packages.
In theory, these packages would enable authorization on any OAuth-enabled host. In practice, however, this was only tested for authorizing with GitHub.
[oauth-device]: https://oauth.net/2/device-flow/
[gh-device]: https://docs.github.com/en/free-pro-team@latest/developers/apps/authorizing-oauth-apps#device-flow
oauth-1.0.1/api/ 0000775 0000000 0000000 00000000000 14366704443 0013415 5 ustar 00root root 0000000 0000000 oauth-1.0.1/api/access_token.go 0000664 0000000 0000000 00000001401 14366704443 0016401 0 ustar 00root root 0000000 0000000 package api
// AccessToken is an OAuth access token.
type AccessToken struct {
// The token value, typically a 40-character random string.
Token string
// The refresh token value, associated with the access token.
RefreshToken string
// The token type, e.g. "bearer".
Type string
// Space-separated list of OAuth scopes that this token grants.
Scope string
}
// AccessToken extracts the access token information from a server response.
func (f FormResponse) AccessToken() (*AccessToken, error) {
if accessToken := f.Get("access_token"); accessToken != "" {
return &AccessToken{
Token: accessToken,
RefreshToken: f.Get("refresh_token"),
Type: f.Get("token_type"),
Scope: f.Get("scope"),
}, nil
}
return nil, f.Err()
}
oauth-1.0.1/api/access_token_test.go 0000664 0000000 0000000 00000003371 14366704443 0017450 0 ustar 00root root 0000000 0000000 package api
import (
"net/url"
"reflect"
"testing"
)
func TestFormResponse_AccessToken(t *testing.T) {
tests := []struct {
name string
response FormResponse
want *AccessToken
wantErr *Error
}{
{
name: "with token",
response: FormResponse{
values: url.Values{
"access_token": []string{"ATOKEN"},
"token_type": []string{"bearer"},
"scope": []string{"repo gist"},
},
},
want: &AccessToken{
Token: "ATOKEN",
RefreshToken: "",
Type: "bearer",
Scope: "repo gist",
},
wantErr: nil,
},
{
name: "with refresh token",
response: FormResponse{
values: url.Values{
"access_token": []string{"ATOKEN"},
"refresh_token": []string{"AREFRESHTOKEN"},
"token_type": []string{"bearer"},
"scope": []string{"repo gist"},
},
},
want: &AccessToken{
Token: "ATOKEN",
RefreshToken: "AREFRESHTOKEN",
Type: "bearer",
Scope: "repo gist",
},
wantErr: nil,
},
{
name: "no token",
response: FormResponse{
StatusCode: 200,
values: url.Values{
"error": []string{"access_denied"},
},
},
want: nil,
wantErr: &Error{
Code: "access_denied",
ResponseCode: 200,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.response.AccessToken()
if err != nil {
apiError := err.(*Error)
if !reflect.DeepEqual(apiError, tt.wantErr) {
t.Fatalf("error %v, want %v", apiError, tt.wantErr)
}
} else if tt.wantErr != nil {
t.Fatalf("want error %v, got nil", tt.wantErr)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("FormResponse.AccessToken() = %v, want %v", got, tt.want)
}
})
}
}
oauth-1.0.1/api/form.go 0000664 0000000 0000000 00000004632 14366704443 0014714 0 ustar 00root root 0000000 0000000 // Package api implements request and response parsing logic shared between different OAuth strategies.
package api
import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"mime"
"net/http"
"net/url"
"strconv"
)
type httpClient interface {
PostForm(string, url.Values) (*http.Response, error)
}
// FormResponse is the parsed "www-form-urlencoded" response from the server.
type FormResponse struct {
StatusCode int
requestURI string
values url.Values
}
// Get the response value named k.
func (f FormResponse) Get(k string) string {
return f.values.Get(k)
}
// Err returns an Error object extracted from the response.
func (f FormResponse) Err() error {
return &Error{
RequestURI: f.requestURI,
ResponseCode: f.StatusCode,
Code: f.Get("error"),
message: f.Get("error_description"),
}
}
// Error is the result of an unexpected HTTP response from the server.
type Error struct {
Code string
ResponseCode int
RequestURI string
message string
}
func (e Error) Error() string {
if e.message != "" {
return fmt.Sprintf("%s (%s)", e.message, e.Code)
}
if e.Code != "" {
return e.Code
}
return fmt.Sprintf("HTTP %d", e.ResponseCode)
}
// PostForm makes an POST request by serializing input parameters as a form and parsing the response
// of the same type.
func PostForm(c httpClient, u string, params url.Values) (*FormResponse, error) {
resp, err := c.PostForm(u, params)
if err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
r := &FormResponse{
StatusCode: resp.StatusCode,
requestURI: u,
}
mediaType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
switch mediaType {
case "application/x-www-form-urlencoded":
var bb []byte
bb, err = ioutil.ReadAll(resp.Body)
if err != nil {
return r, err
}
r.values, err = url.ParseQuery(string(bb))
if err != nil {
return r, err
}
case "application/json":
var values map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&values); err != nil {
return r, err
}
r.values = make(url.Values)
for key, value := range values {
switch v := value.(type) {
case string:
r.values.Set(key, v)
case int64:
r.values.Set(key, strconv.FormatInt(v, 10))
case float64:
r.values.Set(key, strconv.FormatFloat(v, 'f', -1, 64))
}
}
default:
_, err = io.Copy(ioutil.Discard, resp.Body)
if err != nil {
return r, err
}
}
return r, nil
}
oauth-1.0.1/api/form_test.go 0000664 0000000 0000000 00000011400 14366704443 0015742 0 ustar 00root root 0000000 0000000 package api
import (
"bytes"
"io/ioutil"
"net/http"
"net/url"
"reflect"
"testing"
)
func TestFormResponse_Get(t *testing.T) {
tests := []struct {
name string
response FormResponse
key string
want string
}{
{
name: "blank",
response: FormResponse{},
key: "access_token",
want: "",
},
{
name: "with value",
response: FormResponse{
values: url.Values{
"access_token": []string{"ATOKEN"},
},
},
key: "access_token",
want: "ATOKEN",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.response.Get(tt.key); got != tt.want {
t.Errorf("FormResponse.Get() = %v, want %v", got, tt.want)
}
})
}
}
func TestFormResponse_Err(t *testing.T) {
tests := []struct {
name string
response FormResponse
wantErr Error
errorMsg string
}{
{
name: "blank",
response: FormResponse{},
wantErr: Error{},
errorMsg: "HTTP 0",
},
{
name: "with values",
response: FormResponse{
StatusCode: 422,
requestURI: "http://example.com/path",
values: url.Values{
"error": []string{"try_again"},
"error_description": []string{"maybe it works later"},
},
},
wantErr: Error{
Code: "try_again",
ResponseCode: 422,
RequestURI: "http://example.com/path",
},
errorMsg: "maybe it works later (try_again)",
},
{
name: "no values",
response: FormResponse{
StatusCode: 422,
requestURI: "http://example.com/path",
},
wantErr: Error{
Code: "",
ResponseCode: 422,
RequestURI: "http://example.com/path",
},
errorMsg: "HTTP 422",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.response.Err()
if err == nil {
t.Fatalf("FormResponse.Err() = %v, want %v", nil, tt.wantErr)
}
apiError := err.(*Error)
if apiError.Code != tt.wantErr.Code {
t.Errorf("Error.Code = %v, want %v", apiError.Code, tt.wantErr.Code)
}
if apiError.ResponseCode != tt.wantErr.ResponseCode {
t.Errorf("Error.ResponseCode = %v, want %v", apiError.ResponseCode, tt.wantErr.ResponseCode)
}
if apiError.RequestURI != tt.wantErr.RequestURI {
t.Errorf("Error.RequestURI = %v, want %v", apiError.RequestURI, tt.wantErr.RequestURI)
}
if apiError.Error() != tt.errorMsg {
t.Errorf("Error.Error() = %q, want %q", apiError.Error(), tt.errorMsg)
}
})
}
}
type apiClient struct {
status int
body string
contentType string
postCount int
}
func (c *apiClient) PostForm(u string, params url.Values) (*http.Response, error) {
c.postCount++
return &http.Response{
Body: ioutil.NopCloser(bytes.NewBufferString(c.body)),
Header: http.Header{
"Content-Type": {c.contentType},
},
StatusCode: c.status,
}, nil
}
func TestPostForm(t *testing.T) {
type args struct {
url string
params url.Values
}
tests := []struct {
name string
args args
http apiClient
want *FormResponse
wantErr bool
}{
{
name: "success urlencoded",
args: args{
url: "https://github.com/oauth",
},
http: apiClient{
body: "access_token=123abc&scopes=repo%20gist",
status: 200,
contentType: "application/x-www-form-urlencoded; charset=utf-8",
},
want: &FormResponse{
StatusCode: 200,
requestURI: "https://github.com/oauth",
values: url.Values{
"access_token": {"123abc"},
"scopes": {"repo gist"},
},
},
wantErr: false,
},
{
name: "success JSON",
args: args{
url: "https://github.com/oauth",
},
http: apiClient{
body: `{"access_token":"123abc", "scopes":"repo gist"}`,
status: 200,
contentType: "application/json; charset=utf-8",
},
want: &FormResponse{
StatusCode: 200,
requestURI: "https://github.com/oauth",
values: url.Values{
"access_token": {"123abc"},
"scopes": {"repo gist"},
},
},
wantErr: false,
},
{
name: "HTML response",
args: args{
url: "https://github.com/oauth",
},
http: apiClient{
body: "Something went wrong
",
status: 502,
contentType: "text/html",
},
want: &FormResponse{
StatusCode: 502,
requestURI: "https://github.com/oauth",
values: url.Values(nil),
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := PostForm(&tt.http, tt.args.url, tt.args.params)
if (err != nil) != tt.wantErr {
t.Errorf("PostForm() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.http.postCount != 1 {
t.Errorf("expected PostForm to happen 1 time; happened %d times", tt.http.postCount)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("PostForm() = %v, want %v", got, tt.want)
}
})
}
}
oauth-1.0.1/device/ 0000775 0000000 0000000 00000000000 14366704443 0014103 5 ustar 00root root 0000000 0000000 oauth-1.0.1/device/device_flow.go 0000664 0000000 0000000 00000013141 14366704443 0016720 0 ustar 00root root 0000000 0000000 // 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
}
}
}
oauth-1.0.1/device/device_flow_test.go 0000664 0000000 0000000 00000026744 14366704443 0017774 0 ustar 00root root 0000000 0000000 package device
import (
"bytes"
"context"
"errors"
"io/ioutil"
"net/http"
"net/url"
"reflect"
"testing"
"time"
"github.com/cli/oauth/api"
)
type apiStub struct {
status int
body string
contentType string
}
type postArgs struct {
url string
params url.Values
}
type apiClient struct {
stubs []apiStub
calls []postArgs
postCount int
}
func (c *apiClient) PostForm(u string, params url.Values) (*http.Response, error) {
stub := c.stubs[c.postCount]
c.calls = append(c.calls, postArgs{url: u, params: params})
c.postCount++
return &http.Response{
Body: ioutil.NopCloser(bytes.NewBufferString(stub.body)),
Header: http.Header{
"Content-Type": {stub.contentType},
},
StatusCode: stub.status,
}, nil
}
func TestRequestCode(t *testing.T) {
type args struct {
http apiClient
url string
clientID string
scopes []string
}
tests := []struct {
name string
args args
want *CodeResponse
wantErr string
posts []postArgs
}{
{
name: "success",
args: args{
http: apiClient{
stubs: []apiStub{
{
body: "verification_uri=http://verify.me&interval=5&expires_in=99&device_code=DEVIC&user_code=123-abc",
status: 200,
contentType: "application/x-www-form-urlencoded; charset=utf-8",
},
},
},
url: "https://github.com/oauth",
clientID: "CLIENT-ID",
scopes: []string{"repo", "gist"},
},
want: &CodeResponse{
DeviceCode: "DEVIC",
UserCode: "123-abc",
VerificationURI: "http://verify.me",
ExpiresIn: 99,
Interval: 5,
},
posts: []postArgs{
{
url: "https://github.com/oauth",
params: url.Values{
"client_id": {"CLIENT-ID"},
"scope": {"repo gist"},
},
},
},
},
{
name: "with verification_uri_complete",
args: args{
http: apiClient{
stubs: []apiStub{
{
body: "verification_uri=http://verify.me&interval=5&expires_in=99&device_code=DEVIC&user_code=123-abc&verification_uri_complete=http://verify.me/?code=123-abc",
status: 200,
contentType: "application/x-www-form-urlencoded; charset=utf-8",
},
},
},
url: "https://github.com/oauth",
clientID: "CLIENT-ID",
scopes: []string{"repo", "gist"},
},
want: &CodeResponse{
DeviceCode: "DEVIC",
UserCode: "123-abc",
VerificationURI: "http://verify.me",
VerificationURIComplete: "http://verify.me/?code=123-abc",
ExpiresIn: 99,
Interval: 5,
},
posts: []postArgs{
{
url: "https://github.com/oauth",
params: url.Values{
"client_id": {"CLIENT-ID"},
"scope": {"repo gist"},
},
},
},
},
{
name: "unsupported",
args: args{
http: apiClient{
stubs: []apiStub{
{
body: "",
status: 404,
contentType: "text/html",
},
},
},
url: "https://github.com/oauth",
clientID: "CLIENT-ID",
scopes: []string{"repo", "gist"},
},
wantErr: "device flow not supported",
posts: []postArgs{
{
url: "https://github.com/oauth",
params: url.Values{
"client_id": {"CLIENT-ID"},
"scope": {"repo gist"},
},
},
},
},
{
name: "unauthorized client",
args: args{
http: apiClient{
stubs: []apiStub{
{
body: "error=unauthorized_client",
status: 400,
contentType: "application/x-www-form-urlencoded; charset=utf-8",
},
},
},
url: "https://github.com/oauth",
clientID: "CLIENT-ID",
scopes: []string{"repo", "gist"},
},
wantErr: "device flow not supported",
posts: []postArgs{
{
url: "https://github.com/oauth",
params: url.Values{
"client_id": {"CLIENT-ID"},
"scope": {"repo gist"},
},
},
},
},
{
name: "device flow disabled",
args: args{
http: apiClient{
stubs: []apiStub{
{
body: "error=device_flow_disabled",
status: 400,
contentType: "application/x-www-form-urlencoded; charset=utf-8",
},
},
},
url: "https://github.com/oauth",
clientID: "CLIENT-ID",
scopes: []string{"repo", "gist"},
},
wantErr: "device flow not supported",
posts: []postArgs{
{
url: "https://github.com/oauth",
params: url.Values{
"client_id": {"CLIENT-ID"},
"scope": {"repo gist"},
},
},
},
},
{
name: "server error",
args: args{
http: apiClient{
stubs: []apiStub{
{
body: "Something went wrong
",
status: 502,
contentType: "text/html",
},
},
},
url: "https://github.com/oauth",
clientID: "CLIENT-ID",
scopes: []string{"repo", "gist"},
},
wantErr: "HTTP 502",
posts: []postArgs{
{
url: "https://github.com/oauth",
params: url.Values{
"client_id": {"CLIENT-ID"},
"scope": {"repo gist"},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := RequestCode(&tt.args.http, tt.args.url, tt.args.clientID, tt.args.scopes)
if (err != nil) != (tt.wantErr != "") {
t.Errorf("RequestCode() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr != "" && err.Error() != tt.wantErr {
t.Errorf("error = %q, want %q", err.Error(), tt.wantErr)
}
if tt.args.http.postCount != 1 {
t.Errorf("expected PostForm to happen 1 time; happened %d times", tt.args.http.postCount)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("RequestCode() = %v, want %v", got, tt.want)
}
if !reflect.DeepEqual(tt.args.http.calls, tt.posts) {
t.Errorf("PostForm() = %v, want %v", tt.args.http.calls, tt.posts)
}
})
}
}
func TestPollToken(t *testing.T) {
makeFakePoller := func(maxWaits int) pollerFactory {
return func(ctx context.Context, interval, expiresIn time.Duration) (context.Context, poller) {
return ctx, &fakePoller{maxWaits: maxWaits}
}
}
type args struct {
http apiClient
url string
opts WaitOptions
}
tests := []struct {
name string
args args
want *api.AccessToken
wantErr string
posts []postArgs
slept time.Duration
}{
{
name: "success",
args: args{
http: apiClient{
stubs: []apiStub{
{
body: "error=authorization_pending",
status: 200,
contentType: "application/x-www-form-urlencoded; charset=utf-8",
},
{
body: "access_token=123abc",
status: 200,
contentType: "application/x-www-form-urlencoded; charset=utf-8",
},
},
},
url: "https://github.com/oauth",
opts: WaitOptions{
ClientID: "CLIENT-ID",
DeviceCode: &CodeResponse{
DeviceCode: "DEVIC",
UserCode: "123-abc",
VerificationURI: "http://verify.me",
ExpiresIn: 99,
Interval: 5,
},
newPoller: makeFakePoller(2),
},
},
want: &api.AccessToken{
Token: "123abc",
},
posts: []postArgs{
{
url: "https://github.com/oauth",
params: url.Values{
"client_id": {"CLIENT-ID"},
"device_code": {"DEVIC"},
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
},
},
{
url: "https://github.com/oauth",
params: url.Values{
"client_id": {"CLIENT-ID"},
"device_code": {"DEVIC"},
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
},
},
},
},
{
name: "with client secret and grant type",
args: args{
http: apiClient{
stubs: []apiStub{
{
body: "access_token=123abc",
status: 200,
contentType: "application/x-www-form-urlencoded; charset=utf-8",
},
},
},
url: "https://github.com/oauth",
opts: WaitOptions{
ClientID: "CLIENT-ID",
ClientSecret: "SEKRIT",
GrantType: "device_code",
DeviceCode: &CodeResponse{
DeviceCode: "DEVIC",
UserCode: "123-abc",
VerificationURI: "http://verify.me",
ExpiresIn: 99,
Interval: 5,
},
newPoller: makeFakePoller(1),
},
},
want: &api.AccessToken{
Token: "123abc",
},
posts: []postArgs{
{
url: "https://github.com/oauth",
params: url.Values{
"client_id": {"CLIENT-ID"},
"client_secret": {"SEKRIT"},
"device_code": {"DEVIC"},
"grant_type": {"device_code"},
},
},
},
},
{
name: "timed out",
args: args{
http: apiClient{
stubs: []apiStub{
{
body: "error=authorization_pending",
status: 200,
contentType: "application/x-www-form-urlencoded; charset=utf-8",
},
{
body: "error=authorization_pending",
status: 200,
contentType: "application/x-www-form-urlencoded; charset=utf-8",
},
},
},
url: "https://github.com/oauth",
opts: WaitOptions{
ClientID: "CLIENT-ID",
DeviceCode: &CodeResponse{
DeviceCode: "DEVIC",
UserCode: "123-abc",
VerificationURI: "http://verify.me",
ExpiresIn: 14,
Interval: 5,
},
newPoller: makeFakePoller(2),
},
},
wantErr: "context deadline exceeded",
posts: []postArgs{
{
url: "https://github.com/oauth",
params: url.Values{
"client_id": {"CLIENT-ID"},
"device_code": {"DEVIC"},
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
},
},
{
url: "https://github.com/oauth",
params: url.Values{
"client_id": {"CLIENT-ID"},
"device_code": {"DEVIC"},
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
},
},
},
},
{
name: "access denied",
args: args{
http: apiClient{
stubs: []apiStub{
{
body: "error=access_denied",
status: 200,
contentType: "application/x-www-form-urlencoded; charset=utf-8",
},
},
},
url: "https://github.com/oauth",
opts: WaitOptions{
ClientID: "CLIENT-ID",
DeviceCode: &CodeResponse{
DeviceCode: "DEVIC",
UserCode: "123-abc",
VerificationURI: "http://verify.me",
ExpiresIn: 99,
Interval: 5,
},
newPoller: makeFakePoller(1),
},
},
wantErr: "access_denied",
posts: []postArgs{
{
url: "https://github.com/oauth",
params: url.Values{
"client_id": {"CLIENT-ID"},
"device_code": {"DEVIC"},
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Wait(context.Background(), &tt.args.http, tt.args.url, tt.args.opts)
if (err != nil) != (tt.wantErr != "") {
t.Errorf("PollToken() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr != "" && err.Error() != tt.wantErr {
t.Errorf("PollToken error = %q, want %q", err.Error(), tt.wantErr)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("PollToken() = %v, want %v", got, tt.want)
}
if !reflect.DeepEqual(tt.args.http.calls, tt.posts) {
t.Errorf("PostForm() = %v, want %v", tt.args.http.calls, tt.posts)
}
})
}
}
type fakePoller struct {
maxWaits int
count int
}
func (p *fakePoller) Wait() error {
if p.count == p.maxWaits {
return errors.New("context deadline exceeded")
}
p.count++
return nil
}
func (p *fakePoller) Cancel() {
}
oauth-1.0.1/device/examples_test.go 0000664 0000000 0000000 00000001757 14366704443 0017321 0 ustar 00root root 0000000 0000000 package device_test
import (
"context"
"fmt"
"net/http"
"os"
"github.com/cli/oauth/device"
)
// This demonstrates how to perform OAuth Device Authorization Flow for GitHub.com.
// After RequestCode successfully completes, the client app should prompt the user to copy
// the UserCode and to open VerificationURI in their web browser to enter the code.
func ExampleRequestCode() {
clientID := os.Getenv("OAUTH_CLIENT_ID")
scopes := []string{"repo", "read:org"}
httpClient := http.DefaultClient
code, err := device.RequestCode(httpClient, "https://github.com/login/device/code", clientID, scopes)
if err != nil {
panic(err)
}
fmt.Printf("Copy code: %s\n", code.UserCode)
fmt.Printf("then open: %s\n", code.VerificationURI)
accessToken, err := device.Wait(context.TODO(), httpClient, "https://github.com/login/oauth/access_token", device.WaitOptions{
ClientID: clientID,
DeviceCode: code,
})
if err != nil {
panic(err)
}
fmt.Printf("Access token: %s\n", accessToken.Token)
}
oauth-1.0.1/device/poller.go 0000664 0000000 0000000 00000001407 14366704443 0015731 0 ustar 00root root 0000000 0000000 package device
import (
"context"
"time"
)
type poller interface {
Wait() error
Cancel()
}
type pollerFactory func(context.Context, time.Duration, time.Duration) (context.Context, poller)
func newPoller(ctx context.Context, checkInteval, expiresIn time.Duration) (context.Context, poller) {
c, cancel := context.WithTimeout(ctx, expiresIn)
return c, &intervalPoller{
ctx: c,
interval: checkInteval,
cancelFunc: cancel,
}
}
type intervalPoller struct {
ctx context.Context
interval time.Duration
cancelFunc func()
}
func (p intervalPoller) Wait() error {
t := time.NewTimer(p.interval)
select {
case <-p.ctx.Done():
t.Stop()
return p.ctx.Err()
case <-t.C:
return nil
}
}
func (p intervalPoller) Cancel() {
p.cancelFunc()
}
oauth-1.0.1/examples_test.go 0000664 0000000 0000000 00000001614 14366704443 0016052 0 ustar 00root root 0000000 0000000 package oauth_test
import (
"fmt"
"os"
"github.com/cli/oauth"
)
// DetectFlow attempts to initiate OAuth Device flow with the server and falls back to OAuth Web
// application flow if Device flow seems unsupported. This approach isn't strictly needed for
// github.com, as its Device flow support is globally available, but it enables logging in to
// self-hosted GitHub instances as well.
func ExampleFlow_DetectFlow() {
flow := &oauth.Flow{
Host: oauth.GitHubHost("https://github.com"),
ClientID: os.Getenv("OAUTH_CLIENT_ID"),
ClientSecret: os.Getenv("OAUTH_CLIENT_SECRET"), // only applicable to web app flow
CallbackURI: "http://127.0.0.1/callback", // only applicable to web app flow
Scopes: []string{"repo", "read:org", "gist"},
}
accessToken, err := flow.DetectFlow()
if err != nil {
panic(err)
}
fmt.Printf("Access token: %s\n", accessToken.Token)
}
oauth-1.0.1/go.mod 0000664 0000000 0000000 00000000114 14366704443 0013746 0 ustar 00root root 0000000 0000000 module github.com/cli/oauth
go 1.13
require github.com/cli/browser v1.0.0
oauth-1.0.1/go.sum 0000664 0000000 0000000 00000000510 14366704443 0013773 0 ustar 00root root 0000000 0000000 github.com/cli/browser v1.0.0 h1:RIleZgXrhdiCVgFBSjtWwkLPUCWyhhhN5k5HGSBt1js=
github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q=
github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI=
github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
oauth-1.0.1/oauth.go 0000664 0000000 0000000 00000005122 14366704443 0014313 0 ustar 00root root 0000000 0000000 // Package oauth is a library for Go client applications that need to perform OAuth authorization
// against a server, typically GitHub.com.
package oauth
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"github.com/cli/oauth/api"
"github.com/cli/oauth/device"
)
type httpClient interface {
PostForm(string, url.Values) (*http.Response, error)
}
// Host defines the endpoints used to authorize against an OAuth server.
type Host struct {
DeviceCodeURL string
AuthorizeURL string
TokenURL string
}
// GitHubHost constructs a Host from the given URL to a GitHub instance.
func GitHubHost(hostURL string) *Host {
u, _ := url.Parse(hostURL)
return &Host{
DeviceCodeURL: fmt.Sprintf("%s://%s/login/device/code", u.Scheme, u.Host),
AuthorizeURL: fmt.Sprintf("%s://%s/login/oauth/authorize", u.Scheme, u.Host),
TokenURL: fmt.Sprintf("%s://%s/login/oauth/access_token", u.Scheme, u.Host),
}
}
// Flow facilitates a single OAuth authorization flow.
type Flow struct {
// The hostname to authorize the app with.
//
// Deprecated: Use Host instead.
Hostname string
// Host configuration to authorize the app with.
Host *Host
// OAuth scopes to request from the user.
Scopes []string
// OAuth application ID.
ClientID string
// OAuth application secret. Only applicable in web application flow.
ClientSecret string
// The localhost URI for web application flow callback, e.g. "http://127.0.0.1/callback".
CallbackURI string
// Display a one-time code to the user. Receives the code and the browser URL as arguments. Defaults to printing the
// code to the user on Stdout with instructions to copy the code and to press Enter to continue in their browser.
DisplayCode func(string, string) error
// Open a web browser at a URL. Defaults to opening the default system browser.
BrowseURL func(string) error
// Render an HTML page to the user upon completion of web application flow. The default is to
// render a simple message that informs the user they can close the browser tab and return to the app.
WriteSuccessHTML func(io.Writer)
// The HTTP client to use for API POST requests. Defaults to http.DefaultClient.
HTTPClient httpClient
// The stream to listen to keyboard input on. Defaults to os.Stdin.
Stdin io.Reader
// The stream to print UI messages to. Defaults to os.Stdout.
Stdout io.Writer
}
// DetectFlow tries to perform Device flow first and falls back to Web application flow.
func (oa *Flow) DetectFlow() (*api.AccessToken, error) {
accessToken, err := oa.DeviceFlow()
if errors.Is(err, device.ErrUnsupported) {
return oa.WebAppFlow()
}
return accessToken, err
}
oauth-1.0.1/oauth_device.go 0000664 0000000 0000000 00000003121 14366704443 0015627 0 ustar 00root root 0000000 0000000 package oauth
import (
"bufio"
"context"
"fmt"
"io"
"net/http"
"os"
"github.com/cli/browser"
"github.com/cli/oauth/api"
"github.com/cli/oauth/device"
)
// DeviceFlow captures the full OAuth Device flow, including prompting the user to copy a one-time
// code and opening their web browser, and returns an access token upon completion.
func (oa *Flow) DeviceFlow() (*api.AccessToken, error) {
httpClient := oa.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
stdin := oa.Stdin
if stdin == nil {
stdin = os.Stdin
}
stdout := oa.Stdout
if stdout == nil {
stdout = os.Stdout
}
host := oa.Host
if host == nil {
host = GitHubHost("https://" + oa.Hostname)
}
code, err := device.RequestCode(httpClient, host.DeviceCodeURL, oa.ClientID, oa.Scopes)
if err != nil {
return nil, err
}
if oa.DisplayCode == nil {
fmt.Fprintf(stdout, "First, copy your one-time code: %s\n", code.UserCode)
fmt.Fprint(stdout, "Then press [Enter] to continue in the web browser... ")
_ = waitForEnter(stdin)
} else {
err := oa.DisplayCode(code.UserCode, code.VerificationURI)
if err != nil {
return nil, err
}
}
browseURL := oa.BrowseURL
if browseURL == nil {
browseURL = browser.OpenURL
}
if err = browseURL(code.VerificationURI); err != nil {
return nil, fmt.Errorf("error opening the web browser: %w", err)
}
return device.Wait(context.TODO(), httpClient, host.TokenURL, device.WaitOptions{
ClientID: oa.ClientID,
DeviceCode: code,
})
}
func waitForEnter(r io.Reader) error {
scanner := bufio.NewScanner(r)
scanner.Scan()
return scanner.Err()
}
oauth-1.0.1/oauth_webapp.go 0000664 0000000 0000000 00000002406 14366704443 0015653 0 ustar 00root root 0000000 0000000 package oauth
import (
"context"
"fmt"
"net/http"
"github.com/cli/browser"
"github.com/cli/oauth/api"
"github.com/cli/oauth/webapp"
)
// WebAppFlow starts a local HTTP server, opens the web browser to initiate the OAuth Web application
// flow, blocks until the user completes authorization and is redirected back, and returns the access token.
func (oa *Flow) WebAppFlow() (*api.AccessToken, error) {
host := oa.Host
if host == nil {
host = GitHubHost("https://" + oa.Hostname)
}
flow, err := webapp.InitFlow()
if err != nil {
return nil, err
}
params := webapp.BrowserParams{
ClientID: oa.ClientID,
RedirectURI: oa.CallbackURI,
Scopes: oa.Scopes,
AllowSignup: true,
}
browserURL, err := flow.BrowserURL(host.AuthorizeURL, params)
if err != nil {
return nil, err
}
go func() {
_ = flow.StartServer(oa.WriteSuccessHTML)
}()
browseURL := oa.BrowseURL
if browseURL == nil {
browseURL = browser.OpenURL
}
err = browseURL(browserURL)
if err != nil {
return nil, fmt.Errorf("error opening the web browser: %w", err)
}
httpClient := oa.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
return flow.Wait(context.TODO(), httpClient, host.TokenURL, webapp.WaitOptions{
ClientSecret: oa.ClientSecret,
})
}
oauth-1.0.1/webapp/ 0000775 0000000 0000000 00000000000 14366704443 0014122 5 ustar 00root root 0000000 0000000 oauth-1.0.1/webapp/examples_test.go 0000664 0000000 0000000 00000002346 14366704443 0017333 0 ustar 00root root 0000000 0000000 package webapp_test
import (
"context"
"fmt"
"net/http"
"os"
"github.com/cli/browser"
"github.com/cli/oauth/webapp"
)
// Initiate the OAuth App Authorization Flow for GitHub.com.
func ExampleInitFlow() {
clientID := os.Getenv("OAUTH_CLIENT_ID")
clientSecret := os.Getenv("OAUTH_CLIENT_SECRET")
callbackURL := "http://127.0.0.1/callback"
flow, err := webapp.InitFlow()
if err != nil {
panic(err)
}
params := webapp.BrowserParams{
ClientID: clientID,
RedirectURI: callbackURL,
Scopes: []string{"repo", "read:org"},
AllowSignup: true,
}
browserURL, err := flow.BrowserURL("https://github.com/login/oauth/authorize", params)
if err != nil {
panic(err)
}
// A localhost server on a random available port will receive the web redirect.
go func() {
_ = flow.StartServer(nil)
}()
// Note: the user's web browser must run on the same device as the running app.
err = browser.OpenURL(browserURL)
if err != nil {
panic(err)
}
httpClient := http.DefaultClient
accessToken, err := flow.Wait(context.TODO(), httpClient, "https://github.com/login/oauth/access_token", webapp.WaitOptions{
ClientSecret: clientSecret,
})
if err != nil {
panic(err)
}
fmt.Printf("Access token: %s\n", accessToken.Token)
}
oauth-1.0.1/webapp/local_server.go 0000664 0000000 0000000 00000003324 14366704443 0017133 0 ustar 00root root 0000000 0000000 package webapp
import (
"context"
"fmt"
"io"
"net"
"net/http"
)
// CodeResponse represents the code received by the local server's callback handler.
type CodeResponse struct {
Code string
State string
}
// bindLocalServer initializes a LocalServer that will listen on a randomly available TCP port.
func bindLocalServer() (*localServer, error) {
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
return nil, err
}
return &localServer{
listener: listener,
resultChan: make(chan CodeResponse, 1),
}, nil
}
type localServer struct {
CallbackPath string
WriteSuccessHTML func(w io.Writer)
resultChan chan (CodeResponse)
listener net.Listener
}
func (s *localServer) Port() int {
return s.listener.Addr().(*net.TCPAddr).Port
}
func (s *localServer) Close() error {
return s.listener.Close()
}
func (s *localServer) Serve() error {
return http.Serve(s.listener, s)
}
func (s *localServer) WaitForCode(ctx context.Context) (CodeResponse, error) {
select {
case <-ctx.Done():
return CodeResponse{}, ctx.Err()
case code := <-s.resultChan:
return code, nil
}
}
// ServeHTTP implements http.Handler.
func (s *localServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if s.CallbackPath != "" && r.URL.Path != s.CallbackPath {
w.WriteHeader(404)
return
}
defer func() {
_ = s.Close()
}()
params := r.URL.Query()
s.resultChan <- CodeResponse{
Code: params.Get("code"),
State: params.Get("state"),
}
w.Header().Add("content-type", "text/html")
if s.WriteSuccessHTML != nil {
s.WriteSuccessHTML(w)
} else {
defaultSuccessHTML(w)
}
}
func defaultSuccessHTML(w io.Writer) {
fmt.Fprintf(w, "You may now close this page and return to the client app.
")
}
oauth-1.0.1/webapp/local_server_test.go 0000664 0000000 0000000 00000003643 14366704443 0020176 0 ustar 00root root 0000000 0000000 package webapp
import (
"bytes"
"errors"
"net"
"net/http"
"testing"
)
type fakeListener struct {
closed bool
addr *net.TCPAddr
}
func (l *fakeListener) Accept() (net.Conn, error) {
return nil, errors.New("not implemented")
}
func (l *fakeListener) Close() error {
l.closed = true
return nil
}
func (l *fakeListener) Addr() net.Addr {
return l.addr
}
type responseWriter struct {
header http.Header
written bytes.Buffer
status int
}
func (w *responseWriter) Header() http.Header {
if w.header == nil {
w.header = make(http.Header)
}
return w.header
}
func (w *responseWriter) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = 200
}
return w.written.Write(b)
}
func (w *responseWriter) WriteHeader(s int) {
w.status = s
}
func Test_localServer_ServeHTTP(t *testing.T) {
listener := &fakeListener{}
s := &localServer{
CallbackPath: "/hello",
resultChan: make(chan CodeResponse, 1),
listener: listener,
}
w1 := &responseWriter{}
w2 := &responseWriter{}
serveChan := make(chan struct{})
go func() {
req1, _ := http.NewRequest("GET", "http://127.0.0.1:12345/favicon.ico", nil)
s.ServeHTTP(w1, req1)
req2, _ := http.NewRequest("GET", "http://127.0.0.1:12345/hello?code=ABC-123&state=xy%2Fz", nil)
s.ServeHTTP(w2, req2)
serveChan <- struct{}{}
}()
res := <-s.resultChan
if res.Code != "ABC-123" {
t.Errorf("got code %q", res.Code)
}
if res.State != "xy/z" {
t.Errorf("got state %q", res.State)
}
<-serveChan
if w1.status != 404 {
t.Errorf("status = %d", w2.status)
}
if w2.status != 200 {
t.Errorf("status = %d", w2.status)
}
if w2.written.String() != "You may now close this page and return to the client app.
" {
t.Errorf("written: %q", w2.written.String())
}
if w2.Header().Get("Content-Type") != "text/html" {
t.Errorf("Content-Type: %v", w2.Header().Get("Content-Type"))
}
if !listener.closed {
t.Error("expected listener to be closed")
}
}
oauth-1.0.1/webapp/webapp_flow.go 0000664 0000000 0000000 00000006663 14366704443 0016771 0 ustar 00root root 0000000 0000000 // Package webapp implements the OAuth Web Application authorization flow for client applications by
// starting a server at localhost to receive the web redirect after the user has authorized the application.
package webapp
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/cli/oauth/api"
)
type httpClient interface {
PostForm(string, url.Values) (*http.Response, error)
}
// Flow holds the state for the steps of OAuth Web Application flow.
type Flow struct {
server *localServer
clientID string
state string
}
// InitFlow creates a new Flow instance by detecting a locally available port number.
func InitFlow() (*Flow, error) {
server, err := bindLocalServer()
if err != nil {
return nil, err
}
state, _ := randomString(20)
return &Flow{
server: server,
state: state,
}, nil
}
// BrowserParams are GET query parameters for initiating the web flow.
type BrowserParams struct {
ClientID string
RedirectURI string
Scopes []string
LoginHandle string
AllowSignup bool
}
// BrowserURL appends GET query parameters to baseURL and returns the url that the user should
// navigate to in their web browser.
func (flow *Flow) BrowserURL(baseURL string, params BrowserParams) (string, error) {
ru, err := url.Parse(params.RedirectURI)
if err != nil {
return "", err
}
ru.Host = fmt.Sprintf("%s:%d", ru.Hostname(), flow.server.Port())
flow.server.CallbackPath = ru.Path
flow.clientID = params.ClientID
q := url.Values{}
q.Set("client_id", params.ClientID)
q.Set("redirect_uri", ru.String())
q.Set("scope", strings.Join(params.Scopes, " "))
q.Set("state", flow.state)
if params.LoginHandle != "" {
q.Set("login", params.LoginHandle)
}
if !params.AllowSignup {
q.Set("allow_signup", "false")
}
return fmt.Sprintf("%s?%s", baseURL, q.Encode()), nil
}
// StartServer starts the localhost server and blocks until it has received the web redirect. The
// writeSuccess function can be used to render a HTML page to the user upon completion.
func (flow *Flow) StartServer(writeSuccess func(io.Writer)) error {
flow.server.WriteSuccessHTML = writeSuccess
return flow.server.Serve()
}
// AccessToken blocks until the browser flow has completed and returns the access token.
//
// Deprecated: use Wait.
func (flow *Flow) AccessToken(c httpClient, tokenURL, clientSecret string) (*api.AccessToken, error) {
return flow.Wait(context.Background(), c, tokenURL, WaitOptions{ClientSecret: clientSecret})
}
// WaitOptions specifies parameters to exchange the access token for.
type WaitOptions struct {
// ClientSecret is the app client secret value.
ClientSecret string
}
// Wait blocks until the browser flow has completed and returns the access token.
func (flow *Flow) Wait(ctx context.Context, c httpClient, tokenURL string, opts WaitOptions) (*api.AccessToken, error) {
code, err := flow.server.WaitForCode(ctx)
if err != nil {
return nil, err
}
if code.State != flow.state {
return nil, errors.New("state mismatch")
}
resp, err := api.PostForm(c, tokenURL,
url.Values{
"client_id": {flow.clientID},
"client_secret": {opts.ClientSecret},
"code": {code.Code},
"state": {flow.state},
})
if err != nil {
return nil, err
}
return resp.AccessToken()
}
func randomString(length int) (string, error) {
b := make([]byte, length/2)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
oauth-1.0.1/webapp/webapp_flow_test.go 0000664 0000000 0000000 00000006416 14366704443 0020024 0 ustar 00root root 0000000 0000000 package webapp
import (
"bytes"
"context"
"io/ioutil"
"net"
"net/http"
"net/url"
"testing"
)
func TestFlow_BrowserURL(t *testing.T) {
server := &localServer{
listener: &fakeListener{
addr: &net.TCPAddr{Port: 12345},
},
}
type fields struct {
server *localServer
clientID string
state string
}
type args struct {
baseURL string
params BrowserParams
}
tests := []struct {
name string
fields fields
args args
want string
wantErr bool
}{
{
name: "happy path",
fields: fields{
server: server,
state: "xy/z",
},
args: args{
baseURL: "https://github.com/authorize",
params: BrowserParams{
ClientID: "CLIENT-ID",
RedirectURI: "http://127.0.0.1/hello",
Scopes: []string{"repo", "read:org"},
AllowSignup: true,
},
},
want: "https://github.com/authorize?client_id=CLIENT-ID&redirect_uri=http%3A%2F%2F127.0.0.1%3A12345%2Fhello&scope=repo+read%3Aorg&state=xy%2Fz",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
flow := &Flow{
server: tt.fields.server,
clientID: tt.fields.clientID,
state: tt.fields.state,
}
got, err := flow.BrowserURL(tt.args.baseURL, tt.args.params)
if (err != nil) != tt.wantErr {
t.Errorf("Flow.BrowserURL() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Flow.BrowserURL() = %v, want %v", got, tt.want)
}
})
}
}
type apiStub struct {
status int
body string
contentType string
}
type postArgs struct {
url string
params url.Values
}
type apiClient struct {
stubs []apiStub
calls []postArgs
postCount int
}
func (c *apiClient) PostForm(u string, params url.Values) (*http.Response, error) {
stub := c.stubs[c.postCount]
c.calls = append(c.calls, postArgs{url: u, params: params})
c.postCount++
return &http.Response{
Body: ioutil.NopCloser(bytes.NewBufferString(stub.body)),
Header: http.Header{
"Content-Type": {stub.contentType},
},
StatusCode: stub.status,
}, nil
}
func TestFlow_AccessToken(t *testing.T) {
server := &localServer{
listener: &fakeListener{
addr: &net.TCPAddr{Port: 12345},
},
resultChan: make(chan CodeResponse),
}
flow := Flow{
server: server,
clientID: "CLIENT-ID",
state: "xy/z",
}
client := &apiClient{
stubs: []apiStub{
{
body: "access_token=ATOKEN&token_type=bearer&scope=repo+gist",
status: 200,
contentType: "application/x-www-form-urlencoded; charset=utf-8",
},
},
}
go func() {
server.resultChan <- CodeResponse{
Code: "ABC-123",
State: "xy/z",
}
}()
token, err := flow.Wait(context.Background(), client, "https://github.com/access_token", WaitOptions{ClientSecret: "OAUTH-SEKRIT"})
if err != nil {
t.Fatalf("AccessToken() error: %v", err)
}
if len(client.calls) != 1 {
t.Fatalf("expected 1 HTTP POST, got %d", len(client.calls))
}
apiPost := client.calls[0]
if apiPost.url != "https://github.com/access_token" {
t.Errorf("HTTP POST to %q", apiPost.url)
}
if params := apiPost.params.Encode(); params != "client_id=CLIENT-ID&client_secret=OAUTH-SEKRIT&code=ABC-123&state=xy%2Fz" {
t.Errorf("HTTP POST params: %v", params)
}
if token.Token != "ATOKEN" {
t.Errorf("Token = %q", token.Token)
}
}