diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ec9906 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.sqlite +*.local +.idea/ +.data/ diff --git a/cmd/lavender/conf.go b/cmd/lavender/conf.go new file mode 100644 index 0000000..de9f42a --- /dev/null +++ b/cmd/lavender/conf.go @@ -0,0 +1,11 @@ +package main + +import loginServiceManager "github.com/1f349/lavender/issuer" + +type startUpConfig struct { + Listen string `json:"listen"` + BaseUrl string `json:"base_url"` + PrivateKey string `json:"private_key"` + Issuer string `json:"issuer"` + SsoServices []loginServiceManager.SsoConfig `json:"sso_services"` +} diff --git a/cmd/lavender/main.go b/cmd/lavender/main.go new file mode 100644 index 0000000..f0b4be5 --- /dev/null +++ b/cmd/lavender/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "context" + "flag" + "github.com/google/subcommands" + "os" +) + +func main() { + subcommands.Register(subcommands.HelpCommand(), "") + subcommands.Register(subcommands.FlagsCommand(), "") + subcommands.Register(subcommands.CommandsCommand(), "") + subcommands.Register(&serveCmd{}, "") + + flag.Parse() + ctx := context.Background() + os.Exit(int(subcommands.Execute(ctx))) +} diff --git a/cmd/lavender/serve.go b/cmd/lavender/serve.go new file mode 100644 index 0000000..0f67a18 --- /dev/null +++ b/cmd/lavender/serve.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/json" + "flag" + "github.com/1f349/lavender/issuer" + "github.com/1f349/lavender/server" + "github.com/1f349/violet/utils" + exit_reload "github.com/MrMelon54/exit-reload" + "github.com/MrMelon54/mjwt" + "github.com/google/subcommands" + "log" + "os" + "path/filepath" +) + +type serveCmd struct{ configPath string } + +func (s *serveCmd) Name() string { return "serve" } + +func (s *serveCmd) Synopsis() string { return "Serve API authentication service" } + +func (s *serveCmd) SetFlags(f *flag.FlagSet) { + f.StringVar(&s.configPath, "conf", "", "/path/to/config.json : path to the config file") +} + +func (s *serveCmd) Usage() string { + return `serve [-conf ] + Serve API authentication service using information from the config file +` +} + +func (s *serveCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { + log.Println("[Lavender] Starting...") + + if s.configPath == "" { + log.Println("[Lavender] Error: config flag is missing") + return subcommands.ExitUsageError + } + + openConf, err := os.Open(s.configPath) + if err != nil { + if os.IsNotExist(err) { + log.Println("[Lavender] Error: missing config file") + } else { + log.Println("[Lavender] Error: open config file: ", err) + } + return subcommands.ExitFailure + } + + var config startUpConfig + err = json.NewDecoder(openConf).Decode(&config) + if err != nil { + log.Println("[Lavender] Error: invalid config file: ", err) + return subcommands.ExitFailure + } + + configPathAbs, err := filepath.Abs(s.configPath) + if err != nil { + log.Fatal("[Lavender] Failed to get absolute config path") + } + wd := filepath.Dir(configPathAbs) + normalLoad(config, wd) + return subcommands.ExitSuccess +} + +func normalLoad(startUp startUpConfig, wd string) { + mSign, err := mjwt.NewMJwtSignerFromFileOrCreate(startUp.Issuer, filepath.Join(wd, "lavender.private.key"), rand.Reader, 4096) + if err != nil { + log.Fatal("[Lavender] Failed to load or create MJWT signer:", err) + } + + manager, err := issuer.NewManager(startUp.SsoServices) + if err != nil { + log.Fatal("[Lavender] Failed to create SSO service manager") + } + + srv := server.NewHttpServer(startUp.Listen, startUp.BaseUrl, manager, mSign) + log.Printf("[Lavender] Starting HTTP server on '%s'\n", srv.Addr) + go utils.RunBackgroundHttp("HTTP", srv) + + exit_reload.ExitReload("Tulip", func() {}, func() { + // stop http server + _ = srv.Close() + }) +} diff --git a/go.mod b/go.mod index 7883022..e82eee8 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,16 @@ module github.com/1f349/lavender go 1.20 + +require ( + github.com/1f349/violet v0.0.10 + github.com/MrMelon54/exit-reload v0.0.1 + github.com/MrMelon54/mjwt v0.1.1 + github.com/google/subcommands v1.2.0 + github.com/julienschmidt/httprouter v1.3.0 +) + +require ( + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/pkg/errors v0.9.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ea559fc --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/1f349/violet v0.0.10 h1:2HuQq7SddV60JZ4Xr7DmmhTOPbjiF+1Uqk+d6O1f18U= +github.com/1f349/violet v0.0.10/go.mod h1:Uzu6I1pLBP5UEzcUCTQBbk/NTfI5TAABSrowa8DSpR0= +github.com/MrMelon54/exit-reload v0.0.1 h1:sxHa59tNEQMcikwuX2+93lw6Vi1+R7oCRF8a0C3alXc= +github.com/MrMelon54/exit-reload v0.0.1/go.mod h1:PLiSfmUzwdpTTQP3BBfUPhkqPwaIZjx0DuXBnM76Bug= +github.com/MrMelon54/mjwt v0.1.1 h1:m+aTpxbhQCrOPKHN170DQMFR5r938LkviU38unob5Jw= +github.com/MrMelon54/mjwt v0.1.1/go.mod h1:oYrDBWK09Hju98xb+bRQ0wy+RuAzacxYvKYOZchR2Tk= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/issuer/manager.go b/issuer/manager.go new file mode 100644 index 0000000..aa2cf8e --- /dev/null +++ b/issuer/manager.go @@ -0,0 +1,29 @@ +package issuer + +type Manager struct { + m map[string]*WellKnownOIDC +} + +func NewManager(services []SsoConfig) (*Manager, error) { + l := &Manager{m: make(map[string]*WellKnownOIDC)} + for _, i := range services { + conf, err := i.FetchConfig() + if err != nil { + return nil, err + } + + // save by issuer + l.m[conf.Issuer] = conf + } + return l, nil +} + +func (l *Manager) CheckIssuer(issuer string) bool { + _, ok := l.m[issuer] + return ok +} + +func (l *Manager) FindServiceFromLogin(login string) *WellKnownOIDC { + + return l.m[namespace] +} diff --git a/issuer/sso.go b/issuer/sso.go new file mode 100644 index 0000000..c2d7a16 --- /dev/null +++ b/issuer/sso.go @@ -0,0 +1,77 @@ +package issuer + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "path" + "slices" +) + +// SsoConfig is the base URL for an OAUTH/OPENID/SSO login service +// The path `/.well-known/openid-configuration` should be available +type SsoConfig struct { + Addr string `json:"addr"` // https://login.example.com + Namespace string `json:"namespace"` // example.com +} + +func (s SsoConfig) FetchConfig() (*WellKnownOIDC, error) { + confUrl := path.Join(s.Addr, ".well-known", "openid-configuration") + get, err := http.Get(confUrl) + if err != nil { + return nil, err + } + defer get.Body.Close() + + var c WellKnownOIDC + err = json.NewDecoder(get.Body).Decode(&c) + return &c, err +} + +type WellKnownOIDC struct { + Config SsoConfig `json:"-"` + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserInfoEndpoint string `json:"userinfo_endpoint"` + ResponseTypesSupported []string `json:"response_types_supported"` + ScopesSupported []string `json:"scopes_supported"` + ClaimsSupported []string `json:"claims_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` +} + +func (o WellKnownOIDC) Validate() error { + if o.Issuer == "" { + return errors.New("missing issuer") + } + + // check URLs are valid + if _, err := url.Parse(o.AuthorizationEndpoint); err != nil { + return err + } + if _, err := url.Parse(o.TokenEndpoint); err != nil { + return err + } + if _, err := url.Parse(o.UserInfoEndpoint); err != nil { + return err + } + + // check oidc supported values + if !slices.Contains(o.ResponseTypesSupported, "code") { + return errors.New("missing required response type 'code'") + } + if !slices.Contains(o.ScopesSupported, "openid") { + return errors.New("missing required scope 'openid'") + } + requiredClaims := []string{"sub", "name", "preferred_username", "email", "email_verified"} + for _, i := range requiredClaims { + if !slices.Contains(o.ClaimsSupported, i) { + return fmt.Errorf("missing required claim '%s'", i) + } + } + + // oidc valid + return nil +} diff --git a/server/flow-popup.go.html b/server/flow-popup.go.html new file mode 100644 index 0000000..f809094 --- /dev/null +++ b/server/flow-popup.go.html @@ -0,0 +1,21 @@ + + + + {{.ServiceName}} + + +
+

{{.ServiceName}}

+
+
+
+ +
+ + +
+ +
+
+ + diff --git a/server/flow.go b/server/flow.go new file mode 100644 index 0000000..54a5308 --- /dev/null +++ b/server/flow.go @@ -0,0 +1,49 @@ +package server + +import ( + _ "embed" + "github.com/julienschmidt/httprouter" + "html/template" + "log" + "net/http" +) + +var ( + //go:embed flow-popup.go.html + flowPopupHtml string + flowPopupTemplate *template.Template +) + +func init() { + pageParse, err := template.New("pages").Parse(flowPopupHtml) + if err != nil { + log.Fatal("flow.go: Failed to parse flow popup HTML:", err) + } + flowPopupTemplate = pageParse +} + +func (h *HttpServer) flowPopup(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { + err := flowPopupTemplate.Execute(rw, map[string]any{ + "ServiceName": flowPopupTemplate, + "Return": req.URL.Query().Get("return"), + }) + if err != nil { + log.Printf("Failed to render page: %s\n", err) + } +} + +func (h *HttpServer) flowPopupPost(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { + login := h.manager.FindServiceFromLogin(req.PostFormValue("username")) + if login == nil { + http.Error(rw, "No login service defined for this username", http.StatusBadRequest) + return + } + + login.AuthorizationEndpoint + + // https://github.com/go-oauth2/oauth2/blob/master/example/client/client.go +} + +func (h *HttpServer) flowCallback(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { + +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..7d0ccf8 --- /dev/null +++ b/server/server.go @@ -0,0 +1,47 @@ +package server + +import ( + "fmt" + "github.com/1f349/lavender/issuer" + "github.com/MrMelon54/mjwt" + "github.com/julienschmidt/httprouter" + "net/http" + "time" +) + +type HttpServer struct { + r *httprouter.Router + baseUrl string + manager *issuer.Manager + signer mjwt.Signer +} + +func NewHttpServer(listen, baseUrl string, manager *issuer.Manager, signer mjwt.Signer) *http.Server { + r := httprouter.New() + + hs := &HttpServer{ + r: r, + baseUrl: baseUrl, + manager: manager, + signer: signer, + } + + r.GET("/", func(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { + rw.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintln(rw, "What is this?") + }) + r.POST("/verify", hs.verifyHandler) + r.GET("/popup", hs.flowPopup) + r.POST("/popup", hs.flowPopupPost) + r.GET("/callback", hs.flowCallback) + + return &http.Server{ + Addr: listen, + Handler: r, + ReadTimeout: time.Minute, + ReadHeaderTimeout: time.Minute, + WriteTimeout: time.Minute, + IdleTimeout: time.Minute, + MaxHeaderBytes: 2500, + } +} diff --git a/server/verify.go b/server/verify.go new file mode 100644 index 0000000..67831c3 --- /dev/null +++ b/server/verify.go @@ -0,0 +1,33 @@ +package server + +import ( + "github.com/1f349/violet/utils" + "github.com/MrMelon54/mjwt" + "github.com/MrMelon54/mjwt/auth" + "github.com/julienschmidt/httprouter" + "net/http" +) + +func (h *HttpServer) verifyHandler(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { + // find bearer token + bearer := utils.GetBearer(req) + if bearer == "" { + http.Error(rw, "Missing bearer", http.StatusForbidden) + return + } + + // after this mjwt is considered valid + _, b, err := mjwt.ExtractClaims[auth.AccessTokenClaims](h.signer, bearer) + if err != nil { + http.Error(rw, "Invalid token", http.StatusForbidden) + return + } + + // check issuer against config + if b.Issuer != h.baseUrl { + http.Error(rw, "Invalid issuer", http.StatusBadRequest) + return + } + + rw.WriteHeader(http.StatusOK) +}