├── .gitignore ├── README.md ├── cookies.go ├── oauth.go ├── plugin.go ├── providers ├── github │ └── github.go └── provider.go └── test ├── caddyfile ├── index.html └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | .env -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # caddy-oauth 2 | github auth plugin for caddy 3 | 4 | This project is old and abandoned. If looking for oauth for caddy, you should probably use [this plugin](https://caddyserver.com/docs/http.login). 5 | -------------------------------------------------------------------------------- /cookies.go: -------------------------------------------------------------------------------- 1 | package oauth 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/sha256" 6 | "encoding/base64" 7 | 8 | "fmt" 9 | 10 | "path/filepath" 11 | 12 | "io/ioutil" 13 | 14 | "os" 15 | 16 | "net/http" 17 | 18 | "github.com/gorilla/securecookie" 19 | "github.com/mholt/caddy" 20 | ) 21 | 22 | type cookieManager struct { 23 | sc *securecookie.SecureCookie 24 | } 25 | 26 | func getCookie(given string) (*cookieManager, error) { 27 | var hashKey, blockKey []byte 28 | //given in caddyfile 29 | if len(given) > 0 { 30 | if len(given) < 10 { 31 | return nil, fmt.Errorf("cookie_secret is too short") 32 | } 33 | hashKey, blockKey = getKeys(given) 34 | } else { 35 | //ok, lets make one and store it in the .caddy dir then 36 | dir := filepath.Join(caddy.AssetsPath(), "oauth") 37 | if err := os.MkdirAll(dir, 0600); err != nil { 38 | return nil, err 39 | } 40 | fpath := filepath.Join(dir, "secret.key") 41 | dat, err := ioutil.ReadFile(fpath) 42 | //not there. make it. 43 | if os.IsNotExist(err) { 44 | dat = make([]byte, 64) 45 | rand.Read(dat) 46 | err = ioutil.WriteFile(fpath, dat, 0600) 47 | if err != nil { 48 | return nil, fmt.Errorf("writing cookie_secret: %s", err) 49 | } 50 | } else if err != nil { 51 | return nil, err 52 | } 53 | if len(dat) != 64 { 54 | return nil, fmt.Errorf("Stored cookie_secret is wrong length. Expect exactly 64 bytes") 55 | } 56 | hashKey, blockKey = dat[:32], dat[32:] 57 | } 58 | return &cookieManager{sc: securecookie.New(hashKey, blockKey)}, nil 59 | } 60 | 61 | func getKeys(s string) ([]byte, []byte) { 62 | var dat []byte 63 | var err error 64 | //if valid b64, use that. best practice is a 64 byte random base 64 string 65 | if dat, err = base64.StdEncoding.DecodeString(s); err != nil { 66 | dat = []byte(s) 67 | } 68 | var hashKey, blockKey []byte 69 | //exactly 64 bytes, awesome 70 | if len(dat) == 64 { 71 | hashKey, blockKey = dat[:32], dat[32:] 72 | } else { 73 | //otherwise hash each half 74 | split := len(dat) / 2 75 | h, e := sha256.Sum256(dat[split:]), sha256.Sum256(dat[:split]) 76 | hashKey, blockKey = h[:], e[:] 77 | } 78 | return hashKey, blockKey 79 | } 80 | 81 | func (cm *cookieManager) ReadCookie(r *http.Request, name string, maxAge int, dst interface{}) error { 82 | val, err := cm.ReadCookiePlain(r, name) 83 | if err != nil { 84 | return err 85 | } 86 | if err = cm.sc.MaxAge(maxAge).Decode(name, val, dst); err != nil { 87 | return err 88 | } 89 | return nil 90 | } 91 | 92 | func (cm *cookieManager) SetCookie(w http.ResponseWriter, name string, maxAge int, dat interface{}) error { 93 | val, err := cm.sc.MaxAge(maxAge).Encode(name, dat) 94 | if err != nil { 95 | return err 96 | } 97 | cm.SetCookiePlain(w, name, maxAge, val) 98 | return nil 99 | } 100 | 101 | func (cm *cookieManager) SetCookiePlain(w http.ResponseWriter, name string, maxAge int, value string) { 102 | cookie := &http.Cookie{ 103 | MaxAge: maxAge, 104 | HttpOnly: true, 105 | Name: name, 106 | Path: "/", 107 | Secure: true, 108 | Value: value, 109 | } 110 | http.SetCookie(w, cookie) 111 | } 112 | 113 | func (cm *cookieManager) ReadCookiePlain(r *http.Request, name string) (string, error) { 114 | cookie, err := r.Cookie(name) 115 | if err != nil { 116 | return "", err //cookie no exist 117 | } 118 | return cookie.Value, nil 119 | } 120 | 121 | func (cm *cookieManager) ClearCookie(w http.ResponseWriter, name string) { 122 | cm.SetCookiePlain(w, name, -1, "") 123 | } 124 | -------------------------------------------------------------------------------- /oauth.go: -------------------------------------------------------------------------------- 1 | package oauth 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "net/http" 7 | 8 | "encoding/base64" 9 | 10 | "fmt" 11 | 12 | "github.com/captncraig/caddy-oauth/providers" 13 | "golang.org/x/oauth2" 14 | ) 15 | 16 | func (o *oathPlugin) stripHeaders(r *http.Request) { 17 | for _, p := range o.providers { 18 | for _, h := range p.HeadersUsed() { 19 | r.Header.Del(h) 20 | } 21 | } 22 | } 23 | 24 | func (o *oathPlugin) loginPage(w http.ResponseWriter, r *http.Request) (int, error) { 25 | return 0, nil 26 | } 27 | 28 | func (o *oathPlugin) logout(w http.ResponseWriter, r *http.Request) (int, error) { 29 | for _, p := range o.providerConfigs { 30 | o.cookies.ClearCookie(w, p.CookieName) 31 | } 32 | //TODO: customizable? 33 | http.Redirect(w, r, "/", http.StatusFound) 34 | return http.StatusFound, nil 35 | } 36 | 37 | func (o *oathPlugin) start(w http.ResponseWriter, r *http.Request, p providers.Provider) (int, error) { 38 | dat := make([]byte, 9) 39 | rand.Read(dat) 40 | state := base64.StdEncoding.EncodeToString(dat) 41 | o.cookies.SetCookie(w, "oauth-state", 120, state) 42 | url := p.OauthConfig().AuthCodeURL(state) 43 | http.Redirect(w, r, url, http.StatusFound) 44 | return http.StatusFound, nil 45 | } 46 | 47 | func (o *oathPlugin) callback(w http.ResponseWriter, r *http.Request, p providers.Provider, cfg *providers.ProviderConfig) (int, error) { 48 | fail := func(e error) (int, error) { 49 | fmt.Println("FIAIL", e) 50 | return 200, nil 51 | } 52 | var err error 53 | code, state := r.URL.Query().Get("code"), r.URL.Query().Get("state") 54 | var foundState string 55 | if err = o.cookies.ReadCookie(r, "oauth-state", 120, &foundState); err != nil { 56 | return fail(err) 57 | } 58 | o.cookies.ClearCookie(w, "oauth-state") 59 | if foundState != state { 60 | return fail(fmt.Errorf("state does not match")) 61 | } 62 | var tok *oauth2.Token 63 | if tok, err = p.OauthConfig().Exchange(context.Background(), code); err != nil { 64 | return fail(err) 65 | } 66 | 67 | headers, err := p.GetUserData(tok) 68 | if err != nil { 69 | return fail(err) 70 | } 71 | if err = o.cookies.SetCookie(w, cfg.CookieName, cookieDuration, headers); err != nil { 72 | return fail(err) 73 | } 74 | return o.successRedirect(w, r) 75 | } 76 | 77 | var cookieDuration = 60 * 60 * 24 * 30 78 | 79 | func (o *oathPlugin) successRedirect(w http.ResponseWriter, r *http.Request) (int, error) { 80 | //TODO: read cookie and redirect to that url. 81 | // Otherwise / (or maybe a configurable path) 82 | http.Redirect(w, r, "/", http.StatusFound) 83 | return http.StatusFound, nil 84 | } 85 | -------------------------------------------------------------------------------- /plugin.go: -------------------------------------------------------------------------------- 1 | package oauth 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "strings" 8 | 9 | "github.com/captncraig/caddy-oauth/providers" 10 | "github.com/captncraig/caddy-oauth/providers/github" 11 | "github.com/mholt/caddy" 12 | "github.com/mholt/caddy/caddyhttp/httpserver" 13 | ) 14 | 15 | type oathPlugin struct { 16 | next httpserver.Handler 17 | 18 | //route for a generated landing page with a login button for each provider. default: /login 19 | loginRoute string 20 | //route to delete all cookies. default: /logout 21 | logoutRoute string 22 | //should we add headers for downstream requests to see or not? headers are provider specific. default: false 23 | passHeaders bool 24 | //routes that require login from at least one provider. default: none 25 | protectedRoutes []string 26 | 27 | // internal things 28 | cookies *cookieManager 29 | providerConfigs map[string]*providers.ProviderConfig 30 | providers map[string]providers.Provider 31 | } 32 | 33 | type providerInit func(*providers.ProviderConfig) (providers.Provider, error) 34 | 35 | var providerTypes = map[string]providerInit{ 36 | "github": github.New, 37 | } 38 | 39 | func init() { 40 | plug := caddy.Plugin{ 41 | ServerType: "http", 42 | Action: setup, 43 | } 44 | caddy.RegisterPlugin("oauth", plug) 45 | } 46 | 47 | func setup(c *caddy.Controller) (err error) { 48 | var o *oathPlugin 49 | for c.Next() { 50 | if o != nil { 51 | return c.Err("Cannot define oauth more than once per server") 52 | } 53 | o = &oathPlugin{ 54 | loginRoute: "/login", 55 | logoutRoute: "/logout", 56 | passHeaders: false, 57 | providerConfigs: map[string]*providers.ProviderConfig{}, 58 | providers: map[string]providers.Provider{}, 59 | } 60 | o.protectedRoutes = c.RemainingArgs() 61 | for c.NextBlock() { 62 | if err := parseArg(c, o); err != nil { 63 | return err 64 | } 65 | } 66 | } 67 | if o.cookies == nil { 68 | if o.cookies, err = getCookie(""); err != nil { 69 | return err 70 | } 71 | } 72 | if len(o.providerConfigs) == 0 { 73 | return c.Errf("At least one oauth provider must be specified") 74 | } 75 | for name, cfg := range o.providerConfigs { 76 | if cfg.ClientID == "" || cfg.ClientSecret == "" { 77 | return c.Errf("need %s_client_id and %s_client_secret", name, name) 78 | } 79 | prov, err := providerTypes[name](cfg) 80 | if err != nil { 81 | return err 82 | } 83 | o.providers[name] = prov 84 | } 85 | 86 | cfg := httpserver.GetConfig(c) 87 | mid := func(next httpserver.Handler) httpserver.Handler { 88 | o.next = next 89 | return o 90 | } 91 | cfg.AddMiddleware(mid) 92 | return nil 93 | } 94 | 95 | func parseArg(c *caddy.Controller, o *oathPlugin) (err error) { 96 | defer func() { 97 | if r := recover(); r != nil { 98 | if e, ok := r.(error); ok { 99 | err = e 100 | } 101 | } 102 | }() 103 | v := c.Val() 104 | switch v { 105 | case "login": 106 | o.loginRoute = singleArg(c) 107 | case "logout": 108 | o.logoutRoute = singleArg(c) 109 | case "pass_headers": 110 | if len(c.RemainingArgs()) != 0 { 111 | return c.ArgErr() 112 | } 113 | o.passHeaders = true 114 | case "cookie_secret": 115 | if o.cookies, err = getCookie(singleArg(c)); err != nil { 116 | return err 117 | } 118 | default: 119 | //either provider specific config, or an unknown key 120 | parts := strings.SplitN(c.Val(), "_", 2) 121 | if len(parts) != 2 { 122 | return c.Errf("Unkown oauth config item %s", v) 123 | } 124 | pname := parts[0] 125 | _, ok := providerTypes[pname] 126 | if !ok { 127 | return c.Errf("Unkown oauth provider type %s", parts[0]) 128 | } 129 | var pcfg = o.providerConfigs[pname] 130 | if pcfg == nil { 131 | pcfg = &providers.ProviderConfig{ 132 | CallbackRoute: fmt.Sprintf("/auth/%s/callback", pname), 133 | StartRoute: fmt.Sprintf("/auth/%s/start", pname), 134 | CookieName: fmt.Sprintf("auth-%s", pname), 135 | Params: map[string][][]string{}, 136 | } 137 | o.providerConfigs[parts[0]] = pcfg 138 | } 139 | switch parts[1] { 140 | case "callback": 141 | pcfg.CallbackRoute = singleArg(c) 142 | case "start": 143 | pcfg.StartRoute = singleArg(c) 144 | case "cookie_name": 145 | pcfg.CookieName = singleArg(c) 146 | case "client_secret": 147 | pcfg.ClientSecret = singleArg(c) 148 | case "client_id": 149 | pcfg.ClientID = singleArg(c) 150 | case "scopes": 151 | pcfg.Scopes = append(pcfg.Scopes, c.RemainingArgs()...) 152 | default: 153 | pcfg.Params[parts[1]] = append(pcfg.Params[parts[1]], c.RemainingArgs()) 154 | } 155 | } 156 | return nil 157 | } 158 | 159 | func singleArg(c *caddy.Controller) string { 160 | if args := c.RemainingArgs(); len(args) == 1 { 161 | return args[0] 162 | } 163 | panic(c.ArgErr()) 164 | } 165 | 166 | func (o *oathPlugin) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) { 167 | path := httpserver.Path(r.URL.Path) 168 | // 1. special routes we handle ourselves 169 | if path.Matches(o.loginRoute) { 170 | return o.loginPage(w, r) 171 | } 172 | if path.Matches(o.logoutRoute) { 173 | return o.logout(w, r) 174 | } 175 | for name, p := range o.providerConfigs { 176 | if path.Matches(p.StartRoute) { 177 | return o.start(w, r, o.providers[name]) 178 | } 179 | if path.Matches(p.CallbackRoute) { 180 | return o.callback(w, r, o.providers[name], p) 181 | } 182 | } 183 | any := false 184 | // 2. check all cookies, populate downstream headers 185 | for _, p := range o.providerConfigs { 186 | dat := map[string]string{} 187 | if err := o.cookies.ReadCookie(r, p.CookieName, cookieDuration, &dat); err == nil { 188 | for k, v := range dat { 189 | r.Header.Set(k, v) 190 | } 191 | any = true 192 | } 193 | } 194 | if !any { 195 | fmt.Println("DENY!") 196 | } 197 | // 3. deny if configured 198 | return o.next.ServeHTTP(w, r) 199 | } 200 | 201 | //TODO: possible actions on DENY: 202 | //1. Redirect to $loginRoute, storing desired path in cookie (CURRENT) 203 | //2. Redirect to route of user's choice. 204 | //3. Straight up 403 (CURRENT if request does not accept html) 205 | //4. REWRITE to some other path 206 | -------------------------------------------------------------------------------- /providers/github/github.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | 6 | "fmt" 7 | "net/http" 8 | 9 | "encoding/json" 10 | 11 | "github.com/captncraig/caddy-oauth/providers" 12 | "golang.org/x/oauth2" 13 | ghoauth "golang.org/x/oauth2/github" 14 | ) 15 | 16 | type githubProvider struct { 17 | *providers.ProviderConfig 18 | 19 | restrict bool 20 | allowedUsers []string 21 | allowedOrgs []string 22 | allowedRepos [][]string 23 | } 24 | 25 | func New(p *providers.ProviderConfig) (providers.Provider, error) { 26 | g := &githubProvider{ProviderConfig: p} 27 | for k, vs := range p.Params { 28 | switch k { 29 | case "allowed_users": 30 | for _, v := range vs { 31 | for _, u := range v { 32 | g.allowedUsers = append(g.allowedUsers, u) 33 | } 34 | } 35 | case "allowed_orgs": 36 | for _, v := range vs { 37 | for _, o := range v { 38 | g.allowedOrgs = append(g.allowedOrgs, o) 39 | } 40 | } 41 | case "allow_repo_members": 42 | for _, v := range vs { 43 | if len(v) != 2 { 44 | return nil, fmt.Errorf("github_allow_repo_members expects 2 arguments for owner and repo name") 45 | } 46 | g.allowedRepos = append(g.allowedRepos, []string{v[0], v[1]}) 47 | } 48 | default: 49 | return nil, fmt.Errorf("unkown github config item github_%s", k) 50 | } 51 | } 52 | if g.allowedOrgs != nil || g.allowedRepos != nil || g.allowedUsers != nil { 53 | g.restrict = true 54 | } 55 | return g, nil 56 | } 57 | 58 | const ( 59 | idHeader = "X-Github-ID" 60 | userHeader = "X-Github-User" 61 | tokenHeader = "X-Github-Token" 62 | avatarHeader = "X-Github-Avatar" 63 | ) 64 | 65 | var headers = []string{ 66 | idHeader, 67 | userHeader, 68 | tokenHeader, 69 | avatarHeader, 70 | } 71 | 72 | func (g *githubProvider) OauthConfig() *oauth2.Config { 73 | return &oauth2.Config{ 74 | Endpoint: ghoauth.Endpoint, 75 | ClientID: g.ClientID, 76 | ClientSecret: g.ClientSecret, 77 | Scopes: g.Scopes, 78 | } 79 | } 80 | 81 | func (g *githubProvider) HeadersUsed() []string { 82 | return headers 83 | } 84 | 85 | type ghUser struct { 86 | ID int `json:"id"` 87 | Login string `json:"login"` 88 | Avatar string `json:"avatar_url"` 89 | } 90 | 91 | const apiBase = "https://api.github.com" 92 | 93 | func (g *githubProvider) GetUserData(tok *oauth2.Token) (map[string]string, error) { 94 | c := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(tok)) 95 | resp, err := c.Get(apiBase + "/user") 96 | if err != nil { 97 | return nil, err 98 | } 99 | if resp.StatusCode != http.StatusOK { 100 | return nil, fmt.Errorf("Status from github: %d", resp.StatusCode) 101 | } 102 | dec := json.NewDecoder(resp.Body) 103 | user := &ghUser{} 104 | if err = dec.Decode(user); err != nil { 105 | return nil, err 106 | } 107 | data := map[string]string{ 108 | idHeader: fmt.Sprint(user.ID), 109 | avatarHeader: user.Avatar, 110 | userHeader: user.Login, 111 | tokenHeader: tok.AccessToken, 112 | } 113 | if !g.restrict { 114 | return data, nil 115 | } 116 | for _, u := range g.allowedUsers { 117 | if u == user.Login { 118 | return data, nil 119 | } 120 | } 121 | for _, o := range g.allowedOrgs { 122 | if g.userMemberOfOrg(c, user.Login, o) { 123 | return data, nil 124 | } 125 | } 126 | for _, r := range g.allowedRepos { 127 | if g.userMemberOfRepo(c, user.Login, r) { 128 | return data, nil 129 | } 130 | } 131 | return nil, fmt.Errorf("User not authorized") 132 | } 133 | 134 | func (g *githubProvider) userMemberOfOrg(c *http.Client, u string, org string) bool { 135 | url := fmt.Sprintf("%s/orgs/%s/members/%s", apiBase, org, u) 136 | resp, err := c.Get(url) 137 | if err == nil && resp.StatusCode == http.StatusNoContent { 138 | return true 139 | } 140 | return false 141 | } 142 | 143 | func (g *githubProvider) userMemberOfRepo(c *http.Client, u string, repo []string) bool { 144 | url := fmt.Sprintf("%s/repos/%s/%s/collaborators/%s", apiBase, repo[0], repo[1], u) 145 | resp, err := c.Get(url) 146 | if err == nil && resp.StatusCode == http.StatusNoContent { 147 | return true 148 | } 149 | return false 150 | } 151 | -------------------------------------------------------------------------------- /providers/provider.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "golang.org/x/oauth2" 5 | ) 6 | 7 | // A provider can wrap an error in VisibleError to make it visible to the end user. 8 | // Otherwise they will get a generic "auth failure" message. 9 | type VisibleError struct { 10 | error 11 | } 12 | 13 | type Provider interface { 14 | OauthConfig() *oauth2.Config 15 | HeadersUsed() []string 16 | GetUserData(*oauth2.Token) (map[string]string, error) 17 | } 18 | 19 | type ProviderConfig struct { 20 | Provider 21 | //For provider specific config data, we pass you the key/value pairs directly. 22 | //Each time a key is seen, a new array will be added with all args, so multiple args on multiple lines are distinguishable. 23 | Params map[string][][]string 24 | 25 | //name for this provider's cookie. default: auth-$provider 26 | CookieName string 27 | //route that redirects to login. default: /auth/$provider/start 28 | StartRoute string 29 | //route for callback. Must be unique. default: /auth/$provider/callback 30 | CallbackRoute string 31 | 32 | //required oauth params 33 | ClientSecret string 34 | ClientID string 35 | Scopes []string 36 | } 37 | -------------------------------------------------------------------------------- /test/caddyfile: -------------------------------------------------------------------------------- 1 | :8888 { 2 | errors visible 3 | oauth /bar { 4 | github_client_id redacted 5 | github_client_secret redacted 6 | github_callback /ghcb 7 | } 8 | templates / .html 9 | } -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 |

Welcome

2 | 3 | Log Out 4 | Start github flow 5 | 6 | -------------------------------------------------------------------------------- /test/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "github.com/captncraig/caddy-oauth" 5 | "github.com/mholt/caddy/caddy/caddymain" 6 | "github.com/mholt/caddy/caddyhttp/httpserver" 7 | ) 8 | 9 | func main() { 10 | httpserver.RegisterDevDirective("oauth", "jwt") 11 | caddymain.Run() 12 | } 13 | --------------------------------------------------------------------------------