pax_global_header00006660000000000000000000000064141323370120014506gustar00rootroot0000000000000052 comment=ce77fe604c66e8d9b67e69bcc0ba1b6b8153f5c8 oauth-0.9.0/000077500000000000000000000000001413233701200126345ustar00rootroot00000000000000oauth-0.9.0/.github/000077500000000000000000000000001413233701200141745ustar00rootroot00000000000000oauth-0.9.0/.github/workflows/000077500000000000000000000000001413233701200162315ustar00rootroot00000000000000oauth-0.9.0/.github/workflows/ci.yml000066400000000000000000000007111413233701200173460ustar00rootroot00000000000000on: [push, pull_request] name: CI jobs: test: strategy: matrix: go: [ '1.13', '1.15' ] 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-0.9.0/.github/workflows/lint.yml000066400000000000000000000015551413233701200177300ustar00rootroot00000000000000name: 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.15 uses: actions/setup-go@v2 with: go-version: 1.15 - name: Check out code uses: actions/checkout@v2 - name: Verify dependencies env: LINT_VERSION: 1.33.0 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-0.9.0/.golangci.yml000066400000000000000000000004431413233701200152210ustar00rootroot00000000000000linters: enable: - gofmt - godot - golint 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-0.9.0/LICENSE000066400000000000000000000020551413233701200136430ustar00rootroot00000000000000MIT 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-0.9.0/README.md000066400000000000000000000046331413233701200141210ustar00rootroot00000000000000# 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-0.9.0/api/000077500000000000000000000000001413233701200134055ustar00rootroot00000000000000oauth-0.9.0/api/access_token.go000066400000000000000000000014011413233701200163710ustar00rootroot00000000000000package 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-0.9.0/api/access_token_test.go000066400000000000000000000033711413233701200174400ustar00rootroot00000000000000package 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-0.9.0/api/form.go000066400000000000000000000044621413233701200147050ustar00rootroot00000000000000package 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-0.9.0/api/form_test.go000066400000000000000000000114001413233701200157320ustar00rootroot00000000000000package 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-0.9.0/device/000077500000000000000000000000001413233701200140735ustar00rootroot00000000000000oauth-0.9.0/device/device_flow.go000066400000000000000000000101411413233701200167050ustar00rootroot00000000000000// 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 ( "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 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 timeNow func() time.Time timeSleep func(time.Duration) } // 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 resp.StatusCode == 401 || resp.StatusCode == 403 || resp.StatusCode == 404 || resp.StatusCode == 422 || (resp.StatusCode == 200 && verificationURI == "") || (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, Interval: intervalSeconds, ExpiresIn: expiresIn, }, nil } const grantType = "urn:ietf:params:oauth:grant-type:device_code" // PollToken polls the server at pollURL until an access token is granted or denied. func PollToken(c httpClient, pollURL string, clientID string, code *CodeResponse) (*api.AccessToken, error) { timeNow := code.timeNow if timeNow == nil { timeNow = time.Now } timeSleep := code.timeSleep if timeSleep == nil { timeSleep = time.Sleep } checkInterval := time.Duration(code.Interval) * time.Second expiresAt := timeNow().Add(time.Duration(code.ExpiresIn) * time.Second) for { timeSleep(checkInterval) resp, err := api.PostForm(c, pollURL, url.Values{ "client_id": {clientID}, "device_code": {code.DeviceCode}, "grant_type": {grantType}, }) 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 } if timeNow().After(expiresAt) { return nil, ErrTimeout } } } oauth-0.9.0/device/device_flow_test.go000066400000000000000000000221051413233701200177470ustar00rootroot00000000000000package device import ( "bytes" "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: "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: "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) { var totalSlept time.Duration mockSleep := func(d time.Duration) { totalSlept += d } duration := func(d string) time.Duration { res, _ := time.ParseDuration(d) return res } clock := func(durations ...string) func() time.Time { count := 0 now := time.Now() return func() time.Time { t := now.Add(duration(durations[count])) count++ return t } } type args struct { http apiClient url string clientID string code *CodeResponse } 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", clientID: "CLIENT-ID", code: &CodeResponse{ DeviceCode: "DEVIC", UserCode: "123-abc", VerificationURI: "http://verify.me", ExpiresIn: 99, Interval: 5, timeSleep: mockSleep, timeNow: clock("0", "5s", "10s"), }, }, want: &api.AccessToken{ Token: "123abc", }, slept: duration("10s"), 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: "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", clientID: "CLIENT-ID", code: &CodeResponse{ DeviceCode: "DEVIC", UserCode: "123-abc", VerificationURI: "http://verify.me", ExpiresIn: 99, Interval: 5, timeSleep: mockSleep, timeNow: clock("0", "5s", "15m"), }, }, wantErr: "authentication timed out", slept: duration("10s"), 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", clientID: "CLIENT-ID", code: &CodeResponse{ DeviceCode: "DEVIC", UserCode: "123-abc", VerificationURI: "http://verify.me", ExpiresIn: 99, Interval: 5, timeSleep: mockSleep, timeNow: clock("0", "5s"), }, }, wantErr: "access_denied", slept: duration("5s"), 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) { totalSlept = 0 got, err := PollToken(&tt.args.http, tt.args.url, tt.args.clientID, tt.args.code) 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) } if totalSlept != tt.slept { t.Errorf("slept %v, wanted %v", totalSlept, tt.slept) } }) } } oauth-0.9.0/device/examples_test.go000066400000000000000000000015471413233701200173060ustar00rootroot00000000000000package device import ( "fmt" "net/http" "os" ) // 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 Example() { clientID := os.Getenv("OAUTH_CLIENT_ID") scopes := []string{"repo", "read:org"} httpClient := http.DefaultClient code, err := 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 := PollToken(httpClient, "https://github.com/login/oauth/access_token", clientID, code) if err != nil { panic(err) } fmt.Printf("Access token: %s\n", accessToken.Token) } oauth-0.9.0/examples_test.go000066400000000000000000000014641413233701200160450ustar00rootroot00000000000000package oauth import ( "fmt" "os" ) // Try initiating OAuth Device flow on the server and fall 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 enables logging in to hosted GitHub instances as well. func Example() { flow := &Flow{ Host: 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-0.9.0/go.mod000066400000000000000000000001141413233701200137360ustar00rootroot00000000000000module github.com/cli/oauth go 1.13 require github.com/cli/browser v1.0.0 oauth-0.9.0/go.sum000066400000000000000000000005101413233701200137630ustar00rootroot00000000000000github.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-0.9.0/oauth.go000066400000000000000000000051221413233701200143030ustar00rootroot00000000000000// 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-0.9.0/oauth_device.go000066400000000000000000000030071413233701200156220ustar00rootroot00000000000000package oauth import ( "bufio" "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.PollToken(httpClient, host.TokenURL, oa.ClientID, code) } func waitForEnter(r io.Reader) error { scanner := bufio.NewScanner(r) scanner.Scan() return scanner.Err() } oauth-0.9.0/oauth_webapp.go000066400000000000000000000023121413233701200156370ustar00rootroot00000000000000package oauth import ( "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.AccessToken(httpClient, host.TokenURL, oa.ClientSecret) } oauth-0.9.0/webapp/000077500000000000000000000000001413233701200141125ustar00rootroot00000000000000oauth-0.9.0/webapp/examples_test.go000066400000000000000000000023031413233701200173140ustar00rootroot00000000000000package webapp import ( "fmt" "net/http" "os" "github.com/cli/browser" ) // This demonstrates how to perform OAuth App Authorization Flow for GitHub.com. // Ensure that the OAuth app on GitHub lists the callback URL: "http://127.0.0.1/callback" func Example() { clientID := os.Getenv("OAUTH_CLIENT_ID") clientSecret := os.Getenv("OAUTH_CLIENT_SECRET") flow, err := InitFlow() if err != nil { panic(err) } params := BrowserParams{ ClientID: clientID, RedirectURI: "http://127.0.0.1/callback", 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.AccessToken(httpClient, "https://github.com/login/oauth/access_token", clientSecret) if err != nil { panic(err) } fmt.Printf("Access token: %s\n", accessToken.Token) } oauth-0.9.0/webapp/local_server.go000066400000000000000000000031351413233701200171230ustar00rootroot00000000000000package webapp import ( "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() (CodeResponse, error) { return <-s.resultChan, 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-0.9.0/webapp/local_server_test.go000066400000000000000000000036431413233701200201660ustar00rootroot00000000000000package 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-0.9.0/webapp/webapp_flow.go000066400000000000000000000056571413233701200167630ustar00rootroot00000000000000// 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 ( "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. func (flow *Flow) AccessToken(c httpClient, tokenURL, clientSecret string) (*api.AccessToken, error) { code, err := flow.server.WaitForCode() 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": {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-0.9.0/webapp/webapp_flow_test.go000066400000000000000000000063311413233701200200100ustar00rootroot00000000000000package webapp import ( "bytes" "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.AccessToken(client, "https://github.com/access_token", "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) } }