initial commit

pull/1/head
Brad Rydzewski 7 years ago
commit 75c3d8669d

4
.gitignore vendored

@ -0,0 +1,4 @@
*.bak
*.env
*.out
_*.md

@ -0,0 +1,11 @@
1. Install go 1.9 or later
2. Install dependencies:
go get github.com/h2non/gock
3. Compile and test:
go install ./...
go vet ./...
go test ./...
golint ./...

@ -0,0 +1,3 @@
Copyright 2017 Drone.IO Inc. All rights reserved.
Use of this source code is governed by a BSD-style
license that can be found in the LICENSE file.

@ -0,0 +1,29 @@
BSD 3-Clause License
Copyright (c) 2017, drone.io
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

@ -0,0 +1,137 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"flag"
"fmt"
"net/http"
"os"
"github.com/drone/go-login/login"
"github.com/drone/go-login/login/bitbucket"
"github.com/drone/go-login/login/github"
"github.com/drone/go-login/login/gitlab"
"github.com/drone/go-login/login/gogs"
)
var (
provider = flag.String("provider", "github", "")
providerURL = flag.String("provider-url", "", "")
clientID = flag.String("client-id", "", "")
clientSecret = flag.String("client-secret", "", "")
redirectURL = flag.String("redirect-url", "http://localhost:8080/login", "")
address = flag.String("address", ":8080", "")
help = flag.Bool("help", false, "")
)
func main() {
flag.Usage = usage
flag.Parse()
if *help {
flag.Usage()
os.Exit(0)
}
var auther http.Handler
switch *provider {
case "gogs", "gitea":
auther = gogs.New(
http.HandlerFunc(details),
gogs.WithAddress(*providerURL),
gogs.WithLoginRedirect("/login/form"),
)
case "gitlab":
auther = gitlab.New(
http.HandlerFunc(details),
gitlab.WithClientID(*clientID),
gitlab.WithClientSecret(*clientSecret),
gitlab.WithRedirectURL(*redirectURL),
gitlab.WithScope("read_user", "api"),
)
case "github":
auther = github.New(
http.HandlerFunc(details),
github.WithClientID(*clientID),
github.WithClientSecret(*clientSecret),
github.WithScope("repo", "user", "read:org"),
)
case "bitbucket":
auther = bitbucket.New(
http.HandlerFunc(details),
bitbucket.WithClientID(*clientID),
bitbucket.WithClientSecret(*clientSecret),
bitbucket.WithRedirectURL(*redirectURL),
)
}
// handles the authorization flow and displays the
// authorization results at completion.
http.Handle("/login", auther)
http.Handle("/login/form", http.HandlerFunc(form))
// redirects the user to the login handler.
http.Handle("/", http.RedirectHandler("/login", http.StatusSeeOther))
http.ListenAndServe(*address, nil)
}
// returns the login credentials.
func details(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
err := login.ErrorFrom(ctx)
if err != nil {
fmt.Fprintf(w, failure, err)
return
}
token := login.TokenFrom(ctx)
fmt.Fprintf(w, success, token.Access)
}
// display the login form.
func form(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, loginForm)
}
// html page displayed to collect credentials.
var loginForm = `
<form method="POST" action="/login">
<input type="text" name="username" />
<input type="password" name="password" />
<input type="submit" />
</form>
`
// html page displayed on success.
var success = `
<html>
<body>
<h1>Token</h1>
<h2>%s</h2>
</body>
</html>
`
// html page displayed on failure.
var failure = `
<html>
<body>
<h1>Error</h1>
<h2>%s</h2>
</body>
</html>
`
func usage() {
fmt.Println(`Usage: go run main.go [OPTION]...
--provider oauth provider (github, gitlab, gogs, gitea, bitbucket)
--provider-url oauth provider url (gitea, gogs only)
--client-id oauth client id
--client-secret oauth client secret
--redirect-url oauth redirect url
--address http server address (:8080)
--help display this help and exit`)
}

@ -0,0 +1,78 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package bitbucket
import (
"net/http"
"github.com/drone/go-login/login/internal/oauth2"
)
// Options provides the Bitbucket authentication options.
type Options struct {
redirectURL string
clientID string
clientSecret string
client *http.Client
}
func createOptions() *Options {
return &Options{
client: http.DefaultClient,
}
}
// Option configures an authorization handler option.
type Option func(o *Options)
// WithClient configures the authorization handler with a
// custom http.Client.
func WithClient(client *http.Client) Option {
return func(o *Options) {
o.client = client
}
}
// WithClientID configures the authorization handler with
// the client_id.
func WithClientID(clientID string) Option {
return func(o *Options) {
o.clientID = clientID
}
}
// WithClientSecret configures the authorization handler
// with the client_secret.
func WithClientSecret(clientSecret string) Option {
return func(o *Options) {
o.clientSecret = clientSecret
}
}
// WithRedirectURL configures the authorization handler
// with the redirect_url
func WithRedirectURL(redirectURL string) Option {
return func(o *Options) {
o.redirectURL = redirectURL
}
}
// New returns a http.Handler that runs h at the completion
// of the Bitbucket authorization flow. The Bitbucket
// authorization is passed to h in the http.Request context.
func New(h http.Handler, opt ...Option) http.Handler {
opts := createOptions()
for _, fn := range opt {
fn(opts)
}
return oauth2.Handler(h, &oauth2.Config{
Client: opts.client,
ClientID: opts.clientID,
ClientSecret: opts.clientSecret,
RedirectURL: opts.redirectURL,
AccessTokenURL: "https://bitbucket.org/site/oauth2/access_token",
AuthorizationURL: "https://bitbucket.org/site/oauth2/authorize",
})
}

@ -0,0 +1,50 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package bitbucket
import (
"net/http"
"testing"
)
func TestWithClient(t *testing.T) {
c := &http.Client{}
o := &Options{}
WithClient(c)(o)
if got, want := o.client, c; got != want {
t.Errorf("Expect custom client")
}
}
func TestWithClientID(t *testing.T) {
opts := &Options{}
WithClientID("3da54155991")(opts)
if got, want := opts.clientID, "3da54155991"; got != want {
t.Errorf("Expect custom client_id")
}
}
func TestWithClientSecret(t *testing.T) {
opts := &Options{}
WithClientSecret("5012f6c60b2")(opts)
if got, want := opts.clientSecret, "5012f6c60b2"; got != want {
t.Errorf("Expect custom client_secret")
}
}
func TestWithRedirectURL(t *testing.T) {
opts := &Options{}
WithRedirectURL("http://company.com/login")(opts)
if got, want := opts.redirectURL, "http://company.com/login"; got != want {
t.Errorf("Expect custom redirect_uri")
}
}
func TestDefaultOptions(t *testing.T) {
opts := createOptions()
if got, want := opts.client, http.DefaultClient; got != want {
t.Errorf("Expect default client is http.DefaultClient")
}
}

@ -0,0 +1,90 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package github
import (
"net/http"
"strings"
"github.com/drone/go-login/login/internal/oauth2"
)
// Options provides the GitHub authentication options.
type Options struct {
scope []string
clientID string
clientSecret string
server string
client *http.Client
}
func defaultOptions() *Options {
return &Options{
server: "https://github.com",
client: http.DefaultClient,
}
}
// Option configures an authorization handler option.
type Option func(o *Options)
// WithClient configures the authorization handler with a
// custom http.Client.
func WithClient(client *http.Client) Option {
return func(o *Options) {
o.client = client
}
}
// WithClientID configures the authorization handler with
// the client_id.
func WithClientID(clientID string) Option {
return func(o *Options) {
o.clientID = clientID
}
}
// WithClientSecret configures the authorization handler
// with the client_secret.
func WithClientSecret(clientSecret string) Option {
return func(o *Options) {
o.clientSecret = clientSecret
}
}
// WithScope configures the authorization handler with the
// these scopes.
func WithScope(scope ...string) Option {
return func(o *Options) {
o.scope = scope
}
}
// WithAddress configures the authorization handler with
// a GitHub enterprise server address.
func WithAddress(server string) Option {
return func(o *Options) {
o.server = strings.TrimSuffix(server, "/")
}
}
// New returns a http.Handler that runs h at the completion
// of the GitHub authorization flow. The GitHub authorization
// is availabe to h in the http.Request context.
func New(h http.Handler, opt ...Option) http.Handler {
opts := defaultOptions()
for _, fn := range opt {
fn(opts)
}
return oauth2.Handler(h, &oauth2.Config{
BasicAuthOff: true,
Client: opts.client,
ClientID: opts.clientID,
ClientSecret: opts.clientSecret,
AccessTokenURL: opts.server + "/login/oauth/access_token",
AuthorizationURL: opts.server + "/login/oauth/authorize",
Scope: opts.scope,
})
}

@ -0,0 +1,66 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package github
import (
"net/http"
"reflect"
"strings"
"testing"
)
func TestWithClient(t *testing.T) {
c := &http.Client{}
o := &Options{}
WithClient(c)(o)
if got, want := o.client, c; got != want {
t.Errorf("Expect custom client option applied")
}
}
func TestWithClientID(t *testing.T) {
opts := &Options{}
WithClientID("3da54155991")(opts)
if got, want := opts.clientID, "3da54155991"; got != want {
t.Errorf("Expect client_id option applied")
}
}
func TestWithClientSecret(t *testing.T) {
opts := &Options{}
WithClientSecret("5012f6c60b2")(opts)
if got, want := opts.clientSecret, "5012f6c60b2"; got != want {
t.Errorf("Expect client_secret option applied")
}
}
func TestWithAddress(t *testing.T) {
opts := &Options{}
WithAddress("https://company.github.com/")(opts)
if strings.HasSuffix(opts.server, "/") {
t.Errorf("Expect trailing slash removed from server address")
}
if got, want := opts.server, "https://company.github.com"; got != want {
t.Errorf("Expect server address option applied")
}
}
func TestWithScope(t *testing.T) {
opts := &Options{}
WithScope("user", "repo")(opts)
if got, want := opts.scope, []string{"user", "repo"}; !reflect.DeepEqual(got, want) {
t.Errorf("Expect scope option applied")
}
}
func TestDefaultOptions(t *testing.T) {
opts := defaultOptions()
if got, want := opts.client, http.DefaultClient; got != want {
t.Errorf("Expect default client is http.DefaultClient")
}
if got, want := opts.server, "https://github.com"; got != want {
t.Errorf("Expect default server is https://github.com")
}
}

@ -0,0 +1,100 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package gitlab
import (
"net/http"
"strings"
"github.com/drone/go-login/login/internal/oauth2"
)
// Options provides the GitLab authentication options.
type Options struct {
scope []string
clientID string
clientSecret string
redirectURL string
server string
client *http.Client
}
func createOptions() *Options {
return &Options{
server: "https://gitlab.com",
client: http.DefaultClient,
}
}
// Option configures an authorization handler option.
type Option func(o *Options)
// WithClient configures the authorization handler with a
// custom http.Client.
func WithClient(client *http.Client) Option {
return func(o *Options) {
o.client = client
}
}
// WithClientID configures the authorization handler with
// the client_id.
func WithClientID(clientID string) Option {
return func(o *Options) {
o.clientID = clientID
}
}
// WithClientSecret configures the authorization handler
// with the client_secret.
func WithClientSecret(clientSecret string) Option {
return func(o *Options) {
o.clientSecret = clientSecret
}
}
// WithRedirectURL configures the authorization handler
// with the redirect_url
func WithRedirectURL(redirectURL string) Option {
return func(o *Options) {
o.redirectURL = redirectURL
}
}
// WithScope configures the authorization handler with the
// these scopes.
func WithScope(scope ...string) Option {
return func(o *Options) {
o.scope = scope
}
}
// WithAddress configures the authorization handler with
// the the self-hosted GitLab server address.
func WithAddress(server string) Option {
return func(o *Options) {
o.server = strings.TrimSuffix(server, "/")
}
}
// New returns a http.Handler that runs h at the completion
// of the GitLab authorization flow. The GitLab authorization
// is availabe to h in the http.Request context.
func New(h http.Handler, opt ...Option) http.Handler {
opts := createOptions()
for _, fn := range opt {
fn(opts)
}
return oauth2.Handler(h, &oauth2.Config{
BasicAuthOff: true,
Client: opts.client,
ClientID: opts.clientID,
ClientSecret: opts.clientSecret,
RedirectURL: opts.redirectURL,
AccessTokenURL: opts.server + "/oauth/token",
AuthorizationURL: opts.server + "/oauth/authorize",
Scope: opts.scope,
})
}

@ -0,0 +1,74 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package gitlab
import (
"net/http"
"reflect"
"strings"
"testing"
)
func TestWithClient(t *testing.T) {
c := &http.Client{}
o := &Options{}
WithClient(c)(o)
if got, want := o.client, c; got != want {
t.Errorf("Expect custom client option applied")
}
}
func TestWithClientID(t *testing.T) {
opts := &Options{}
WithClientID("3da54155991")(opts)
if got, want := opts.clientID, "3da54155991"; got != want {
t.Errorf("Expect client_id option applied")
}
}
func TestWithClientSecret(t *testing.T) {
opts := &Options{}
WithClientSecret("5012f6c60b2")(opts)
if got, want := opts.clientSecret, "5012f6c60b2"; got != want {
t.Errorf("Expect client_secret option applied")
}
}
func TestWithRedirectURL(t *testing.T) {
opts := &Options{}
WithRedirectURL("http://company.com/login")(opts)
if got, want := opts.redirectURL, "http://company.com/login"; got != want {
t.Errorf("Expect redirect_uri option applied")
}
}
func TestWithAddress(t *testing.T) {
opts := &Options{}
WithAddress("https://company.gitlab.com/")(opts)
if strings.HasSuffix(opts.server, "/") {
t.Errorf("Expect trailing slash removed from server address")
}
if got, want := opts.server, "https://company.gitlab.com"; got != want {
t.Errorf("Expect server address option applied")
}
}
func TestWithScope(t *testing.T) {
opts := &Options{}
WithScope("read_user", "api")(opts)
if got, want := opts.scope, []string{"read_user", "api"}; !reflect.DeepEqual(got, want) {
t.Errorf("Expect scope option applied")
}
}
func TestDefaultOptions(t *testing.T) {
opts := createOptions()
if got, want := opts.client, http.DefaultClient; got != want {
t.Errorf("Expect default client is http.DefaultClient")
}
if got, want := opts.server, "https://gitlab.com"; got != want {
t.Errorf("Expect default server is https://gitlab.com")
}
}

@ -0,0 +1,133 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package gogs
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/drone/go-login/login"
)
type token struct {
Name string `json:"name"`
Sha1 string `json:"sha1,omitempty"`
}
type handler struct {
next http.Handler
label string
login string
server string
client *http.Client
}
// New returns a Handler that runs h at the completion of
// the gogs authorization flow. The gogs authorization
// is availabe to h in the http.Request context.
func New(h http.Handler, opt ...Option) http.Handler {
opts := defaultOptions()
for _, fn := range opt {
fn(opts)
}
return &handler{
next: h,
label: opts.label,
login: opts.login,
server: opts.server,
client: opts.client,
}
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user := r.FormValue("username")
pass := r.FormValue("password")
if (user == "" || pass == "") && h.login != "" {
http.Redirect(w, r, h.login, 303)
return
}
token, err := h.createFindToken(user, pass)
if err != nil {
ctx = login.WithError(ctx, err)
} else {
ctx = login.WithToken(ctx, &login.Token{
Access: token.Sha1,
})
}
h.next.ServeHTTP(w, r.WithContext(ctx))
}
func (h *handler) createFindToken(user, pass string) (*token, error) {
tokens, err := h.findTokens(user, pass)
if err != nil {
return nil, err
}
for _, token := range tokens {
if token.Name == h.label {
return token, nil
}
}
return h.createToken(user, pass)
}
func (h *handler) createToken(user, pass string) (*token, error) {
path := fmt.Sprintf("%s/api/v1/users/%s/tokens", h.server, user)
buf := new(bytes.Buffer)
json.NewEncoder(buf).Encode(&token{
Name: h.label,
})
req, err := http.NewRequest("POST", path, buf)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth(user, pass)
res, err := h.client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode > 299 {
return nil, errors.New(
http.StatusText(res.StatusCode),
)
}
out := new(token)
err = json.NewDecoder(res.Body).Decode(out)
return out, err
}
func (h *handler) findTokens(user, pass string) ([]*token, error) {
path := fmt.Sprintf("%s/api/v1/users/%s/tokens", h.server, user)
req, err := http.NewRequest("GET", path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth(user, pass)
res, err := h.client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode > 299 {
return nil, errors.New(
http.StatusText(res.StatusCode),
)
}
out := []*token{}
err = json.NewDecoder(res.Body).Decode(&out)
return out, err
}

@ -0,0 +1,158 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package gogs
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/drone/go-login/login"
"github.com/h2non/gock"
)
func TestLogin(t *testing.T) {
defer gock.Off()
tests := []struct {
user string
pass string
path string
auth string
tokens []*token
token *token
err error
}{
// Success, match found.
{
user: "janedoe",
pass: "password",
path: "/api/v1/users/janedoe/token",
auth: "Basic amFuZWRvZTpwYXNzd29yZA==",
token: &token{Name: "default", Sha1: "3da541559"},
tokens: []*token{{Name: "default", Sha1: "3da541559"}},
},
// Success, match not found, token created.
{
user: "janedoe",
pass: "password",
path: "/api/v1/users/janedoe/token",
auth: "Basic amFuZWRvZTpwYXNzd29yZA==",
token: &token{Name: "default", Sha1: "918a808c2"},
tokens: []*token{},
},
// Failure, error getting token list.
{
user: "janedoe",
pass: "password",
path: "/api/v1/users/janedoe/token",
auth: "Basic amFuZWRvZTpwYXNzd29yZA==",
tokens: nil,
token: nil,
err: errors.New("Not Found"),
},
// Failure, match not found, error creating token.
{
user: "janedoe",
pass: "password",
path: "/api/v1/users/janedoe/token",
auth: "Basic amFuZWRvZTpwYXNzd29yZA==",
tokens: []*token{{Name: "some-random-token-name", Sha1: "918a808c2"}},
token: nil,
err: errors.New("Not Found"),
},
}
for _, test := range tests {
gock.Flush()
if test.tokens != nil {
gock.New("https://gogs.io").
Get("/api/v1/users/janedoe/token").
MatchHeader("Authorization", test.auth).
Reply(200).
JSON(test.tokens)
} else {
gock.New("https://gogs.io").
Get("/api/v1/users/janedoe/token").
Reply(404)
}
if test.token != nil {
gock.New("https://gogs.io").
Post("/api/v1/users/janedoe/token").
MatchHeader("Authorization", test.auth).
Reply(200).
JSON(test.token)
} else {
gock.New("https://gogs.io").
Post("/api/v1/users/janedoe/token").
Reply(404)
}
var ctx context.Context
fn := func(w http.ResponseWriter, r *http.Request) {
ctx = r.Context()
}
h := New(
http.HandlerFunc(fn),
WithAddress("https://gogs.io"),
)
data := url.Values{
"username": {test.user},
"password": {test.pass},
}.Encode()
res := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/", strings.NewReader(data))
req.Header.Set(
"Content-Type", "application/x-www-form-urlencoded",
)
h.ServeHTTP(res, req)
tok := login.TokenFrom(ctx)
err := login.ErrorFrom(ctx)
if test.err != nil {
if err == nil {
t.Errorf("Want error")
} else if got, want := err.Error(), test.err.Error(); got != want {
t.Errorf("Want error %q, got %q", want, got)
}
} else {
if tok == nil {
t.Errorf("Want user token, got nil")
} else if got, want := tok.Access, test.token.Sha1; got != want {
t.Errorf("Want access token %s, got %s", want, got)
}
}
}
}
func TestLoginRedirect(t *testing.T) {
h := New(
http.NotFoundHandler(),
WithLoginRedirect("/login/form"),
WithAddress("https://gogs.io"),
).(*handler)
r := httptest.NewRequest("POST", "/login", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if want, got := w.Code, 303; want != got {
t.Errorf("Want status code %d, got %d", want, got)
}
if want, got := w.Header().Get("Location"), "/login/form"; want != got {
t.Errorf("Want redirect location %s, got %s", want, got)
}
}

@ -0,0 +1,62 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package gogs
import (
"net/http"
"strings"
)
// Options provides the Gogs authentication options.
type Options struct {
label string
login string
server string
client *http.Client
}
func defaultOptions() *Options {
return &Options{
label: "default",
client: http.DefaultClient,
}
}
// Option configures an authorization handler option.
type Option func(o *Options)
// WithAddress configures the authorization handler with
// the progived Gogs server address.
func WithAddress(server string) Option {
return func(o *Options) {
o.server = strings.TrimPrefix(server, "/")
}
}
// WithClient configures the authorization handler with a
// custom http.Client.
func WithClient(client *http.Client) Option {
return func(o *Options) {
o.client = client
}
}
// WithTokenName configures the authorization handler to
// use the specificed token name when finding and creating
// authorization tokens.
func WithTokenName(name string) Option {
return func(o *Options) {
o.label = name
}
}
// WithLoginRedirect configures the authorization handler
// to redirect the http.Request to the login form when the
// username or password are missing from the Form data.
func WithLoginRedirect(path string) Option {
return func(o *Options) {
o.login = path
}
}

@ -0,0 +1,39 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package gogs
import (
"net/http"
"testing"
)
func TestWithAddress(t *testing.T) {
h := New(nil, WithAddress("https://try.gogs.io"))
if got, want := h.(*handler).server, "https://try.gogs.io"; got != want {
t.Errorf("Expect server address %q, got %q", want, got)
}
}
func TestWithClient(t *testing.T) {
c := &http.Client{}
h := New(nil, WithClient(c))
if got, want := h.(*handler).client, c; got != want {
t.Errorf("Expect custom client")
}
}
func TestWithTokenName(t *testing.T) {
h := New(nil, WithTokenName("some_token"))
if got, want := h.(*handler).label, "some_token"; got != want {
t.Errorf("Expect token name url %q, got %q", want, got)
}
}
func TestWithLoginRedirect(t *testing.T) {
h := New(nil, WithLoginRedirect("/path/to/login"))
if got, want := h.(*handler).login, "/path/to/login"; got != want {
t.Errorf("Expect login redirect url %q, got %q", want, got)
}
}

@ -0,0 +1,130 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package oauth2
import (
"encoding/json"
"net/http"
"net/url"
"strings"
)
// token stores the authorization credentials used to
// access protected resources.
type token struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
RefreshToken string `json:"refresh_token"`
Expires int64 `json:"expires_in"`
}
// Config stores the application configuration.
type Config struct {
// HTTP client used to communicate with the authorization
// server. If nil, DefaultClient is used.
Client *http.Client
// ClientID is the identifier issued to the application
// during the registration process.
ClientID string
// ClientSecret is the secret issued to the application
// during the registration process.
ClientSecret string
// Scope is the scope of the access request.
Scope []string
// RedirectURL is used by the authorization server to
// return the authorization credentials to the client.
RedirectURL string
// AccessTokenURL is used by the client to exchange an
// authorization grant for an access token.
AccessTokenURL string
// AuthorizationURL is used by the client to obtain
// authorization from the resource owner.
AuthorizationURL string
// BasicAuthOff instructs the client to disable use of
// the authorization header and provide the client_id
// and client_secret in the formdata.
BasicAuthOff bool
}
// authorizeRedirect returns a client authorization
// redirect endpoint.
func (c *Config) authorizeRedirect(state string) string {
v := url.Values{
"response_type": {"code"},
"client_id": {c.ClientID},
}
if len(c.Scope) != 0 {
v.Set("scope", strings.Join(c.Scope, " "))
}
if len(state) != 0 {
v.Set("state", state)
}
if len(c.RedirectURL) != 0 {
v.Set("redirect_uri", c.RedirectURL)
}
u, _ := url.Parse(c.AuthorizationURL)
u.RawQuery = v.Encode()
return u.String()
}
// exchange converts an authorization code into a token.
func (c *Config) exchange(code, state string) (*token, error) {
v := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
}
if c.BasicAuthOff {
v.Set("client_id", c.ClientID)
v.Set("client_secret", c.ClientSecret)
}
if len(state) != 0 {
v.Set("state", state)
}
if len(c.RedirectURL) != 0 {
v.Set("redirect_uri", c.RedirectURL)
}
req, err := http.NewRequest("POST", c.AccessTokenURL, strings.NewReader(v.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if !c.BasicAuthOff {
req.SetBasicAuth(c.ClientID, c.ClientSecret)
}
req.Header.Set("Accept", "application/json")
res, err := c.client().Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode > 299 {
err := new(Error)
json.NewDecoder(res.Body).Decode(err)
return nil, err
}
token := &token{}
err = json.NewDecoder(res.Body).Decode(token)
return token, err
}
func (c *Config) client() *http.Client {
client := c.Client
if client == nil {
client = http.DefaultClient
}
return client
}

@ -0,0 +1,100 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package oauth2
import (
"errors"
"net/http"
"testing"
"github.com/h2non/gock"
)
func TestAuthorizeRedirect(t *testing.T) {
tests := []struct {
clientID string
redirectURL string
authorzationURL string
state string
scope []string
result string
}{
// minimum required values.
{
clientID: "3da54155991",
authorzationURL: "https://bitbucket.org/site/oauth2/authorize",
result: "https://bitbucket.org/site/oauth2/authorize?client_id=3da54155991&response_type=code",
},
// all values.
{
clientID: "3da54155991",
redirectURL: "https://company.com/login",
authorzationURL: "https://bitbucket.org/site/oauth2/authorize",
state: "9f41a95cba5",
scope: []string{"user", "user:email"},
result: "https://bitbucket.org/site/oauth2/authorize?client_id=3da54155991&redirect_uri=https%3A%2F%2Fcompany.com%2Flogin&response_type=code&scope=user+user%3Aemail&state=9f41a95cba5",
},
}
for _, test := range tests {
c := Config{
ClientID: test.clientID,
RedirectURL: test.redirectURL,
AuthorizationURL: test.authorzationURL,
Scope: test.scope,
}
result := c.authorizeRedirect(test.state)
if got, want := result, test.result; want != got {
t.Errorf("Want authorize redirect %q, got %q", want, got)
}
}
}
func TestExchange(t *testing.T) {
defer gock.Off()
gock.New("https://bitbucket.org").
Post("/site/oauth2/access_token").
MatchHeader("Authorization", "Basic NTE2M2MwMWRlYToxNGM3MWEyYTIx").
MatchHeader("Accept", "application/json").
MatchHeader("Content-Type", "application/x-www-form-urlencoded").
AddMatcher(func(r *http.Request, _ *gock.Request) (bool, error) {
switch {
case r.FormValue("code") != "3da5415599":
return false, errors.New("Unexpected code")
case r.FormValue("grant_type") != "authorization_code":
return false, errors.New("Unexpected authorization_code")
case r.FormValue("redirect_uri") != "https://company.com/login":
return false, errors.New("Unexpected redirect_uri")
case r.FormValue("state") != "c60b27661c":
return false, errors.New("Unexpected state")
default:
return true, nil
}
}).
Reply(200).
JSON(&token{
AccessToken: "755bb80e5b",
RefreshToken: "e08f3fa43e",
})
c := Config{
ClientID: "5163c01dea",
ClientSecret: "14c71a2a21",
AccessTokenURL: "https://bitbucket.org/site/oauth2/access_token",
RedirectURL: "https://company.com/login",
}
token, err := c.exchange("3da5415599", "c60b27661c")
if err != nil {
t.Errorf("Error exchanging token. %s", err)
return
}
if got, want := token.AccessToken, "755bb80e5b"; got != want {
t.Errorf("Want access_token %s, got %s", want, got)
}
if got, want := token.RefreshToken, "e08f3fa43e"; got != want {
t.Errorf("Want refresh_token %s, got %s", want, got)
}
}

@ -0,0 +1,22 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package oauth2
import "errors"
// ErrState indicates the state is invalid.
var ErrState = errors.New("Invalid state")
// Error represents a failed authorization request.
type Error struct {
Code string `json:"error"`
Desc string `json:"error_description"`
}
// Error returns the string representation of an
// authorization error.
func (e *Error) Error() string {
return e.Code + ": " + e.Desc
}

@ -0,0 +1,16 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package oauth2
import "testing"
func TestError(t *testing.T) {
err := Error{}
err.Code = "invalid_request"
err.Desc = " The request is missing a required parameter"
if got, want := err.Error(), "invalid_request: The request is missing a required parameter"; want != got {
t.Errorf("Want error message %q, got %q", want, got)
}
}

@ -0,0 +1,80 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package oauth2
import (
"errors"
"net/http"
"time"
"github.com/drone/go-login/login"
)
// Handler returns a Handler that runs h at the completion
// of the oauth2 authorization flow.
func Handler(h http.Handler, c *Config) http.Handler {
return &handler{next: h, conf: c}
}
type handler struct {
conf *Config
next http.Handler
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// checks for the error query parameter in the request.
// If non-empty, write to the context and proceed with
// the next http.Handler in the chain.
if erro := r.FormValue("error"); erro != "" {
ctx = login.WithError(ctx, errors.New(erro))
h.next.ServeHTTP(w, r.WithContext(ctx))
return
}
// checks for the code query parameter in the request
// If empty, redirect to the authorization endpoint.
code := r.FormValue("code")
if len(code) == 0 {
state := createState(w)
http.Redirect(w, r, h.conf.authorizeRedirect(state), 303)
return
}
// checks for the state query parameter in the requet.
// If empty, write the error to the context and proceed
// with the next http.Handler in the chain.
state := r.FormValue("state")
deleteState(w)
if err := validateState(r, state); err != nil {
ctx = login.WithError(ctx, err)
h.next.ServeHTTP(w, r.WithContext(ctx))
return
}
// requests the access_token and refresh_token from the
// authorization server. If an error is encountered,
// write the error to the context and prceed with the
// next http.Handler in the chain.
source, err := h.conf.exchange(code, state)
if err != nil {
ctx = login.WithError(ctx, err)
h.next.ServeHTTP(w, r.WithContext(ctx))
return
}
// converts the oauth2 token type to the internal Token
// type and attaches to the context.
ctx = login.WithToken(ctx, &login.Token{
Access: source.AccessToken,
Refresh: source.RefreshToken,
Expires: time.Now().UTC().Add(
time.Duration(source.Expires) * time.Second,
),
})
h.next.ServeHTTP(w, r.WithContext(ctx))
}

@ -0,0 +1,5 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package oauth2

@ -0,0 +1,56 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package oauth2
import (
"fmt"
"math/rand"
"net/http"
"time"
)
// default cookie name.
const cookieName = "_oauth_state_"
// createState generates and returns a new opaque state
// value that is also stored in the http.Response by
// creating a session cookie.
func createState(w http.ResponseWriter) string {
cookie := &http.Cookie{
Name: cookieName,
Value: random(),
MaxAge: 1800,
}
http.SetCookie(w, cookie)
return cookie.Value
}
// validateState returns an error if the state value does
// not match the session cookie value.
func validateState(r *http.Request, state string) error {
cookie, err := r.Cookie(cookieName)
if err != nil {
return err
}
if state != cookie.Value {
return ErrState
}
return nil
}
// deleteState deletes the state from the session cookie.
func deleteState(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}
// random creates an opaque value shared between the
// http.Request and the callback used to validate redirects.
func random() string {
return fmt.Sprintf("%x", rand.Uint64())
}

@ -0,0 +1,64 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package oauth2
import (
"net/http"
"net/http/httptest"
"testing"
)
func Test_createState(t *testing.T) {
w := httptest.NewRecorder()
s := createState(w)
if got, want := s, "4d65822107fcfd52"; got != want {
t.Errorf("Want secrets %s, got %s", want, got)
}
c := "_oauth_state_=4d65822107fcfd52; Max-Age=1800"
if got, want := w.Header().Get("Set-Cookie"), c; got != want {
t.Errorf("Want cookie value %s, got %s", want, got)
}
}
func Test_validateState(t *testing.T) {
tests := []struct {
state string
value string
err error
}{
{
state: "4d65822107fcfd52",
value: "4d65822107fcfd52",
},
{
state: "4d65822107fcfd52",
value: "0000000000000000",
err: ErrState,
},
{
state: "4d65822107fcfd52",
err: http.ErrNoCookie,
},
}
for _, test := range tests {
s := test.state
r := httptest.NewRequest("GET", "/", nil)
if test.value != "" {
r.AddCookie(&http.Cookie{Name: cookieName, Value: test.value})
}
if got, want := validateState(r, s), test.err; got != want {
t.Errorf("Want error %s, got %s", want, got)
}
}
}
func Test_deleteState(t *testing.T) {
w := httptest.NewRecorder()
deleteState(w)
c := "_oauth_state_=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0"
if got, want := w.Header().Get("Set-Cookie"), c; got != want {
t.Errorf("Want cookie value %s, got %s", want, got)
}
}

@ -0,0 +1,46 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package login
import (
"context"
"time"
)
// Token represents an authorization token.
type Token struct {
Access string
Refresh string
Expires time.Time
}
type key int
const (
tokenKey key = iota
errorKey
)
// WithToken returns a parent context with the token.
func WithToken(parent context.Context, token *Token) context.Context {
return context.WithValue(parent, tokenKey, token)
}
// WithError returns a parent context with the error.
func WithError(parent context.Context, err error) context.Context {
return context.WithValue(parent, errorKey, err)
}
// TokenFrom returns the login token rom the context.
func TokenFrom(ctx context.Context) *Token {
token, _ := ctx.Value(tokenKey).(*Token)
return token
}
// ErrorFrom returns the login error from the context.
func ErrorFrom(ctx context.Context) error {
err, _ := ctx.Value(errorKey).(error)
return err
}

@ -0,0 +1,39 @@
// Copyright 2017 Drone.IO Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package login
import (
"context"
"errors"
"testing"
)
func TestWithError(t *testing.T) {
err := errors.New("Not Found")
ctx := context.Background()
ctx = WithError(ctx, err)
if ErrorFrom(ctx) != err {
t.Errorf("Expect error stored in context")
}
ctx = context.Background()
if ErrorFrom(ctx) != nil {
t.Errorf("Expect nil error in context")
}
}
func TestWithToken(t *testing.T) {
token := new(Token)
ctx := context.Background()
ctx = WithToken(ctx, token)
if TokenFrom(ctx) != token {
t.Errorf("Expect token stored in context")
}
ctx = context.Background()
if TokenFrom(ctx) != nil {
t.Errorf("Expect nil error in context")
}
}
Loading…
Cancel
Save