Skip to content

Commit

Permalink
Merge pull request #1 from dpordomingo/master
Browse files Browse the repository at this point in the history
Serve Slack Button by HTTP
  • Loading branch information
Miguel Molina authored Sep 28, 2016
2 parents 3ac4004 + c1ea4f1 commit fb7d789
Show file tree
Hide file tree
Showing 2 changed files with 173 additions and 22 deletions.
84 changes: 72 additions & 12 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,23 @@ import (
"log"
"net/http"
"os"
"strings"
"time"

"github.com/nlopes/slack"

log15 "gopkg.in/inconshreveable/log15.v2"
)

const (
// BOT scope grants permission to add the bot bundled by the app
BOT = "bot"
// WEBHOOK scope allows requesting permission to post content to the user's Slack team
WEBHOOK = "incoming-webhook"
// COMMANDS scope allows installing slash commands bundled in the Slack app
COMMANDS = "commands"
)

// Service is a service to authenticate on slack using the "Add to slack" button.
type Service interface {
// SetLogOutput sets the place where logs will be written.
Expand Down Expand Up @@ -53,30 +63,36 @@ type slackAuth struct {
auths chan *slack.OAuthResponse
callback func(*slack.OAuthResponse)
api slackAPI
buttonTpl *template.Template
scopes string
}

// Options has all the configurable parameters for slack authenticator.
type Options struct {
// Addr is the address where the service will run. e.g: :8080, 0.0.0.0:8989, etc.
Addr string
Addr string
// ClientID is the slack client ID provided to you in your app credentials.
ClientID string
ClientID string
// ClientSecret is the slack client secret provided to you in your app credentials.
ClientSecret string
// SuccessTpl is the path to the template that will be displayed when there is a successful
// auth.
SuccessTpl string
SuccessTpl string
// ErrorTpl is the path to the template that will be displayed when there is an invalid
// auth.
ErrorTpl string
ErrorTpl string
// Debug will print some debug logs.
Debug bool
Debug bool
// CertFile is the path to the SSL certificate file. If this and KeyFile are provided, the
// server will be run with SSL.
CertFile string
CertFile string
// KeyFile is the path to the SSL certificate key file. If this and CertFile are provided, the
// server will be run with SSL.
KeyFile string
KeyFile string
// ButtonTpl is the path to the Slack button template
ButtonTpl string
// Scopes is the list of the allowed scopes
Scopes []string
}

// New creates a new slackauth service.
Expand All @@ -95,7 +111,7 @@ func New(opts Options) (Service, error) {
return nil, err
}

return &slackAuth{
slackAuthService := &slackAuth{
clientID: opts.ClientID,
clientSecret: opts.ClientSecret,
addr: opts.Addr,
Expand All @@ -106,7 +122,31 @@ func New(opts Options) (Service, error) {
keyFile: opts.KeyFile,
auths: make(chan *slack.OAuthResponse, 1),
api: &slackAPIWrapper{},
}, nil
}

err = slackAuthService.configureButton(opts.ButtonTpl, opts.Scopes)
if err != nil {
return nil, err
}
return slackAuthService, nil
}

func (s *slackAuth) configureButton(buttonTpl string, scopes []string) error {
if len(buttonTpl) > 0 {
buttonTpl, err := readTemplate(buttonTpl)
if err != nil {
return err
}

if len(scopes) == 0 {
return errors.New("At least one scope needed")
}

s.scopes = strings.Join(scopes, ",")
s.buttonTpl = buttonTpl
}

return nil
}

func (s *slackAuth) Run() error {
Expand Down Expand Up @@ -146,38 +186,58 @@ func (s *slackAuth) OnAuth(fn func(*slack.OAuthResponse)) {
}

func (s *slackAuth) runServer() error {
mux := http.NewServeMux()
mux.HandleFunc("/", s.buttonHandler)
mux.HandleFunc("/auth", s.authorizationHandler)

srv := &http.Server{
ReadTimeout: 1 * time.Second,
WriteTimeout: 3 * time.Second,
Addr: s.addr,
Handler: s,
Handler: mux,
}

if s.certFile != "" && s.keyFile != "" {
return srv.ListenAndServeTLS(s.certFile, s.keyFile)
}

return srv.ListenAndServe()
}

func (s *slackAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
func (s *slackAuth) authorizationHandler(w http.ResponseWriter, r *http.Request) {
code := r.FormValue("code")
resp, err := s.api.GetOAuthResponse(s.clientID, s.clientSecret, code, s.debug)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
log15.Error("error getting oauth response", "err", err.Error())
if err := s.errorTpl.Execute(w, resp); err != nil {
w.WriteHeader(http.StatusInternalServerError)
log15.Error("error displaying error tpl", "err", err.Error())
}

return
}

if err := s.successTpl.Execute(w, resp); err != nil {
w.WriteHeader(http.StatusInternalServerError)
log15.Error("error displaying success tpl", "err", err.Error())
}

log15.Debug("successful authorization", "team", resp.TeamName, "team id", resp.TeamID)
s.auths <- resp
}

func (s *slackAuth) buttonHandler(w http.ResponseWriter, r *http.Request) {
templateScope := map[string]string{
"Scopes": s.scopes,
"ClientId": s.clientID,
}
if err := s.buttonTpl.Execute(w, templateScope); err != nil {
w.WriteHeader(http.StatusInternalServerError)
log15.Error("error displaying button tpl", "err", err.Error())
}
}

func readTemplate(file string) (*template.Template, error) {
bytes, err := ioutil.ReadFile(file)
if err != nil {
Expand Down
111 changes: 101 additions & 10 deletions auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import (
"html/template"
"io/ioutil"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"testing"
"time"

Expand All @@ -31,8 +34,16 @@ const (
<p>All went ok!</p>`
tplError = `<h1>:(</h1>
<p>Something went wrong!</p>`
tplSlackButton = `ADD ME:
<a href="https://slack.com/oauth/authorize?scope={{.Scopes}}&client_id={{.ClientId}}">
SLACK BUTTON
</a>`
slackButtonRegExp = "<a[^>]+href=\"https://slack.com/oauth/authorize\\?" +
"scope=([^&\"]+)[^\"]*&client_id=([^&\"]+)[^\"]*\"[^>]*>[\\s\\S]*</a>"
)

var slackButtonMatcher = regexp.MustCompile(slackButtonRegExp)

func TestNew(t *testing.T) {
assert.Nil(t, ioutil.WriteFile("valid.txt", []byte("foo"), 0777))

Expand All @@ -48,29 +59,63 @@ func TestNew(t *testing.T) {
ClientSecret: "bar",
SuccessTpl: "invalid.txt",
ErrorTpl: "bar.txt",
ButtonTpl: "invalid.txt",
Scopes: []string{},
}, true},
{Options{
Addr: ":8080",
ClientID: "foo",
ClientSecret: "bar",
SuccessTpl: "valid.txt",
ErrorTpl: "invalid.txt",
ButtonTpl: "invalid.txt",
Scopes: []string{},
}, true},
{Options{
Addr: ":8080",
ClientID: "foo",
ClientSecret: "bar",
SuccessTpl: "valid.txt",
ErrorTpl: "valid.txt",
ButtonTpl: "",
Scopes: []string{},
}, false},
{Options{
Addr: ":8080",
ClientID: "foo",
ClientSecret: "bar",
SuccessTpl: "valid.txt",
ErrorTpl: "valid.txt",
ButtonTpl: "invalid.txt",
Scopes: []string{},
}, true},
{Options{
Addr: ":8080",
ClientID: "foo",
ClientSecret: "bar",
SuccessTpl: "valid.txt",
ErrorTpl: "valid.txt",
ButtonTpl: "valid.txt",
Scopes: []string{},
}, true},
{Options{
Addr: ":8080",
ClientID: "foo",
ClientSecret: "bar",
SuccessTpl: "valid.txt",
ErrorTpl: "valid.txt",
ButtonTpl: "valid.txt",
Scopes: []string{BOT},
}, false},
}

for _, c := range cases {
for i, c := range cases {
_, err := New(c.options)
errorHint := fmt.Sprintf("fail in testcase #%d %#v", i, c.options)
if c.err {
assert.NotNil(t, err)
assert.NotNil(t, err, errorHint)
} else {
assert.Nil(t, err)
assert.Nil(t, err, errorHint)
}
}

Expand Down Expand Up @@ -98,22 +143,68 @@ func TestSlackAuth(t *testing.T) {
<-time.After(50 * time.Millisecond)

// This will not trigger an OnAuth event
testRequest(t, "fooo", tplSuccess)
testRequest(t, "invalid", tplError)
testRequest(t, getURLForAuth("fooo"), tplSuccess)
testRequest(t, getURLForAuth("invalid"), tplError)

var auths int
auth.OnAuth(func(auth *slack.OAuthResponse) {
auths++
})
testRequest(t, "fooo", tplSuccess)
testRequest(t, "bar", tplSuccess)
testRequest(t, getURLForAuth("fooo"), tplSuccess)
testRequest(t, getURLForAuth("bar"), tplSuccess)
assert.Equal(t, 2, auths)
}

func testRequest(t *testing.T, code string, expected string) {
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:8989/?code=%s", code))
func getURLForAuth(code string) string {
return fmt.Sprintf("http://127.0.0.1:8989/auth?code=%s", code)
}

func testRequest(t *testing.T, url string, expected string) {
assert.Equal(t, expected, string(getBody(t, url)))
}

func getBody(t *testing.T, url string) []byte {
resp, err := http.Get(url)
assert.Nil(t, err)
bytes, err := ioutil.ReadAll(resp.Body)
assert.Nil(t, err)
assert.Equal(t, expected, string(bytes))
return bytes
}

type buttonOptions struct {
Scopes []string
ClientId string
}

func TestSlackButton(t *testing.T) {
buttonOpts := buttonOptions{
Scopes: []string{BOT, COMMANDS},
ClientId: "client-id",
}

assert.Nil(t, ioutil.WriteFile("valid.txt", []byte(tplSlackButton), 0777))

auth, err := New(Options{
Addr: ":8080",
ClientID: buttonOpts.ClientId,
ClientSecret: "bar",
SuccessTpl: "valid.txt",
ErrorTpl: "valid.txt",
ButtonTpl: "valid.txt",
Scopes: buttonOpts.Scopes,
})
assert.Nil(t, err)

go auth.Run()
<-time.After(5 * time.Millisecond)

servedButtonCode := getBody(t, "http://127.0.0.1:8080/")
matches := slackButtonMatcher.FindStringSubmatch(string(servedButtonCode))
assert.NotNil(t, matches)
assert.Equal(t, 3, len(matches))
receibedScopes, _ := url.QueryUnescape(matches[1])
assert.Equal(t, strings.Join(buttonOpts.Scopes, ","), receibedScopes)
assert.Equal(t, buttonOpts.ClientId, matches[2])

assert.Nil(t, os.Remove("valid.txt"))
}

0 comments on commit fb7d789

Please sign in to comment.