From f6bf8a89600962e47b29d11f78b67f63f3987876 Mon Sep 17 00:00:00 2001 From: "David J. Allen" Date: Sun, 10 Mar 2024 20:20:53 -0600 Subject: [PATCH] More refactoring and code restructure --- internal/config.go | 66 ++++++++------- internal/login.go | 61 +++++++++++--- internal/oidc/oidc.go | 4 +- internal/server/server.go | 163 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 254 insertions(+), 40 deletions(-) create mode 100644 internal/server/server.go diff --git a/internal/config.go b/internal/config.go index f4e6c4e..7179f01 100644 --- a/internal/config.go +++ b/internal/config.go @@ -1,10 +1,13 @@ package opaal import ( + "davidallendj/opaal/internal/oauth" "log" "os" "path/filepath" + "davidallendj/opaal/internal/server" + goutil "github.com/davidallendj/go-utils/util" "gopkg.in/yaml.v2" @@ -15,17 +18,16 @@ type Flows map[string]FlowOptions type Providers map[string]string type Options struct { - DecodeIdToken bool `yaml:"decode-id-token"` - DecodeAccessToken bool `yaml:"decode-access-token"` - RunOnce bool `yaml:"run-once"` - OpenBrowser bool `yaml:"open-browser"` - FlowType string `yaml:"flow"` - CachePath string `yaml:"cache"` - LocalOnly bool `yaml:"local-only"` - ForwardToken bool `yaml:"forward-token"` + RunOnce bool `yaml:"run-once"` + OpenBrowser bool `yaml:"open-browser"` + FlowType string `yaml:"flow"` + CachePath string `yaml:"cache"` + CacheOnly bool `yaml:"cache-only"` + TokenForwarding bool `yaml:"token-forwarding"` + Verbose bool `yaml:"verbose"` } -type RequestUrls struct { +type Endpoints struct { Identities string `yaml:"identities"` TrustedIssuers string `yaml:"trusted-issuers"` Login string `yaml:"login"` @@ -36,17 +38,20 @@ type RequestUrls struct { } type Authentication struct { - Clients []Client `yaml:"clients"` - Flows Flows `yaml:"flows"` + Clients []oauth.Client `yaml:"clients"` + Flows Flows `yaml:"flows"` + TestAllClients bool `yaml:"test-all"` + State string `yaml:"state"` } type Authorization struct { - RequestUrls RequestUrls `yaml:"urls"` + Endpoints Endpoints `yaml:"endpoints"` + KeyPath string `yaml:"key-path"` } type Config struct { Version string `yaml:"version"` - Server Server `yaml:"server"` + Server server.Server `yaml:"server"` Providers Providers `yaml:"providers"` Options Options `yaml:"options"` Authentication Authentication `yaml:"authentication"` @@ -56,22 +61,25 @@ type Config struct { func NewConfig() Config { return Config{ Version: goutil.GetCommit(), - Server: Server{ + Server: server.Server{ Host: "127.0.0.1", Port: 3333, }, Options: Options{ - DecodeIdToken: true, - DecodeAccessToken: true, - RunOnce: true, - OpenBrowser: false, - CachePath: "opaal.db", - FlowType: "authorization_code", - LocalOnly: false, - ForwardToken: false, + RunOnce: true, + OpenBrowser: false, + CachePath: "opaal.db", + FlowType: "authorization_code", + CacheOnly: false, + TokenForwarding: false, + Verbose: false, + }, + Authentication: Authentication{ + TestAllClients: false, + }, + Authorization: Authorization{ + KeyPath: "./keys", }, - Authentication: Authentication{}, - Authorization: Authorization{}, } } @@ -112,10 +120,10 @@ func HasRequiredConfigParams(config *Config) bool { // must have athe requirements to perform login hasClients := len(config.Authentication.Clients) > 0 hasServer := config.Server.Host != "" && config.Server.Port != 0 && config.Server.Callback != "" - hasEndpoints := config.Authorization.RequestUrls.TrustedIssuers != "" && - config.Authorization.RequestUrls.Login != "" && - config.Authorization.RequestUrls.Clients != "" && - config.Authorization.RequestUrls.Authorize != "" && - config.Authorization.RequestUrls.Token != "" + hasEndpoints := config.Authorization.Endpoints.TrustedIssuers != "" && + config.Authorization.Endpoints.Login != "" && + config.Authorization.Endpoints.Clients != "" && + config.Authorization.Endpoints.Authorize != "" && + config.Authorization.Endpoints.Token != "" return hasClients && hasServer && hasEndpoints } diff --git a/internal/login.go b/internal/login.go index 686dbed..fdab553 100644 --- a/internal/login.go +++ b/internal/login.go @@ -1,31 +1,70 @@ package opaal import ( - "davidallendj/opaal/internal/db" + cache "davidallendj/opaal/internal/cache/sqlite" + "davidallendj/opaal/internal/flows" + "davidallendj/opaal/internal/oauth" "davidallendj/opaal/internal/oidc" + "errors" "fmt" + "net/http" + "time" ) -func Login(config *Config, client *Client, provider *oidc.IdentityProvider) error { +func Login(config *Config, client *oauth.Client, provider *oidc.IdentityProvider) error { if config == nil { return fmt.Errorf("config is not valid") } // make cache if it's not where expect - _, err := db.CreateIdentityProvidersIfNotExists(config.Options.CachePath) + _, err := cache.CreateIdentityProvidersIfNotExists(config.Options.CachePath) if err != nil { fmt.Printf("failed to create cache: %v\n", err) } if config.Options.FlowType == "authorization_code" { - // create a server if doing authorization code flow - server := NewServerWithConfig(config) - err := AuthorizationCodeWithConfig(config, server, client, provider) - if err != nil { - fmt.Printf("failed to complete authorization code flow: %v\n", err) + // build the authorization URL to redirect user for social sign-in + var state = "" + if config.Authentication.Flows["authorization-code"]["state"] != "" { + state = config.Authentication.Flows["authorization-code"]["state"] } + + // print the authorization URL for sharing + var authorizationUrl = client.BuildAuthorizationUrl(provider.Endpoints.Authorization, state) + server := NewServerWithConfig(config) + fmt.Printf("Login with identity provider:\n\n %s/login\n %s\n\n", + server.GetListenAddr(), authorizationUrl, + ) + + var button = MakeButton(authorizationUrl, "Login with "+client.Name) + + // authorize oauth client and listen for callback from provider + fmt.Printf("Waiting for authorization code redirect @%s/oidc/callback...\n", server.GetListenAddr()) + eps := flows.JwtBearerEndpoints{ + Token: config.Authorization.Endpoints.Token, + TrustedIssuers: config.Authorization.Endpoints.TrustedIssuers, + Register: config.Authorization.Endpoints.Register, + } + params := flows.JwtBearerFlowParams{ + Client: oauth.NewClient(), + IdentityProvider: provider, + TrustedIssuer: &oauth.TrustedIssuer{ + AllowAnySubject: false, + Issuer: server.Addr, + Subject: "opaal", + ExpiresAt: time.Now().Add(time.Second * 3600), + }, + Verbose: config.Options.Verbose, + } + err = server.Login(button, provider, client, eps, params) + if errors.Is(err, http.ErrServerClosed) { + fmt.Printf("\n=========================================\nServer closed.\n=========================================\n\n") + } else if err != nil { + return fmt.Errorf("failed to start server: %s", err) + } + } else if config.Options.FlowType == "client_credentials" { - err := ClientCredentialsWithConfig(config, client) + err := NewClientCredentialsFlowWithConfig(config, client) if err != nil { fmt.Printf("failed to complete client credentials flow: %v", err) } @@ -35,3 +74,7 @@ func Login(config *Config, client *Client, provider *oidc.IdentityProvider) erro return nil } + +func MakeButton(url string, text string) string { + return " " + text + "" +} diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index 543905b..63dbb97 100644 --- a/internal/oidc/oidc.go +++ b/internal/oidc/oidc.go @@ -16,7 +16,7 @@ type IdentityProvider struct { Issuer string `db:"issuer" json:"issuer" yaml:"issuer"` Endpoints Endpoints `db:"endpoints" json:"endpoints" yaml:"endpoints"` Supported Supported `db:"supported" json:"supported" yaml:"supported"` - Jwks jwk.Set + KeySet jwk.Set } type Endpoints struct { @@ -142,7 +142,7 @@ func (p *IdentityProvider) FetchJwks() error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() var err error - p.Jwks, err = jwk.Fetch(ctx, p.Endpoints.JwksUri) + p.KeySet, err = jwk.Fetch(ctx, p.Endpoints.JwksUri) if err != nil { return fmt.Errorf("failed to fetch JWKS: %v", err) } diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..0d31fcd --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,163 @@ +package server + +import ( + "davidallendj/opaal/internal/flows" + "davidallendj/opaal/internal/oauth" + "davidallendj/opaal/internal/oidc" + "encoding/json" + "fmt" + "net/http" + "os" + + "github.com/davidallendj/go-utils/httpx" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/nikolalohinski/gonja/v2" + "github.com/nikolalohinski/gonja/v2/exec" +) + +type Server struct { + *http.Server + Host string `yaml:"host"` + Port int `yaml:"port"` + Callback string `yaml:"callback"` + State string `yaml:"state"` +} + +func (s *Server) SetListenAddr(host string, port int) { + s.Addr = s.GetListenAddr() +} + +func (s *Server) GetListenAddr() string { + return fmt.Sprintf("%s:%d", s.Host, s.Port) +} + +func (s *Server) Login(buttons string, provider *oidc.IdentityProvider, client *oauth.Client, eps flows.JwtBearerEndpoints, params flows.JwtBearerFlowParams) error { + var target = "" + + // check if callback is set + if s.Callback == "" { + s.Callback = "/oidc/callback" + } + + var code string + var accessToken string + r := chi.NewRouter() + r.Use(middleware.RedirectSlashes) + r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + target = r.Header.Get("target") + http.Redirect(w, r, "/login", http.StatusSeeOther) + }) + r.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { + // show login page with notice to redirect + template, err := gonja.FromFile("pages/index.html") + if err != nil { + panic(err) + } + + data := exec.NewContext(map[string]interface{}{ + "loginButtons": buttons, + }) + + if err = template.Execute(w, data); err != nil { // Prints: Hello Bob! + panic(err) + } + }) + r.HandleFunc(s.Callback, func(w http.ResponseWriter, r *http.Request) { + // get the code from the OIDC provider + if r != nil { + code = r.URL.Query().Get("code") + fmt.Printf("Authorization code: %v\n", code) + + // use code from response and exchange for bearer token (with ID token) + bearerToken, err := client.FetchTokenFromAuthenticationServer( + code, + provider.Endpoints.Token, + s.State, + ) + if err != nil { + fmt.Printf("failed to fetch token from authentication server: %v\n", err) + http.Redirect(w, r, "/error", http.StatusInternalServerError) + return + } + + // extract ID and access tokens from bearer + var data map[string]any + err = json.Unmarshal([]byte(bearerToken), &data) + if err != nil { + fmt.Printf("failed to unmarshal token: %v\n", err) + return + } + if data["error"] != nil { + fmt.Printf("the response from the authentication server returned an error (%v): %v", data["error"], data["error_description"]) + http.Redirect(w, r, "/error", http.StatusInternalServerError) + return + } + if data["id_token"] == nil { + fmt.Printf("no ID token found\n") + http.Redirect(w, r, "/error", http.StatusInternalServerError) + return + } + + // extract scopes from ID token and add to trusted issuer + + // complete JWT bearer flow to receive access token from authorization server + // fmt.Printf("bearer: %v\n", string(bearerToken)) + params.IdToken = data["id_token"].(string) + accessToken, err = flows.NewJwtBearerFlow(eps, params) + if err != nil { + fmt.Printf("failed to complete JWT bearer flow: %v\n", err) + w.Header().Add("Content-type", "text/html") + http.Redirect(w, r, "/error", http.StatusInternalServerError) + return + } + } + + http.Redirect(w, r, "/success", http.StatusSeeOther) + }) + r.HandleFunc("/success", func(w http.ResponseWriter, r *http.Request) { + fmt.Printf("Serving success page.\n") + template, err := gonja.FromFile("pages/success.html") + if err != nil { + panic(err) + } + + data := exec.NewContext(map[string]interface{}{ + "accessToken": accessToken, + }) + + if err = template.Execute(w, data); err != nil { // Prints: Hello Bob! + panic(err) + } + if target != "" { + httpx.MakeHttpRequest(target, http.MethodPost, []byte(accessToken), httpx.Headers{}) + } + }) + r.HandleFunc("/error", func(w http.ResponseWriter, r *http.Request) { + fmt.Printf("Serving error page.") + errorPage, err := os.ReadFile("pages/error.html") + if err != nil { + fmt.Printf("failed to load error page: %v\n", err) + } + w.Write(errorPage) + }) + s.Handler = r + + return s.ListenAndServe() +} + +func (s *Server) Serve(data chan []byte) error { + output, ok := <-data + if !ok { + return fmt.Errorf("failed to receive data") + } + + fmt.Printf("Received data: %v\n", string(output)) + // http.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) { + + // }) + r := chi.NewRouter() + + s.Handler = r + return s.ListenAndServe() +}