initial commit
commit
75c3d8669d
@ -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…
Reference in New Issue