diff --git a/.gitignore b/.gitignore index d372638..2c385f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ *.json +*.db +*.code-workspace opaal diff --git a/README.md b/README.md index b8a0e49..47f2b74 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # OIDC Provider Authentication/Authorization Login (OPAAL) -This is a small, simple, experimental OIDC login helper tool that automates the authorization code lohin flow defined by [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1) for social sign-in with identity providers (IdP) like Google, Facebook, or GitHub. This tool is made to work when your identity provider is separate from your authorization server, and we only need the IdP to receive an ID token. In this document, the identity provider (or authentication server) is strictly the OIDC implementation that identifies the resource owner (ID token) whereas the resource provider (or authorization server) is the OIDC implementation that grants access to a resource (access token). This tool is tested with Ory Kratos and Hydra for user identity and session management and OAuth2/OIDC implementation respectively. +This is a small, simple, experimental OIDC login helper tool that automates the authorization flows defined by [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1) for social sign-in with identity providers (IdP) like Google, Facebook, or GitHub. This tool is made to work when your identity provider is separate from your authorization server, and we only need the IdP to receive an ID token. In this document, the identity provider (or authentication server) is strictly the OIDC implementation that identifies the resource owner (ID token) whereas the resource provider (or authorization server) is the OIDC implementation that grants access to a resource (access token). OPAAL assumes that the authentication server is external and the authorization server is owned. This tool is tested with Ory Kratos and Hydra for user identity and session management and OAuth2/OIDC implementation respectively. -Note: This tool acts as an OAuth client, contains client secrets, and is not to be exposed to users! +Note: This tool acts as an OAuth client, contains client secrets, and should not to be exposed to users! It would probably also be a good idea to use a reverse proxy and firewall to protect admin endpoints. ## Build and Usage diff --git a/cmd/config.go b/cmd/config.go index 6f9ddbc..52ff313 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -2,9 +2,10 @@ package cmd import ( opaal "davidallendj/opaal/internal" - "davidallendj/opaal/internal/util" "fmt" + "github.com/davidallendj/go-utils/pathx" + "github.com/spf13/cobra" ) @@ -15,7 +16,7 @@ var configCmd = &cobra.Command{ // create a new config at all args (paths) for _, path := range args { // check and make sure something doesn't exist first - if exists, err := util.PathExists(path); exists || err != nil { + if exists, err := pathx.PathExists(path); exists || err != nil { fmt.Printf("file or directory exists\n") continue } diff --git a/cmd/login.go b/cmd/login.go index 1817a7c..187f266 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -2,22 +2,56 @@ package cmd import ( opaal "davidallendj/opaal/internal" + "davidallendj/opaal/internal/db" + "davidallendj/opaal/internal/oidc" "fmt" "os" "github.com/spf13/cobra" ) +var ( + client opaal.Client +) + var loginCmd = &cobra.Command{ Use: "login", Short: "Start the login flow", Run: func(cmd *cobra.Command, args []string) { for { - err := opaal.Login(&config) + // try and find client with valid identity provider config + var provider *oidc.IdentityProvider + for _, c := range config.Authentication.Clients { + // try to get identity provider info locally first + _, err := db.GetIdentityProvider(config.Options.CachePath, c.Issuer) + if err != nil && !config.Options.LocalOnly { + fmt.Printf("fetching config from issuer: %v\n", c.Issuer) + // try to get info remotely by fetching + provider, err = oidc.FetchServerConfig(c.Issuer) + if err != nil { + fmt.Printf("failed to fetch server config: %v\n", err) + continue + } + client = c + // fetch the provider's JWKS + err := provider.FetchJwks() + if err != nil { + fmt.Printf("failed to fetch JWKS: %v\n", err) + } + break + } + } + + if provider == nil { + fmt.Printf("failed to retrieve provider config\n") + os.Exit(1) + } + + err := opaal.Login(&config, &client, provider) if err != nil { fmt.Printf("%v\n", err) os.Exit(1) - } else if config.RunOnce { + } else if config.Options.RunOnce { break } } @@ -25,17 +59,17 @@ var loginCmd = &cobra.Command{ } func init() { - loginCmd.Flags().StringVar(&config.Client.Id, "client.id", config.Client.Id, "set the client ID") - loginCmd.Flags().StringVar(&config.Client.Secret, "client.secret", config.Client.Secret, "set the client secret") - loginCmd.Flags().StringSliceVar(&config.Client.RedirectUris, "redirect-uri", config.Client.RedirectUris, "set the redirect URI") - loginCmd.Flags().StringVar(&config.ResponseType, "response-type", config.ResponseType, "set the response-type") - loginCmd.Flags().StringSliceVar(&config.Scope, "scope", config.Scope, "set the scopes") - loginCmd.Flags().StringVar(&config.State, "state", config.State, "set the state") - loginCmd.Flags().StringVar(&config.Server.Host, "host", config.Server.Host, "set the listening host") - loginCmd.Flags().IntVar(&config.Server.Port, "port", config.Server.Port, "set the listening port") - loginCmd.Flags().BoolVar(&config.OpenBrowser, "open-browser", config.OpenBrowser, "automatically open link in browser") - loginCmd.Flags().BoolVar(&config.DecodeIdToken, "decode-id-token", config.DecodeIdToken, "decode and print ID token from identity provider") - loginCmd.Flags().BoolVar(&config.DecodeAccessToken, "decore-access-token", config.DecodeAccessToken, "decode and print access token from authorization server") - loginCmd.Flags().BoolVar(&config.RunOnce, "once", config.RunOnce, "set whether to run login once and exit") + loginCmd.Flags().StringVar(&client.Id, "client.id", client.Id, "set the client ID") + loginCmd.Flags().StringVar(&client.Secret, "client.secret", client.Secret, "set the client secret") + loginCmd.Flags().StringSliceVar(&client.RedirectUris, "client.redirect-uris", client.RedirectUris, "set the redirect URI") + loginCmd.Flags().StringSliceVar(&client.Scope, "client.scope", client.Scope, "set the scopes") + loginCmd.Flags().StringVar(&config.Server.Host, "server.host", config.Server.Host, "set the listening host") + loginCmd.Flags().IntVar(&config.Server.Port, "server.port", config.Server.Port, "set the listening port") + loginCmd.Flags().BoolVar(&config.Options.OpenBrowser, "open-browser", config.Options.OpenBrowser, "automatically open link in browser") + loginCmd.Flags().BoolVar(&config.Options.DecodeIdToken, "decode-id-token", config.Options.DecodeIdToken, "decode and print ID token from identity provider") + loginCmd.Flags().BoolVar(&config.Options.DecodeAccessToken, "decore-access-token", config.Options.DecodeAccessToken, "decode and print access token from authorization server") + loginCmd.Flags().BoolVar(&config.Options.RunOnce, "once", config.Options.RunOnce, "set whether to run login once and exit") + loginCmd.Flags().StringVar(&config.Options.FlowType, "flow", config.Options.FlowType, "set the grant-type/authorization flow") + loginCmd.Flags().BoolVar(&config.Options.LocalOnly, "local", config.Options.LocalOnly, "only fetch identity provider configs stored locally") rootCmd.AddCommand(loginCmd) } diff --git a/cmd/root.go b/cmd/root.go index 156b29c..4ec5905 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,16 +2,16 @@ package cmd import ( opaal "davidallendj/opaal/internal" - "davidallendj/opaal/internal/util" "fmt" "os" + "github.com/davidallendj/go-utils/pathx" "github.com/spf13/cobra" ) var ( - configPath = "" - config opaal.Config + confPath = "" + config opaal.Config ) var rootCmd = &cobra.Command{ Use: "opaal", @@ -21,21 +21,6 @@ var rootCmd = &cobra.Command{ }, } -func initConfig() { - // load config if found or create a new one - if configPath != "" { - exists, err := util.PathExists(configPath) - if err != nil { - fmt.Printf("failed to load config") - os.Exit(1) - } else if exists { - config = opaal.LoadConfig(configPath) - } else { - config = opaal.NewConfig() - } - } -} - func Execute() { if err := rootCmd.Execute(); err != nil { fmt.Fprintf(os.Stderr, "failed to start CLI: %s", err) @@ -45,5 +30,21 @@ func Execute() { func init() { cobra.OnInitialize(initConfig) - rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "set the config path") + rootCmd.PersistentFlags().StringVar(&confPath, "config", "", "set the config path") + rootCmd.PersistentFlags().StringVar(&config.Options.CachePath, "cache", "", "set the cache path") +} + +func initConfig() { + // load config if found or create a new one + if confPath != "" { + exists, err := pathx.PathExists(confPath) + if err != nil { + fmt.Printf("failed to load config") + os.Exit(1) + } else if exists { + config = opaal.LoadConfig(confPath) + } else { + config = opaal.NewConfig() + } + } } diff --git a/config.yaml b/config.yaml new file mode 100755 index 0000000..48308d3 --- /dev/null +++ b/config.yaml @@ -0,0 +1,56 @@ +version: "0.0.1" +server: + host: "127.0.0.1" + port: 3333 + callback: "/oidc/callback" + +providers: + facebook: "http://facebook.com" + forgejo: "http://git.towk.local:3000" + gitlab: "https://gitlab.newmexicoconsortium.org" + github: "https://github.com" + +authentication: + clients: + - id: "7527e7b4-c96a-4df0-8fc5-00fde18bb65d" + secret: "gto_cc5uvpb5lsdczkwnbarvwmbpv5kcjwg7nhbc75zt65yrfh2ldenq" + name: "forgejo" + issuer: "http://git.towk.local:3000" + scope: + - "openid" + - "profile" + - "read" + - "email" + redirect-uris: + - "http://127.0.0.1:3333/oidc/callback" + - id: "7c0fab1153674a258a705976fcb9468350df3addd91de4ec622fc9ed24bfbcdd" + secret: "a9a8bc55b0cd99236756093adc00ab17855fa507ce106b8038e7f9390ef2ad99" + name: "gitlab" + issuer: "http://gitlab.newmexicoconsortium.org" + scope: + - "openid" + - "profile" + - "email" + redirect-uris: + - "http://127.0.0.1:3333/oidc/callback" + flows: + authorization-code: + state: "" + client-credentials: + +authorization: + urls: + #identities: http://127.0.0.1:4434/admin/identities + trusted-issuers: http://127.0.0.1:4445/admin/trust/grants/jwt-bearer/issuers + login: http://127.0.0.1:4433/self-service/login/api + clients: http://127.0.0.1:4445/admin/clients + authorize: http://127.0.0.1:4444/oauth2/auth + register: http://127.0.0.1:4444/oauth2/register + token: http://127.0.0.1:4444/oauth2/token + + +options: + decode-id-token: true + decode-access-token: true + run-once: true + open-browser: false diff --git a/docker/configs/hydra/hydra.yml b/docker/configs/hydra/hydra.yml index 06772f6..ae49165 100644 --- a/docker/configs/hydra/hydra.yml +++ b/docker/configs/hydra/hydra.yml @@ -18,6 +18,8 @@ secrets: - youReallyNeedToChangeThis oidc: + dynamic_client_registration: + enabled: true subject_identifiers: supported_types: - pairwise @@ -34,3 +36,6 @@ oauth2: log: leak_sensitive_values: true + +strategies: + access_token: jwt diff --git a/go.mod b/go.mod index e459e10..e0ad618 100644 --- a/go.mod +++ b/go.mod @@ -3,25 +3,41 @@ module davidallendj/opaal go 1.22.0 require ( + github.com/davidallendj/go-utils v0.0.0-20240302194916-fe292bcf24a4 github.com/go-chi/chi v1.5.5 github.com/go-chi/chi/v5 v5.0.12 - github.com/golang-jwt/jwt v3.2.2+incompatible - github.com/lestrrat-go/jwx v1.2.28 + github.com/google/uuid v1.6.0 + github.com/jmoiron/sqlx v1.3.5 + github.com/lestrrat-go/jwx/v2 v2.0.20 + github.com/mattn/go-sqlite3 v1.14.6 + github.com/nikolalohinski/gonja/v2 v2.2.0 github.com/spf13/cobra v1.8.0 - golang.org/x/net v0.10.0 + golang.org/x/net v0.21.0 gopkg.in/yaml.v2 v2.4.0 + k8s.io/apimachinery v0.29.2 ) require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kr/text v0.2.0 // indirect github.com/lestrrat-go/blackmagic v1.0.2 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.4 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/option v1.0.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.5 // indirect - golang.org/x/crypto v0.17.0 // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index d7a787d..ff8a3de 100644 --- a/go.sum +++ b/go.sum @@ -1,94 +1,116 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/davidallendj/go-utils v0.0.0-20240302194916-fe292bcf24a4 h1:6LeOczLfpq27cDfu4r6bRU3zGeBER9fy+iecHG5dDSA= +github.com/davidallendj/go-utils v0.0.0-20240302194916-fe292bcf24a4/go.mod h1:/hcpHd4um12taX6iLuMmwxosoyN6E2Ws8QxDpnY07oo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw= github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= +github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A= -github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8= +github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= -github.com/lestrrat-go/jwx v1.2.28 h1:uadI6o0WpOVrBSf498tRXZIwPpEtLnR9CvqPFXeI5sA= -github.com/lestrrat-go/jwx v1.2.28/go.mod h1:nF+91HEMh/MYFVwKPl5HHsBGMPscqbQb+8IDQdIazP8= -github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/jwx/v2 v2.0.20 h1:sAgXuWS/t8ykxS9Bi2Qtn5Qhpakw1wrcjxChudjolCc= +github.com/lestrrat-go/jwx/v2 v2.0.20/go.mod h1:UlCSmKqw+agm5BsOBfEAbTvKsEApaGNqHAEUTv5PJC4= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nikolalohinski/gonja/v2 v2.2.0 h1:tAs3BDHNjvPj48F2BL5t7iVhN32HhgeldAl3EmdsLh8= +github.com/nikolalohinski/gonja/v2 v2.2.0/go.mod h1:l9DuWJvT/BddBr2SsmEimESD6msSqRw7u5HzI2Um+sc= +github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= +github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= +github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= +github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= 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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ= +golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/tools v0.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ= +golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/apimachinery v0.29.2 h1:EWGpfJ856oj11C52NRCHuU7rFDwxev48z+6DSlGNsV8= +k8s.io/apimachinery v0.29.2/go.mod h1:6HVkd1FwxIagpYrHSwJlQqZI3G9LfYWRPAkUvLnXTKU= diff --git a/internal/authenticate.go b/internal/authenticate.go new file mode 100644 index 0000000..91f5940 --- /dev/null +++ b/internal/authenticate.go @@ -0,0 +1,118 @@ +package opaal + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/davidallendj/go-utils/httpx" +) + +func (client *Client) IsFlowInitiated() bool { + return client.FlowId != "" +} + +func (client *Client) BuildAuthorizationUrl(issuer string, state string) string { + return issuer + "?" + "client_id=" + client.Id + + "&redirect_uri=" + url.QueryEscape(strings.Join(client.RedirectUris, ",")) + + "&response_type=code" + // this has to be set to "code" + "&state=" + state + + "&scope=" + strings.Join(client.Scope, "+") + + "&resource=" + url.QueryEscape("http://127.0.0.1:4444/oauth2/token") +} + +func (client *Client) InitiateLoginFlow(loginUrl string) error { + // kratos: GET /self-service/login/api + req, err := http.NewRequest("GET", loginUrl, bytes.NewBuffer([]byte{})) + if err != nil { + return fmt.Errorf("failed to make request: %v", err) + } + res, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to do request: %v", err) + } + defer res.Body.Close() + + // get the flow ID from response + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %v", err) + } + + var flowData map[string]any + err = json.Unmarshal(body, &flowData) + if err != nil { + return fmt.Errorf("failed to unmarshal flow data: %v\n%v", err, string(body)) + } else { + client.FlowId = flowData["id"].(string) + } + return nil +} + +func (client *Client) FetchFlowData(url string) (map[string]any, error) { + //kratos: GET /self-service/login/flows?id={flowId} + + // replace {id} in string with actual value + url = strings.ReplaceAll(url, "{id}", client.FlowId) + _, b, err := httpx.MakeHttpRequest(url, http.MethodGet, nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to make request: %v", err) + } + + var flowData map[string]any + err = json.Unmarshal(b, &flowData) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal flow data: %v", err) + } + return flowData, nil +} + +func (client *Client) FetchCSRFToken(flowUrl string) error { + data, err := client.FetchFlowData(flowUrl) + if err != nil { + return fmt.Errorf("failed to fetch flow data: %v", err) + } + + // iterate through nodes and extract the CSRF token attribute from the flow data + ui := data["ui"].(map[string]any) + nodes := ui["nodes"].([]any) + for _, node := range nodes { + attrs := node.(map[string]any)["attributes"].(map[string]any) + name := attrs["name"].(string) + if name == "csrf_token" { + client.CsrfToken = attrs["value"].(string) + return nil + } + } + return fmt.Errorf("failed to extract CSRF token: not found") +} + +func (client *Client) FetchTokenFromAuthenticationServer(code string, remoteUrl string, state string) ([]byte, error) { + body := url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {client.Id}, + "client_secret": {client.Secret}, + "redirect_uri": {strings.Join(client.RedirectUris, ",")}, + } + // add optional params if valid + if code != "" { + body["code"] = []string{code} + } + if state != "" { + body["state"] = []string{state} + } + res, err := http.PostForm(remoteUrl, body) + if err != nil { + return nil, fmt.Errorf("failed to get ID token: %s", err) + } + defer res.Body.Close() + + // domain, _ := url.Parse("http://127.0.0.1") + // client.Jar.SetCookies(domain, res.Cookies()) + + return io.ReadAll(res.Body) +} diff --git a/internal/authorization_code.go b/internal/authorization_code.go new file mode 100644 index 0000000..efc8ae2 --- /dev/null +++ b/internal/authorization_code.go @@ -0,0 +1,424 @@ +package opaal + +import ( + "crypto/rand" + "crypto/rsa" + "davidallendj/opaal/internal/oidc" + "encoding/json" + "errors" + "fmt" + "net/http" + "reflect" + "time" + + "github.com/davidallendj/go-utils/util" + "github.com/google/uuid" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" +) + +// TODO: change authorization code flow to use these instead +type AuthorizationCodeFlowEndpoints struct { + Login string + Token string + Identities string + TrustedIssuer string + Register string +} + +func AuthorizationCodeWithConfig(config *Config, server *Server, client *Client, idp *oidc.IdentityProvider) error { + // check preconditions are met + err := verifyParams(config, server, client, idp) + if err != nil { + return err + } + + // build the authorization URL to redirect user for social sign-in + state := config.Authentication.Flows["authorization_code"]["state"] + var authorizationUrl = client.BuildAuthorizationUrl(idp.Endpoints.Authorization, state) + + // print the authorization URL for sharing + fmt.Printf("Login with identity provider:\n\n %s/login\n %s\n\n", + server.GetListenAddr(), authorizationUrl, + ) + + // automatically open browser to initiate login flow (only useful for testing and debugging) + if config.Options.OpenBrowser { + util.OpenUrl(authorizationUrl) + } + + // authorize oauth client and listen for callback from provider + fmt.Printf("Waiting for authorization code redirect @%s/oidc/callback...\n", server.GetListenAddr()) + code, err := server.WaitForAuthorizationCode(authorizationUrl, "") + 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) + } + + // start up another server in background to listen for success or failures + d := StartListener(server) + + // use code from response and exchange for bearer token (with ID token) + bearerToken, err := client.FetchTokenFromAuthenticationServer( + code, + idp.Endpoints.Token, + state, + ) + if err != nil { + return fmt.Errorf("failed to fetch token from issuer: %v", err) + } + // fmt.Printf("%v\n", string(bearerToken)) + + // unmarshal data to get id_token and access_token + var data map[string]any + err = json.Unmarshal([]byte(bearerToken), &data) + if err != nil || data == nil { + return fmt.Errorf("failed to unmarshal token: %v", err) + } + + // make sure we have an ID token + if data["id_token"] == nil { + return fmt.Errorf("no ID token found...aborting") + } + + // extract ID token from bearer as JSON string for easy consumption + idToken := data["id_token"].(string) + idJwtSegments, err := util.DecodeJwt(idToken) + if err != nil { + fmt.Printf("failed to parse ID token: %v\n", err) + } else { + fmt.Printf("id_token: %v\n", idToken) + if config.Options.DecodeIdToken { + if err != nil { + fmt.Printf("failed to decode JWT: %v\n", err) + } else { + for i, segment := range idJwtSegments { + // don't print last segment (signatures) + if i == len(idJwtSegments)-1 { + break + } + fmt.Printf("%s\n", string(segment)) + } + } + } + fmt.Println() + } + + // extract the access token to get the scopes + accessToken := data["access_token"].(string) + accessJwtSegments, err := util.DecodeJwt(accessToken) + if err != nil || len(accessJwtSegments) <= 0 { + fmt.Printf("failed to parse access token: %v\n", err) + } else { + fmt.Printf("access_token (from identity provider): %v\n", accessToken) + if config.Options.DecodeIdToken { + if err != nil { + fmt.Printf("failed to decode JWT: %v\n", err) + } else { + for i, segment := range accessJwtSegments { + // don't print last segment (signatures) + if i == len(accessJwtSegments)-1 { + break + } + fmt.Printf("%s\n", string(segment)) + } + } + } + fmt.Println() + } + + if !config.Options.ForwardToken { + + // TODO: implement our own JWT to send to Hydra + // 1. verify that the JWT from the issuer is valid + key, ok := idp.Jwks.Key(0) + if !ok { + return fmt.Errorf("no key found in key set") + } + + parsedIdToken, err := jwt.ParseString(idToken, jwt.WithKey(jwa.RS256, key)) + if err != nil { + return fmt.Errorf("failed to parse ID token: %v", err) + } + _, err = jwt.ParseString(accessToken, jwt.WithKeySet(idp.Jwks)) + if err != nil { + return fmt.Errorf("failed to parse access token: %v", err) + } + + _, err = jws.Verify([]byte(idToken), jws.WithKeySet(idp.Jwks), jws.WithValidateKey(true)) + if err != nil { + return fmt.Errorf("failed to verify JWT: %v", err) + } + + // 2. create a new JWKS (or just JWK) to be verified + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return fmt.Errorf("failed to generate private RSA k-ey: %v", err) + } + privateJwk, err := jwk.FromRaw(privateKey) + if err != nil { + return fmt.Errorf("failed to create private JWK: %v", err) + } + publicJwk, err := jwk.PublicKeyOf(privateJwk) + if err != nil { + return fmt.Errorf("failed to create public JWK: %v", err) + } + publicJwk.Set("kid", uuid.New().String()) + + // 3. add opaal's server host as a trusted issuer with JWK + fmt.Printf("Attempting to add issuer to authorization server...\n") + res, err := client.AddTrustedIssuer( + config.Authorization.RequestUrls.TrustedIssuers, + server.Addr, + publicJwk, + "1", + time.Second*3600, + ) + if err != nil { + return fmt.Errorf("failed to add trusted issuer: %v", err) + } + fmt.Printf("%v\n", string(res)) + + // 4. create a new JWT based on the claims from the identity provider and sign + payload := parsedIdToken.PrivateClaims() + payload["iss"] = server.Addr + payload["aud"] = []string{config.Authorization.RequestUrls.Token} + payload["iat"] = time.Now().Unix() + payload["nbf"] = time.Now().Unix() + payload["exp"] = time.Now().Add(time.Second * 3600).Unix() + payload["sub"] = "1" + payloadJson, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %v", err) + } + newToken, err := jws.Sign(payloadJson, jws.WithJSON(), jws.WithKey(jwa.RS256, privateJwk)) + if err != nil { + return fmt.Errorf("failed to sign token: %v", err) + } + + // sig = rsasha256(b64urlencode(header) + "." + b64urlencode(payload)) + // signature := util.EncodeBase64() + util.EncodeBase64() + + + // 5. dynamically register new OAuth client and authorize it + fmt.Printf("Registering new OAuth2 client with authorization server...\n") + res, err = client.RegisterOAuthClient(config.Authorization.RequestUrls.Register, []string{}) + if err != nil { + return fmt.Errorf("failed to register client: %v", err) + } + fmt.Printf("%v\n", string(res)) + + // extract the client info from response + var clientData map[string]any + err = json.Unmarshal(res, &clientData) + if err != nil { + return fmt.Errorf("failed to unmarshal client data: %v", err) + } else { + // check for error first + errJson := clientData["error"] + if errJson == nil { + client.Id = clientData["client_id"].(string) + client.Secret = clientData["client_secret"].(string) + } else { + // delete client and create again + fmt.Printf("Attempting to delete client...\n") + err := client.DeleteOAuthClient(config.Authorization.RequestUrls.Clients) + if err != nil { + return fmt.Errorf("failed to delete OAuth client: %v", err) + } + fmt.Printf("Attempting to re-create client...\n") + res, err := client.CreateOAuthClient(config.Authorization.RequestUrls.Clients, []string{}) + if err != nil { + return fmt.Errorf("failed to register client: %v", err) + } + fmt.Printf("%v\n", string(res)) + } + } + + // authorize the client + // fmt.Printf("Attempting to authorize client...\n") + // res, err = client.AuthorizeOAuthClient(config.Authorization.RequestUrls.Authorize) + // if err != nil { + // return fmt.Errorf("failed to authorize client: %v", err) + // } + // fmt.Printf("%v\n", string(res)) + + // 6. send JWT to authorization server and receive a access token + if config.Authorization.RequestUrls.Token != "" { + fmt.Printf("Fetching access token from authorization server...\n") + res, err := client.PerformTokenGrant(config.Authorization.RequestUrls.Token, string(newToken)) + if err != nil { + return fmt.Errorf("failed to fetch access token: %v", err) + } + fmt.Printf("%s\n", res) + } + } else { + // extract the scope from access token claims + // var scope []string + // var accessJsonPayload map[string]any + // var accessJwtPayload []byte = accessJwtSegments[1] + // if accessJsonPayload != nil { + // err := json.Unmarshal(accessJwtPayload, &accessJsonPayload) + // if err != nil { + // return fmt.Errorf("failed to unmarshal JWT: %v", err) + // } + // scope = idJsonPayload["scope"].([]string) + // } + + // create a new identity with identity and session manager if url is provided + // if config.RequestUrls.Identities != "" { + // fmt.Printf("Attempting to create a new identity...\n") + // err := client.CreateIdentity(config.RequestUrls.Identities, idToken) + // if err != nil { + // return fmt.Errorf("failed to create new identity: %v", err) + // } + // _, err = client.FetchIdentities(config.RequestUrls.Identities) + // if err != nil { + // return fmt.Errorf("failed to fetch identities: %v", err) + // } + // fmt.Printf("Created new identity successfully.\n\n") + // } + + // extract the subject from ID token claims + var subject string + var audience []string + var idJsonPayload map[string]any + var idJwtPayload []byte = idJwtSegments[1] + if idJwtPayload != nil { + err := json.Unmarshal(idJwtPayload, &idJsonPayload) + if err != nil { + return fmt.Errorf("failed to unmarshal JWT: %v", err) + } + subject = idJsonPayload["sub"].(string) + audType := reflect.ValueOf(idJsonPayload["aud"]) + switch audType.Kind() { + case reflect.String: + audience = append(audience, idJsonPayload["aud"].(string)) + case reflect.Array: + audience = idJsonPayload["aud"].([]string) + } + } else { + return fmt.Errorf("failed to extract subject from ID token claims") + } + + // fetch JWKS and add issuer to authentication server to submit ID token + fmt.Printf("Fetching JWKS from authentication server for verification...\n") + err = idp.FetchJwks() + if err != nil { + return fmt.Errorf("failed to fetch JWK: %v", err) + } else { + fmt.Printf("Successfully retrieved JWK from authentication server.\n\n") + fmt.Printf("Attempting to add issuer to authorization server...\n") + res, err := client.AddTrustedIssuerWithIdentityProvider( + config.Authorization.RequestUrls.TrustedIssuers, + idp, + subject, + time.Duration(1000), + ) + if err != nil { + return fmt.Errorf("failed to add trusted issuer: %v", err) + } + fmt.Printf("%v\n", string(res)) + } + + // add client ID to audience + audience = append(audience, client.Id) + audience = append(audience, "http://127.0.0.1:4444/oauth2/token") + + // try and register a new client with authorization server + fmt.Printf("Registering new OAuth2 client with authorization server...\n") + res, err := client.RegisterOAuthClient(config.Authorization.RequestUrls.Register, audience) + if err != nil { + return fmt.Errorf("failed to register client: %v", err) + } + fmt.Printf("%v\n", string(res)) + + // extract the client info from response + var clientData map[string]any + err = json.Unmarshal(res, &clientData) + if err != nil { + return fmt.Errorf("failed to unmarshal client data: %v", err) + } else { + // check for error first + errJson := clientData["error"] + if errJson == nil { + client.Id = clientData["client_id"].(string) + client.Secret = clientData["client_secret"].(string) + } else { + // delete client and create again + fmt.Printf("Attempting to delete client...\n") + err := client.DeleteOAuthClient(config.Authorization.RequestUrls.Clients) + if err != nil { + return fmt.Errorf("failed to delete OAuth client: %v", err) + } + fmt.Printf("Attempting to re-create client...\n") + res, err := client.CreateOAuthClient(config.Authorization.RequestUrls.Clients, audience) + if err != nil { + return fmt.Errorf("failed to register client: %v", err) + } + fmt.Printf("%v\n", string(res)) + } + } + + // authorize the client + // fmt.Printf("Attempting to authorize client...\n") + // res, err = client.AuthorizeOAuthClient(config.Authorization.RequestUrls.Authorize) + // if err != nil { + // return fmt.Errorf("failed to authorize client: %v", err) + // } + // fmt.Printf("%v\n", string(res)) + + // use ID token/user info to fetch access token from authentication server + if config.Authorization.RequestUrls.Token != "" { + fmt.Printf("Fetching access token from authorization server...\n") + res, err := client.PerformTokenGrant(config.Authorization.RequestUrls.Token, idToken) + if err != nil { + return fmt.Errorf("failed to fetch access token: %v", err) + } + fmt.Printf("%s\n", res) + } + } + var access_token []byte + d <- access_token + return nil +} + +func verifyParams(config *Config, server *Server, client *Client, idp *oidc.IdentityProvider) error { + // make sure we have a valid server and client + if server == nil { + return fmt.Errorf("server not initialized or valid (server == nil)") + } + if client == nil { + return fmt.Errorf("client not initialized or valid (client == nil)") + } + if idp == nil { + return fmt.Errorf("identity provider not initialized or valid (idp == nil)") + } + // check if all appropriate parameters are set in config + if !HasRequiredConfigParams(config) { + return fmt.Errorf("required params not set correctly or missing") + } + return nil +} + +func StartListener(server *Server) chan []byte { + d := make(chan []byte) + quit := make(chan bool) + + go server.Serve(d) + go func() { + select { + case <-d: + fmt.Printf("got access token") + quit <- true + case <-quit: + close(d) + close(quit) + return + default: + } + }() + return d +} diff --git a/internal/authorize.go b/internal/authorize.go new file mode 100644 index 0000000..a778606 --- /dev/null +++ b/internal/authorize.go @@ -0,0 +1,248 @@ +package opaal + +import ( + "bytes" + "davidallendj/opaal/internal/oidc" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "github.com/davidallendj/go-utils/httpx" + "github.com/davidallendj/go-utils/util" + "github.com/lestrrat-go/jwx/v2/jwk" +) + +func (client *Client) AddTrustedIssuer(url string, issuer string, key jwk.Key, subject string, expires time.Duration) ([]byte, error) { + // hydra endpoint: POST /admin/trust/grants/jwt-bearer/issuers + quotedScopes := make([]string, len(client.Scope)) + for i, s := range client.Scope { + quotedScopes[i] = fmt.Sprintf("\"%s\"", s) + } + jwkstr, err := json.Marshal(key) + if err != nil { + return nil, fmt.Errorf("failed to marshal JWK: %v", err) + } + // NOTE: Can also include "jwks_uri" instead + data := []byte(fmt.Sprintf("{"+ + "\"allow_any_subject\": false,"+ + "\"issuer\": \"%s\","+ + "\"subject\": \"%s\","+ + "\"expires_at\": \"%v\","+ + "\"jwk\": %v,"+ + "\"scope\": [ %s ]"+ + "}", issuer, subject, time.Now().Add(expires).Format(time.RFC3339), string(jwkstr), strings.Join(quotedScopes, ","))) + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(data)) + // req.Header.Add("X-CSRF-Token", client.CsrfToken.Value) + if err != nil { + return nil, fmt.Errorf("failed to make request: %v", err) + } + req.Header.Add("Content-Type", "application/json") + // req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", idToken)) + res, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to do request: %v", err) + } + defer res.Body.Close() + + return io.ReadAll(res.Body) +} + +func (client *Client) AddTrustedIssuerWithIdentityProvider(url string, idp *oidc.IdentityProvider, subject string, expires time.Duration) ([]byte, error) { + // hydra endpoint: POST /admin/trust/grants/jwt-bearer/issuers + key, ok := idp.Jwks.Key(0) + if !ok { + return nil, fmt.Errorf("no keys found in key set") + } + return client.AddTrustedIssuer(url, idp.Issuer, key, subject, expires) +} + +func (client *Client) IsOAuthClientRegistered(clientUrl string) (bool, error) { + _, _, err := httpx.MakeHttpRequest(clientUrl, http.MethodGet, nil, nil) + if err != nil { + return false, fmt.Errorf("failed to make request: %v", err) + } + // TODO: need to check contents of actual response + return true, nil +} + +func (client *Client) GetOAuthClient(clientUrl string) error { + _, b, err := httpx.MakeHttpRequest(clientUrl, http.MethodGet, nil, nil) + if err != nil { + return fmt.Errorf("failed to make request: %v", err) + } + + fmt.Printf("GetOAuthClient: %v\n", string(b)) + + var data []map[string]any + err = json.Unmarshal(b, &data) + if err != nil { + return fmt.Errorf("failed to unmarshal JSON: %v", err) + } + + index := slices.IndexFunc(data, func(c map[string]any) bool { + if c["client_id"] == nil { + return false + } + return c["client_id"].(string) == client.Id + }) + if index < 0 { + return fmt.Errorf("client not found") + } + + // cast the redirect_uris from []any to []string and extract registration token + foundClient := data[index] + for _, uri := range foundClient["redirect_uris"].([]any) { + client.RedirectUris = append(client.RedirectUris, uri.(string)) + } + if foundClient["registration-access-token"] != nil { + client.RegistrationAccessToken = foundClient["registration-access-token"].(string) + } + + return nil +} + +func (client *Client) CreateOAuthClient(registerUrl string, audience []string) ([]byte, error) { + // hydra endpoint: POST /clients + audience = util.QuoteArrayStrings(audience) + body := httpx.Body(fmt.Sprintf(`{ + "client_id": "%s", + "client_name": "%s", + "client_secret": "%s", + "token_endpoint_auth_method": "client_secret_post", + "scope": "%s", + "grant_types": ["urn:ietf:params:oauth:grant-type:jwt-bearer"], + "response_types": ["token"], + "redirect_uris": ["http://127.0.0.1:3333/callback"], + "state": 12345678910, + "audience": [%s] + }`, client.Id, client.Id, client.Secret, strings.Join(client.Scope, " "), strings.Join(audience, ","), + )) + headers := httpx.Headers{ + "Content-Type": "application/json", + } + + _, b, err := httpx.MakeHttpRequest(registerUrl, http.MethodPost, []byte(body), headers) + if err != nil { + return nil, fmt.Errorf("failed to make request: %v", err) + } + var rjson map[string]any + err = json.Unmarshal(b, &rjson) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal response body: %v", err) + } + + // check for error first + errJson := rjson["error"] + if errJson == nil { + // set the client ID and secret of registered client + client.Id = rjson["client_id"].(string) + client.Secret = rjson["client_secret"].(string) + client.RegistrationAccessToken = rjson["registration_access_token"].(string) + } else { + return b, nil + } + + return b, err +} + +func (client *Client) RegisterOAuthClient(registerUrl string, audience []string) ([]byte, error) { + // hydra endpoint: POST /oauth2/register + audience = util.QuoteArrayStrings(audience) + body := httpx.Body(fmt.Sprintf(`{ + "client_name": "opaal", + "token_endpoint_auth_method": "client_secret_post", + "scope": "%s", + "grant_types": ["urn:ietf:params:oauth:grant-type:jwt-bearer"], + "response_types": ["token"], + "redirect_uris": ["http://127.0.0.1:3333/callback"], + "state": 12345678910, + "audience": [%s] + }`, strings.Join(client.Scope, " "), strings.Join(audience, ","), + )) + headers := httpx.Headers{ + "Content-Type": "application/json", + } + _, b, err := httpx.MakeHttpRequest(registerUrl, http.MethodPost, body, headers) + if err != nil { + return nil, fmt.Errorf("failed to make request: %v", err) + } + var rjson map[string]any + err = json.Unmarshal(b, &rjson) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal response body: %v", err) + } + + // check for error first + errJson := rjson["error"] + if errJson == nil { + // set the client ID and secret of registered client + client.Id = rjson["client_id"].(string) + client.Secret = rjson["client_secret"].(string) + client.RegistrationAccessToken = rjson["registration_access_token"].(string) + } else { + return b, nil + } + return b, err +} + +func (client *Client) AuthorizeOAuthClient(authorizeUrl string) ([]byte, error) { + // set the authorization header + body := []byte("grant_type=" + url.QueryEscape("urn:ietf:params:oauth:grant-type:jwt-bearer") + + "&scope=" + strings.Join(client.Scope, "+") + + "&client_id=" + client.Id + + "&client_secret=" + client.Secret + + "&redirect_uri=" + url.QueryEscape("http://127.0.0.1:3333/callback") + // FIXME: needs to not be hardcorded + "&response_type=token" + + "&state=12345678910", + ) + headers := httpx.Headers{ + "Authorization": "Bearer " + client.RegistrationAccessToken, + "Content-Type": "application/x-www-form-urlencoded", + } + _, b, err := httpx.MakeHttpRequest(authorizeUrl, http.MethodPost, body, headers) + if err != nil { + return nil, fmt.Errorf("failed to make HTTP request: %v", err) + } + + return b, nil +} + +func (client *Client) PerformTokenGrant(clientUrl string, encodedJwt string) ([]byte, error) { + // hydra endpoint: /oauth/token + body := "grant_type=" + url.QueryEscape("urn:ietf:params:oauth:grant-type:jwt-bearer") + + "&client_id=" + client.Id + + "&client_secret=" + client.Secret + + "&redirect_uri=" + url.QueryEscape("http://127.0.0.1:3333/callback") + // add optional params if valid + if encodedJwt != "" { + body += "&assertion=" + encodedJwt + } + if client.Scope != nil || len(client.Scope) > 0 { + body += "&scope=" + strings.Join(client.Scope, "+") + } + headers := httpx.Headers{ + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Bearer " + client.RegistrationAccessToken, + } + + _, b, err := httpx.MakeHttpRequest(clientUrl, http.MethodPost, []byte(body), headers) + + // set flow ID back to empty string to indicate a completed flow + client.FlowId = "" + + return b, err +} + +func (client *Client) DeleteOAuthClient(clientUrl string) error { + _, _, err := httpx.MakeHttpRequest(clientUrl+"/"+client.Id, http.MethodDelete, nil, nil) + if err != nil { + return fmt.Errorf("failed to make request: %v", err) + } + return nil +} diff --git a/internal/client.go b/internal/client.go index 781151c..d9746f4 100644 --- a/internal/client.go +++ b/internal/client.go @@ -1,277 +1,90 @@ package opaal import ( - "bytes" - "davidallendj/opaal/internal/oidc" - "davidallendj/opaal/internal/util" - "encoding/json" - "fmt" - "io" "net/http" "net/http/cookiejar" - "net/url" - "strings" - "time" + "slices" + "github.com/davidallendj/go-utils/mathx" "golang.org/x/net/publicsuffix" ) type Client struct { http.Client - Id string `yaml:"id"` - Secret string `yaml:"secret"` - RedirectUris []string `yaml:"redirect-uris"` - FlowId string - CsrfToken string + Id string `yaml:"id"` + Secret string `yaml:"secret"` + Name string `yaml:"name"` + Description string `yaml:"description"` + Issuer string `yaml:"issuer"` + RegistrationAccessToken string `yaml:"registration-access-token"` + RedirectUris []string `yaml:"redirect-uris"` + Scope []string `yaml:"scope"` + FlowId string + CsrfToken string +} + +func NewClient() *Client { + return &Client{} } func NewClientWithConfig(config *Config) *Client { - jar, _ := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + // make sure config is valid + if config == nil { + return nil + } + + // make sure we have at least one client + clients := config.Authentication.Clients + if len(clients) <= 0 { + return nil + } + + // use the first client found by default return &Client{ - Id: config.Client.Id, - Secret: config.Client.Secret, - RedirectUris: config.Client.RedirectUris, - Client: http.Client{Jar: jar}, + Id: clients[0].Id, + Secret: clients[0].Secret, + Name: clients[0].Name, + Issuer: clients[0].Issuer, + Scope: clients[0].Scope, + RedirectUris: clients[0].RedirectUris, } } -func (client *Client) IsFlowInitiated() bool { - return client.FlowId != "" +func NewClientWithConfigByIndex(config *Config, index int) *Client { + size := len(config.Authentication.Clients) + index = mathx.Clamp(index, 0, size) + return nil } -func (client *Client) BuildAuthorizationUrl(authEndpoint string, state string, responseType string, scope []string) string { - return authEndpoint + "?" + "client_id=" + client.Id + - "&redirect_uri=" + util.URLEscape(strings.Join(client.RedirectUris, ",")) + - "&response_type=" + responseType + - "&state=" + state + - "&scope=" + strings.Join(scope, "+") + - "&audience=http://127.0.0.1:4444/oauth2/token" -} - -func (client *Client) InitiateLoginFlow(loginUrl string) error { - // kratos: GET /self-service/login/api - req, err := http.NewRequest("GET", loginUrl, bytes.NewBuffer([]byte{})) - if err != nil { - return fmt.Errorf("failed to make request: %v", err) - } - res, err := client.Do(req) - if err != nil { - return fmt.Errorf("failed to do request: %v", err) - } - defer res.Body.Close() - - // get the flow ID from response - body, err := io.ReadAll(res.Body) - - var flowData map[string]any - err = json.Unmarshal(body, &flowData) - if err != nil { - return fmt.Errorf("failed to unmarshal flow data: %v\n%v", err, string(body)) - } else { - client.FlowId = flowData["id"].(string) +func NewClientWithConfigByName(config *Config, name string) *Client { + index := slices.IndexFunc(config.Authentication.Clients, func(c Client) bool { + return c.Name == name + }) + if index >= 0 { + return &config.Authentication.Clients[index] } return nil } -func (client *Client) FetchFlowData(flowUrl string) (map[string]any, error) { - //kratos: GET /self-service/login/flows?id={flowId} +func NewClientWithConfigByProvider(config *Config, issuer string) *Client { + index := slices.IndexFunc(config.Authentication.Clients, func(c Client) bool { + return c.Issuer == issuer + }) - // replace {id} in string with actual value - flowUrl = strings.ReplaceAll(flowUrl, "{id}", client.FlowId) - req, err := http.NewRequest("GET", flowUrl, nil) - if err != nil { - return nil, fmt.Errorf("failed to make request: %v", err) + if index >= 0 { + return &config.Authentication.Clients[index] } - res, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to do request: %v", err) - } - defer res.Body.Close() - - // get the flow data from response - body, err := io.ReadAll(res.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %v", err) - } - - var flowData map[string]any - err = json.Unmarshal(body, &flowData) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal flow data: %v", err) - } - return flowData, nil + return nil } -func (client *Client) FetchCSRFToken(flowUrl string) error { - data, err := client.FetchFlowData(flowUrl) - if err != nil { - return fmt.Errorf("failed to fetch flow data: %v", err) +func NewClientWithConfigById(config *Config, id string) *Client { + index := slices.IndexFunc(config.Authentication.Clients, func(c Client) bool { + return c.Id == id + }) + if index >= 0 { + return &config.Authentication.Clients[index] } - - // iterate through nodes and extract the CSRF token attribute from the flow data - ui := data["ui"].(map[string]any) - nodes := ui["nodes"].([]any) - for _, node := range nodes { - attrs := node.(map[string]any)["attributes"].(map[string]any) - name := attrs["name"].(string) - if name == "csrf_token" { - client.CsrfToken = attrs["value"].(string) - return nil - } - } - return fmt.Errorf("failed to extract CSRF token: not found") -} - -func (client *Client) FetchTokenFromAuthenticationServer(code string, remoteUrl string, state string) ([]byte, error) { - data := url.Values{ - "grant_type": {"authorization_code"}, - "code": {code}, - "client_id": {client.Id}, - "client_secret": {client.Secret}, - "state": {state}, - "redirect_uri": {strings.Join(client.RedirectUris, ",")}, - } - res, err := http.PostForm(remoteUrl, data) - if err != nil { - return nil, fmt.Errorf("failed to get ID token: %s", err) - } - defer res.Body.Close() - - domain, _ := url.Parse("http://127.0.0.1") - client.Jar.SetCookies(domain, res.Cookies()) - - return io.ReadAll(res.Body) -} - -func (client *Client) FetchTokenFromAuthorizationServer(remoteUrl string, jwt string, scope []string) ([]byte, error) { - // hydra endpoint: /oauth/token - data := "grant_type=" + util.URLEscape("urn:ietf:params:oauth:grant-type:jwt-bearer") + - "&client_id=" + client.Id + - "&client_secret=" + client.Secret + - "&scope=" + strings.Join(scope, "+") + - "&assertion=" + jwt - fmt.Printf("encoded params: %v\n\n", data) - req, err := http.NewRequest("POST", remoteUrl, bytes.NewBuffer([]byte(data))) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - if err != nil { - return nil, fmt.Errorf("failed to make request: %s", err) - } - res, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to do request: %v", err) - } - defer res.Body.Close() - - // set flow ID back to empty string to indicate a completed flow - client.FlowId = "" - - return io.ReadAll(res.Body) -} - -func (client *Client) AddTrustedIssuer(remoteUrl string, idp *oidc.IdentityProvider, subject string, duration time.Duration, scope []string) ([]byte, error) { - // hydra endpoint: POST /admin/trust/grants/jwt-bearer/issuers - if idp == nil { - return nil, fmt.Errorf("identity provided is nil") - } - jwkstr, err := json.Marshal(idp.Key) - if err != nil { - return nil, fmt.Errorf("failed to marshal JWK: %v", err) - } - quotedScopes := make([]string, len(scope)) - for i, s := range scope { - quotedScopes[i] = fmt.Sprintf("\"%s\"", s) - } - // NOTE: Can also include "jwks_uri" instead - data := []byte(fmt.Sprintf("{"+ - "\"allow_any_subject\": false,"+ - "\"issuer\": \"%s\","+ - "\"subject\": \"%s\","+ - "\"expires_at\": \"%v\","+ - "\"jwk\": %v,"+ - "\"scope\": [ %s ]"+ - "}", idp.Issuer, subject, time.Now().Add(duration).Format(time.RFC3339), string(jwkstr), strings.Join(quotedScopes, ","))) - fmt.Printf("%v\n", string(data)) - - req, err := http.NewRequest("POST", remoteUrl, bytes.NewBuffer(data)) - // req.Header.Add("X-CSRF-Token", client.CsrfToken.Value) - if err != nil { - return nil, fmt.Errorf("failed to make request: %v", err) - } - req.Header.Add("Content-Type", "application/json") - // req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", idToken)) - res, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to do request: %v", err) - } - defer res.Body.Close() - - return io.ReadAll(res.Body) -} - -func (client *Client) RegisterOAuthClient(registerUrl string, audience []string) ([]byte, error) { - // hydra endpoint: POST /clients - audience = util.QuoteArrayStrings(audience) - data := []byte(fmt.Sprintf(`{ - "client_name": "%s", - "client_secret": "%s", - "token_endpoint_auth_method": "client_secret_post", - "scope": "openid email profile", - "grant_types": ["client_credentials", "urn:ietf:params:oauth:grant-type:jwt-bearer"], - "response_types": ["token"], - "audience": [%s] - }`, client.Id, client.Secret, strings.Join(audience, ","))) - - req, err := http.NewRequest("POST", registerUrl, bytes.NewBuffer(data)) - if err != nil { - return nil, fmt.Errorf("failed to make request: %v", err) - } - req.Header.Add("Content-Type", "application/json") - // req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", idToken)) - res, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to do request: %v", err) - } - defer res.Body.Close() - - return io.ReadAll(res.Body) -} - -func (client *Client) CreateIdentity(remoteUrl string, idToken string) ([]byte, error) { - // kratos endpoint: /admin/identities - data := []byte(`{ - "schema_id": "preset://email", - "traits": { - "email": "docs@example.org" - } - }`) - - req, err := http.NewRequest("POST", remoteUrl, bytes.NewBuffer(data)) - if err != nil { - return nil, fmt.Errorf("failed to create a new request: %v", err) - } - req.Header.Add("Content-Type", "application/json") - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", idToken)) - // req.Header.Add("X-CSRF-Token", client.CsrfToken.Value) - res, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to do request: %v", err) - } - - return io.ReadAll(res.Body) -} - -func (client *Client) FetchIdentities(remoteUrl string) ([]byte, error) { - req, err := http.NewRequest("GET", remoteUrl, bytes.NewBuffer([]byte{})) - if err != nil { - return nil, fmt.Errorf("failed to create a new request: %v", err) - } - - res, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to do request: %v", err) - } - - return io.ReadAll(res.Body) + return nil } func (client *Client) ClearCookies() { diff --git a/internal/client_credentials.go b/internal/client_credentials.go new file mode 100644 index 0000000..efe20eb --- /dev/null +++ b/internal/client_credentials.go @@ -0,0 +1,48 @@ +package opaal + +import ( + "fmt" +) + +type ClientCredentialsFlowParams struct { + State string `yaml:"state"` + ResponseType string `yaml:"response-type"` +} + +type ClientCredentialsFlowEndpoints struct { + Create string + Authorize string + Token string +} + +func ClientCredentials(eps ClientCredentialsFlowEndpoints, client *Client) error { + // register a new OAuth 2 client with authorization srever + _, err := client.CreateOAuthClient(eps.Create, nil) + if err != nil { + return fmt.Errorf("failed to register OAuth client: %v", err) + } + + // authorize the client + _, err = client.AuthorizeOAuthClient(eps.Authorize) + if err != nil { + return fmt.Errorf("failed to authorize client: %v", err) + } + + // request a token from the authorization server + res, err := client.PerformTokenGrant(eps.Token, "") + if err != nil { + return fmt.Errorf("failed to fetch token from authorization server: %v", err) + } + + fmt.Printf("token: %v\n", string(res)) + return nil +} + +func ClientCredentialsWithConfig(config *Config, client *Client) error { + eps := ClientCredentialsFlowEndpoints{ + Create: config.Authorization.RequestUrls.Clients, + Authorize: config.Authorization.RequestUrls.Authorize, + Token: config.Authorization.RequestUrls.Token, + } + return ClientCredentials(eps, client) +} diff --git a/internal/config.go b/internal/config.go index b9c3946..f4e6c4e 100644 --- a/internal/config.go +++ b/internal/config.go @@ -1,56 +1,77 @@ package opaal import ( - "davidallendj/opaal/internal/oidc" - "davidallendj/opaal/internal/util" "log" "os" "path/filepath" + goutil "github.com/davidallendj/go-utils/util" + "gopkg.in/yaml.v2" ) +type FlowOptions map[string]string +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"` +} + +type RequestUrls struct { + Identities string `yaml:"identities"` + TrustedIssuers string `yaml:"trusted-issuers"` + Login string `yaml:"login"` + Clients string `yaml:"clients"` + Token string `yaml:"token"` + Authorize string `yaml:"authorize"` + Register string `yaml:"register"` +} + +type Authentication struct { + Clients []Client `yaml:"clients"` + Flows Flows `yaml:"flows"` +} + +type Authorization struct { + RequestUrls RequestUrls `yaml:"urls"` +} + type Config struct { - Version string `yaml:"version"` - Server Server `yaml:"server"` - Client Client `yaml:"client"` - IdentityProvider oidc.IdentityProvider `yaml:"oidc"` - State string `yaml:"state"` - ResponseType string `yaml:"response-type"` - Scope []string `yaml:"scope"` - ActionUrls ActionUrls `yaml:"urls"` - OpenBrowser bool `yaml:"open-browser"` - DecodeIdToken bool `yaml:"decode-id-token"` - DecodeAccessToken bool `yaml:"decode-access-token"` - RunOnce bool `yaml:"run-once"` + Version string `yaml:"version"` + Server Server `yaml:"server"` + Providers Providers `yaml:"providers"` + Options Options `yaml:"options"` + Authentication Authentication `yaml:"authentication"` + Authorization Authorization `yaml:"authorization"` } func NewConfig() Config { return Config{ - Version: util.GetCommit(), + Version: goutil.GetCommit(), Server: Server{ Host: "127.0.0.1", Port: 3333, }, - Client: Client{ - Id: "", - Secret: "", - RedirectUris: []string{""}, + Options: Options{ + DecodeIdToken: true, + DecodeAccessToken: true, + RunOnce: true, + OpenBrowser: false, + CachePath: "opaal.db", + FlowType: "authorization_code", + LocalOnly: false, + ForwardToken: false, }, - IdentityProvider: *oidc.NewIdentityProvider(), - State: util.RandomString(20), - ResponseType: "code", - Scope: []string{"openid", "profile", "email"}, - ActionUrls: ActionUrls{ - Identities: "", - AccessToken: "", - TrustedIssuers: "", - ServerConfig: "", - }, - OpenBrowser: false, - DecodeIdToken: false, - DecodeAccessToken: false, - RunOnce: true, + Authentication: Authentication{}, + Authorization: Authorization{}, } } @@ -86,3 +107,15 @@ func SaveDefaultConfig(path string) { return } } + +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 != "" + return hasClients && hasServer && hasEndpoints +} diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go new file mode 100644 index 0000000..147fc72 --- /dev/null +++ b/internal/db/sqlite.go @@ -0,0 +1,156 @@ +package db + +import ( + "davidallendj/opaal/internal/oidc" + "fmt" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +func CreateIdentityProvidersIfNotExists(path string) (*sqlx.DB, error) { + schema := ` + CREATE TABLE IF NOT EXISTS identity_providers ( + issuer TEXT NOT NULL, + authorization_endpoint TEXT, + token_endpoint TEXT, + revocation_endpoint TEXT, + introspection_endpoint TEXT, + userinfo_endpoint TEXT, + jwks_uri TEXT, + response_types_supported TEXT, + response_modes_supported TEXT, + grant_types_supported TEXT, + token_endpoint_auth_methods_supported TEXT, + subject_types_supported TEXT, + id_token_signing_alg_values_supported TEXT, + claim_types_supported TEXT, + claims_supported TEXT, + jwks TEXT, + + PRIMARY KEY (issuer) + ); + ` + db, err := sqlx.Open("sqlite3", path) + if err != nil { + return nil, fmt.Errorf("could not open database: %v", err) + } + db.MustExec(schema) + return db, nil +} + +func InsertIdentityProviders(path string, providers *[]oidc.IdentityProvider) error { + if providers == nil { + return fmt.Errorf("states == nil") + } + + // create database if it doesn't already exist + db, err := CreateIdentityProvidersIfNotExists(path) + if err != nil { + return err + } + + // insert all probe states into db + tx := db.MustBegin() + for _, state := range *providers { + sql := `INSERT OR REPLACE INTO identity_providers + ( + issuer, + authorization_endpoint, + token_endpoint, + revocation_endpoint, + introspection_endpoint, + userinfo_endpoint, + jwks_uri, + response_types_supported, + response_modes_supported, + grant_types_supported, + token_endpoint_auth_methods_supported, + subject_types_supported, + id_token_signing_alg_values_supported, + claim_types_supported, + claims_supported, + jwks + ) + VALUES + ( + :issuer, + :authorization_endpoint, + :token_endpoint, + :revocation_endpoint, + :introspection_endpoint, + :userinfo_endpoint, + :jwks_uri, + :response_types_supported, + :response_modes_supported, + :grant_types_supported, + :token_endpoint_auth_methods_supported, + :subject_types_supported, + :id_token_signing_alg_values_supported, + :claim_types_supported, + :claims_supported, + :jwks + );` + _, err := tx.NamedExec(sql, &state) + if err != nil { + fmt.Printf("could not execute transaction: %v\n", err) + } + } + err = tx.Commit() + if err != nil { + return fmt.Errorf("could not commit transaction: %v", err) + } + return nil +} + +func GetIdentityProvider(path string, issuer string) (*oidc.IdentityProvider, error) { + db, err := sqlx.Open("sqlite3", path) + if err != nil { + return nil, fmt.Errorf("could not open database: %v", err) + } + + results := &oidc.IdentityProvider{} + err = db.Select(&results, "SELECT * FROM magellan_scanned_ports ORDER BY host ASC, port ASC LIMIT 1;") + if err != nil { + return nil, fmt.Errorf("could not retrieve probes: %v", err) + } + return results, nil +} + +func GetIdentityProviders(path string) ([]oidc.IdentityProvider, error) { + db, err := sqlx.Open("sqlite3", path) + if err != nil { + return nil, fmt.Errorf("could not open database: %v", err) + } + + results := []oidc.IdentityProvider{} + err = db.Select(&results, "SELECT * FROM magellan_scanned_ports ORDER BY host ASC, port ASC;") + if err != nil { + return nil, fmt.Errorf("could not retrieve probes: %v", err) + } + return results, nil +} + +func DeleteIdentityProviders(path string, results *[]oidc.IdentityProvider) error { + if results == nil { + return fmt.Errorf("no probe results found") + } + db, err := sqlx.Open("sqlite3", path) + if err != nil { + return fmt.Errorf("could not open database: %v", err) + } + tx := db.MustBegin() + for _, state := range *results { + sql := `DELETE FROM identity_providers WHERE host = :issuer;` + _, err := tx.NamedExec(sql, &state) + if err != nil { + fmt.Printf("could not execute transaction: %v\n", err) + } + } + + err = tx.Commit() + if err != nil { + return fmt.Errorf("could not commit transaction: %v", err) + } + return nil +} diff --git a/internal/identities.go b/internal/identities.go new file mode 100644 index 0000000..e1ddee5 --- /dev/null +++ b/internal/identities.go @@ -0,0 +1,32 @@ +package opaal + +import ( + "fmt" + "net/http" + + "github.com/davidallendj/go-utils/httpx" +) + +func (client *Client) CreateIdentity(url string, idToken string) error { + // kratos endpoint: /admin/identities + body := []byte(`{ + "schema_id": "preset://email", + "traits": { + "email": "docs@example.org" + } + }`) + headers := httpx.Headers{ + "Content-Type": "application/json", + "Authorization": fmt.Sprintf("Bearer %s", idToken), + } + _, _, err := httpx.MakeHttpRequest(url, http.MethodPost, body, headers) + if err != nil { + return fmt.Errorf("failed to read response body: %v", err) + } + return nil +} + +func (client *Client) FetchIdentities(remoteUrl string) ([]byte, error) { + _, b, err := httpx.MakeHttpRequest(remoteUrl, http.MethodGet, []byte{}, httpx.Headers{}) + return b, err +} diff --git a/internal/login.go b/internal/login.go index 0beb0c5..686dbed 100644 --- a/internal/login.go +++ b/internal/login.go @@ -1,260 +1,37 @@ package opaal import ( + "davidallendj/opaal/internal/db" "davidallendj/opaal/internal/oidc" - "davidallendj/opaal/internal/util" - "encoding/json" - "errors" "fmt" - "net/http" - "reflect" - "time" ) -func Login(config *Config) error { +func Login(config *Config, client *Client, provider *oidc.IdentityProvider) error { if config == nil { return fmt.Errorf("config is not valid") } - // initialize client that will be used throughout login flow - server := NewServerWithConfig(config) - client := NewClientWithConfig(config) - - // initiate the login flow and get a flow ID and CSRF token - { - err := client.InitiateLoginFlow(config.ActionUrls.Login) - if err != nil { - return fmt.Errorf("failed to initiate login flow: %v", err) - } - err = client.FetchCSRFToken(config.ActionUrls.LoginFlowId) - if err != nil { - return fmt.Errorf("failed to fetch CSRF token: %v", err) - } - } - - // try and fetch server configuration if provided URL - idp := oidc.NewIdentityProvider() - if config.ActionUrls.ServerConfig != "" { - fmt.Printf("Fetching server configuration: %s\n", config.ActionUrls.ServerConfig) - err := idp.FetchServerConfig(config.ActionUrls.ServerConfig) - if err != nil { - return fmt.Errorf("failed to fetch server config: %v", err) - } - } else { - // otherwise, use what's provided in config file - idp.Issuer = config.IdentityProvider.Issuer - idp.Endpoints = config.IdentityProvider.Endpoints - idp.Supported = config.IdentityProvider.Supported - } - - // check if all appropriate parameters are set in config - if !HasRequiredParams(config) { - return fmt.Errorf("client ID must be set") - } - - // build the authorization URL to redirect user for social sign-in - var authorizationUrl = client.BuildAuthorizationUrl( - idp.Endpoints.Authorization, - config.State, - config.ResponseType, - config.Scope, - ) - - // print the authorization URL for sharing - fmt.Printf("Login with identity provider:\n\n %s/login\n %s\n\n", - server.GetListenAddr(), authorizationUrl, - ) - - // automatically open browser to initiate login flow (only useful for testing) - if config.OpenBrowser { - util.OpenUrl(authorizationUrl) - } - - // authorize oauth client and listen for callback from provider - fmt.Printf("Waiting for authorization code redirect @%s/oidc/callback...\n", server.GetListenAddr()) - code, err := server.WaitForAuthorizationCode(authorizationUrl) - 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) - } - - if client == nil { - fmt.Printf("client did not initialize\n") - } - - // start up another serve in background to listen for success or failures - d := make(chan []byte) - quit := make(chan bool) - var access_token []byte - go server.Serve(d) - go func() { - select { - case <-d: - fmt.Printf("got access token") - quit <- true - case <-quit: - close(d) - close(quit) - return - default: - } - }() - - // use code from response and exchange for bearer token (with ID token) - bearerToken, err := client.FetchTokenFromAuthenticationServer( - code, - idp.Endpoints.Token, - config.State, - ) + // make cache if it's not where expect + _, err := db.CreateIdentityProvidersIfNotExists(config.Options.CachePath) if err != nil { - return fmt.Errorf("failed to fetch token from issuer: %v", err) + fmt.Printf("failed to create cache: %v\n", err) } - // unmarshal data to get id_token and access_token - var data map[string]any - err = json.Unmarshal([]byte(bearerToken), &data) - if err != nil || data == nil { - return fmt.Errorf("failed to unmarshal token: %v", err) - } - - // extract ID token from bearer as JSON string for easy consumption - idToken := data["id_token"].(string) - idJwtSegments, err := util.DecodeJwt(idToken) - if err != nil { - fmt.Printf("failed to parse ID token: %v\n", err) - } else { - fmt.Printf("id_token: %v\n", idToken) - if config.DecodeIdToken { - if err != nil { - fmt.Printf("failed to decode JWT: %v\n", err) - } else { - for i, segment := range idJwtSegments { - // don't print last segment (signatures) - if i == len(idJwtSegments)-1 { - break - } - fmt.Printf("%s\n", string(segment)) - } - } - } - fmt.Println() - } - - // extract the access token to get the scopes - accessToken := data["access_token"].(string) - accessJwtSegments, err := util.DecodeJwt(accessToken) - if err != nil || len(accessJwtSegments) <= 0 { - fmt.Printf("failed to parse access token: %v\n", err) - } else { - fmt.Printf("access_token: %v\n", accessToken) - if config.DecodeIdToken { - if err != nil { - fmt.Printf("failed to decode JWT: %v\n", err) - } else { - for i, segment := range accessJwtSegments { - // don't print last segment (signatures) - if i == len(accessJwtSegments)-1 { - break - } - fmt.Printf("%s\n", string(segment)) - } - } - } - fmt.Println() - } - - // extract the scope from access token claims - // var scope []string - // var accessJsonPayload map[string]any - // var accessJwtPayload []byte = accessJwtSegments[1] - // if accessJsonPayload != nil { - // err := json.Unmarshal(accessJwtPayload, &accessJsonPayload) - // if err != nil { - // return fmt.Errorf("failed to unmarshal JWT: %v", err) - // } - // scope = idJsonPayload["scope"].([]string) - // } - - // create a new identity with identity and session manager if url is provided - if config.ActionUrls.Identities != "" { - fmt.Printf("Attempting to create a new identity...\n") - _, err := client.CreateIdentity(config.ActionUrls.Identities, idToken) + 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 { - return fmt.Errorf("failed to create new identity: %v", err) + fmt.Printf("failed to complete authorization code flow: %v\n", err) } - _, err = client.FetchIdentities(config.ActionUrls.Identities) + } else if config.Options.FlowType == "client_credentials" { + err := ClientCredentialsWithConfig(config, client) if err != nil { - return fmt.Errorf("failed to fetch identities: %v", err) - } - fmt.Printf("Created new identity successfully.\n\n") - } - - // extract the subject from ID token claims - var subject string - var audience []string - var idJsonPayload map[string]any - var idJwtPayload []byte = idJwtSegments[1] - if idJwtPayload != nil { - err := json.Unmarshal(idJwtPayload, &idJsonPayload) - if err != nil { - return fmt.Errorf("failed to unmarshal JWT: %v", err) - } - subject = idJsonPayload["sub"].(string) - audType := reflect.ValueOf(idJsonPayload["aud"]) - switch audType.Kind() { - case reflect.String: - audience = append(audience, idJsonPayload["aud"].(string)) - case reflect.Array: - audience = idJsonPayload["aud"].([]string) + fmt.Printf("failed to complete client credentials flow: %v", err) } } else { - return fmt.Errorf("failed to extract subject from ID token claims") + return fmt.Errorf("invalid grant type (options: authorization_code, client_credentials)") } - // fetch JWKS and add issuer to authentication server to submit ID token - fmt.Printf("Fetching JWKS from authentication server for verification...\n") - err = idp.FetchJwk(config.ActionUrls.JwksUri) - if err != nil { - return fmt.Errorf("failed to fetch JWK: %v", err) - } else { - fmt.Printf("Successfully retrieved JWK from authentication server.\n\n") - fmt.Printf("Attempting to add issuer to authorization server...\n") - res, err := client.AddTrustedIssuer(config.ActionUrls.TrustedIssuers, idp, subject, time.Duration(1000), config.Scope) - if err != nil { - return fmt.Errorf("failed to add trusted issuer: %v", err) - } - fmt.Printf("%v\n", string(res)) - } - - // try and register a new client with authorization server - fmt.Printf("Registering new OAuth2 client with authorization server...\n") - res, err := client.RegisterOAuthClient("http://127.0.0.1:4445/clients", audience) - if err != nil { - return fmt.Errorf("failed to register client: %v", err) - } - fmt.Printf("%v\n", string(res)) - - // extract the client info from response - var clientData map[string]any - err = json.Unmarshal(res, &clientData) - if err != nil { - return fmt.Errorf("failed to unmarshal client data: %v", err) - } else { - client.Id = clientData["client_id"].(string) - client.Secret = clientData["client_secret"].(string) - } - - // use ID token/user info to fetch access token from authentication server - if config.ActionUrls.AccessToken != "" { - fmt.Printf("Fetching access token from authorization server...\n") - res, err := client.FetchTokenFromAuthorizationServer(config.ActionUrls.AccessToken, idToken, config.Scope) - if err != nil { - return fmt.Errorf("failed to fetch access token: %v", err) - } - fmt.Printf("%s\n", res) - } - - d <- access_token return nil } diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index e17a120..543905b 100644 --- a/internal/oidc/oidc.go +++ b/internal/oidc/oidc.go @@ -9,33 +9,33 @@ import ( "net/http" "os" - "github.com/lestrrat-go/jwx/jwk" + "github.com/lestrrat-go/jwx/v2/jwk" ) type IdentityProvider struct { - Issuer string `json:"issuer" yaml:"issuer"` - Endpoints Endpoints `json:"endpoints" yaml:"endpoints"` - Supported Supported `json:"supported" yaml:"supported"` - Key jwk.Key + 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 } type Endpoints struct { - Authorization string `json:"authorization_endpoint" yaml:"authorization"` - Token string `json:"token_endpoint" yaml:"token"` - Revocation string `json:"revocation_endpoint" yaml:"revocation"` - Introspection string `json:"introspection_endpoint" yaml:"introspection"` - UserInfo string `json:"userinfo_endpoint" yaml:"userinfo"` - Jwks string `json:"jwks_uri" yaml:"jwks_uri"` + Authorization string `db:"authorization_endpoint" json:"authorization_endpoint" yaml:"authorization"` + Token string `db:"token_endpoint" json:"token_endpoint" yaml:"token"` + Revocation string `db:"revocation_endpoint" json:"revocation_endpoint" yaml:"revocation"` + Introspection string `db:"introspection_endpoint" json:"introspection_endpoint" yaml:"introspection"` + UserInfo string `db:"userinfo_endpoint" json:"userinfo_endpoint" yaml:"userinfo"` + JwksUri string `db:"jwks_uri" json:"jwks_uri" yaml:"jwks_uri"` } type Supported struct { - ResponseTypes []string `json:"response_types_supported"` - ResponseModes []string `json:"response_modes_supported"` - GrantTypes []string `json:"grant_types_supported"` - TokenEndpointAuthMethods []string `json:"token_endpoint_auth_methods_supported"` - SubjectTypes []string `json:"subject_types_supported"` - IdTokenSigningAlgValues []string `json:"id_token_signing_alg_values_supported"` - ClaimTypes []string `json:"claim_types_supported"` - Claims []string `json:"claims_supported"` + ResponseTypes []string `db:"response_types_supported" json:"response_types_supported"` + ResponseModes []string `db:"response_modes_supported" json:"response_modes_supported"` + GrantTypes []string `db:"grant_types_supported" json:"grant_types_supported"` + TokenEndpointAuthMethods []string `db:"token_endpoint_auth_methods_supported" json:"token_endpoint_auth_methods_supported"` + SubjectTypes []string `db:"subject_types_supported" json:"subject_types_supported"` + IdTokenSigningAlgValues []string `db:"id_token_signing_alg_values_supported" json:"id_token_signing_alg_values_supported"` + ClaimTypes []string `db:"claim_types_supported" json:"claim_types_supported"` + Claims []string `db:"claims_supported" json:"claims_supported"` } func NewIdentityProvider() *IdentityProvider { @@ -46,7 +46,7 @@ func NewIdentityProvider() *IdentityProvider { Revocation: p.Issuer + "/oauth/revocation", Introspection: p.Issuer + "/oauth/introspect", UserInfo: p.Issuer + "/oauth/userinfo", - Jwks: p.Issuer + "/oauth/discovery/keys", + JwksUri: p.Issuer + "/oauth/discovery/keys", } p.Supported = Supported{ ResponseTypes: []string{"code"}, @@ -109,55 +109,43 @@ func (p *IdentityProvider) LoadServerConfig(path string) error { return nil } -func (p *IdentityProvider) FetchServerConfig(url string) error { +func FetchServerConfig(issuer string) (*IdentityProvider, error) { // make a request to a server's openid-configuration - req, err := http.NewRequest("GET", url, bytes.NewBuffer([]byte{})) + req, err := http.NewRequest(http.MethodGet, issuer+"/.well-known/openid-configuration", bytes.NewBuffer([]byte{})) if err != nil { - return fmt.Errorf("failed to create a new request: %v", err) + return nil, fmt.Errorf("failed to create a new request: %v", err) } client := &http.Client{} // temp client to get info and not used in flow res, err := client.Do(req) if err != nil { - return fmt.Errorf("failed to do request: %v", err) + return nil, fmt.Errorf("failed to do request: %v", err) } body, err := io.ReadAll(res.Body) if err != nil { - return fmt.Errorf("failed to read response body: %v", err) + return nil, fmt.Errorf("failed to read response body: %v", err) } + var p IdentityProvider err = p.ParseServerConfig(body) if err != nil { - return fmt.Errorf("failed to parse server config: %v", err) + return nil, fmt.Errorf("failed to parse server config: %v", err) } - return nil + return &p, nil } -func (p *IdentityProvider) FetchJwk(url string) error { - if url == "" { - url = p.Endpoints.Jwks +func (p *IdentityProvider) FetchJwks() error { + if p.Endpoints.JwksUri == "" { + return fmt.Errorf("JWKS endpoint not set") } // fetch JWKS from identity provider ctx, cancel := context.WithCancel(context.Background()) defer cancel() - set, err := jwk.Fetch(ctx, url) + var err error + p.Jwks, err = jwk.Fetch(ctx, p.Endpoints.JwksUri) if err != nil { - return fmt.Errorf("%v", err) - } - // get the first JWK from set - for it := set.Iterate(context.Background()); it.Next(context.Background()); { - pair := it.Pair() - p.Key = pair.Value.(jwk.Key) - return nil + return fmt.Errorf("failed to fetch JWKS: %v", err) } - return fmt.Errorf("failed to load public key: %v", err) -} - -func (p *IdentityProvider) GetRawJwk() (any, error) { - var rawkey any - if err := p.Key.Raw(&rawkey); err != nil { - return nil, fmt.Errorf("failed to get raw key: %v", err) - } - return rawkey, nil + return nil } diff --git a/internal/opaal.go b/internal/opaal.go deleted file mode 100644 index 67dcf81..0000000 --- a/internal/opaal.go +++ /dev/null @@ -1,15 +0,0 @@ -package opaal - -type ActionUrls struct { - Identities string `yaml:"identities"` - TrustedIssuers string `yaml:"trusted-issuers"` - AccessToken string `yaml:"access-token"` - ServerConfig string `yaml:"server-config"` - JwksUri string `yaml:"jwks_uri"` - Login string `yaml:"login"` - LoginFlowId string `yaml:"login-flow-id"` -} - -func HasRequiredParams(config *Config) bool { - return config.Client.Id != "" && config.Client.Secret != "" -} diff --git a/internal/server.go b/internal/server.go index c048592..7c11426 100644 --- a/internal/server.go +++ b/internal/server.go @@ -8,17 +8,20 @@ import ( "github.com/go-chi/chi/middleware" "github.com/go-chi/chi/v5" + "github.com/nikolalohinski/gonja/v2" + "github.com/nikolalohinski/gonja/v2/exec" ) type Server struct { *http.Server - Host string `yaml:"host"` - Port int `yaml:"port"` + Host string `yaml:"host"` + Port int `yaml:"port"` + Callback string `yaml:"callback"` } -func NewServerWithConfig(config *Config) *Server { - host := config.Server.Host - port := config.Server.Port +func NewServerWithConfig(conf *Config) *Server { + host := conf.Server.Host + port := conf.Server.Port server := &Server{ Server: &http.Server{ Addr: fmt.Sprintf("%s:%d", host, port), @@ -37,7 +40,12 @@ func (s *Server) GetListenAddr() string { return fmt.Sprintf("%s:%d", s.Host, s.Port) } -func (s *Server) WaitForAuthorizationCode(loginUrl string) (string, error) { +func (s *Server) WaitForAuthorizationCode(loginUrl string, callback string) (string, error) { + // check if callback is set + if callback == "" { + callback = "/oidc/callback" + } + var code string r := chi.NewRouter() r.Use(middleware.RedirectSlashes) @@ -46,14 +54,20 @@ func (s *Server) WaitForAuthorizationCode(loginUrl string) (string, error) { }) r.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { // show login page with notice to redirect - loginPage, err := os.ReadFile("pages/index.html") + template, err := gonja.FromFile("pages/index.html") if err != nil { - fmt.Printf("failed to load login page: %v\n", err) + panic(err) + } + + data := exec.NewContext(map[string]interface{}{ + "loginUrl": loginUrl, + }) + + if err = template.Execute(w, data); err != nil { // Prints: Hello Bob! + panic(err) } - loginPage = []byte(strings.ReplaceAll(string(loginPage), "{{loginUrl}}", loginUrl)) - w.Write(loginPage) }) - r.HandleFunc("/oidc/callback", func(w http.ResponseWriter, r *http.Request) { + r.HandleFunc(callback, func(w http.ResponseWriter, r *http.Request) { // get the code from the OIDC provider if r != nil { code = r.URL.Query().Get("code") diff --git a/internal/util/util.go b/internal/util/util.go deleted file mode 100644 index 4ba8156..0000000 --- a/internal/util/util.go +++ /dev/null @@ -1,109 +0,0 @@ -package util - -import ( - "encoding/base64" - "math/rand" - "net/url" - "os" - "os/exec" - "runtime" - "strings" - - "github.com/golang-jwt/jwt" -) - -func RandomString(n int) string { - const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - const ( - letterIdxBits = 6 // 6 bits to represent a letter index - letterIdxMask = 1<= 0; { - if remain == 0 { - cache, remain = rand.Int63(), letterIdxMax - } - if idx := int(cache & letterIdxMask); idx < len(letterBytes) { - b[i] = letterBytes[idx] - i-- - } - cache >>= letterIdxBits - remain-- - } - - return string(b) -} - -func URLEscape(s string) string { - return url.QueryEscape(s) -} - -func EncodeBase64(s string) string { - return base64.StdEncoding.EncodeToString([]byte(s)) -} - -func DecodeJwt(encoded string) ([][]byte, error) { - // split the string into 3 segments and decode - segments := strings.Split(encoded, ".") - decoded := [][]byte{} - for _, segment := range segments { - bytes, _ := jwt.DecodeSegment(segment) - decoded = append(decoded, bytes) - } - return decoded, nil -} - -func PathExists(path string) (bool, error) { - _, err := os.Stat(path) - if err == nil { - return true, nil - } - if os.IsNotExist(err) { - return false, nil - } - return false, err -} - -// https://stackoverflow.com/questions/39320371/how-start-web-server-to-open-page-in-browser-in-golang -// open opens the specified URL in the default browser of the user. -func OpenUrl(url string) error { - var cmd string - var args []string - - switch runtime.GOOS { - case "windows": - cmd = "cmd" - args = []string{"/c", "start"} - case "darwin": - cmd = "open" - default: // "linux", "freebsd", "openbsd", "netbsd" - cmd = "xdg-open" - } - args = append(args, url) - return exec.Command(cmd, args...).Start() -} - -func GetCommit() string { - bytes, err := exec.Command("git", "rev --parse HEAD").Output() - if err != nil { - return "" - } - return string(bytes) -} - -func Tokenize(s string) map[string]any { - tokens := make(map[string]any) - - // find token enclosed in curly brackets - - return tokens -} - -func QuoteArrayStrings(arr []string) []string { - for i, v := range arr { - arr[i] = "\"" + v + "\"" - } - return arr -} diff --git a/pages/index.html b/pages/index.html index 5548a5d..ee880f1 100644 --- a/pages/index.html +++ b/pages/index.html @@ -2,5 +2,5 @@ Welcome to Opaal's default login in page! Click the link below to log in with your identity provider:

- Login + Login \ No newline at end of file