diff --git a/cmd/config.go b/cmd/config.go index c69b435..260bc6a 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -1,7 +1,9 @@ package cmd import ( - "davidallendj/oidc-auth/internal/util" + "davidallendj/opal/internal/oauth" + "davidallendj/opal/internal/oidc" + "davidallendj/opal/internal/util" "fmt" "log" "os" @@ -11,35 +13,51 @@ import ( yaml "gopkg.in/yaml.v2" ) +type Server struct { + Host string `yaml:"host"` + Port int `yaml:"port"` +} + +type AuthEndpoints struct { + Identities string `yaml:"identities"` + TrustedIssuers string `yaml:"trusted-issuers"` + AccessToken string `yaml:"access-token"` + ServerConfig string `yaml:"server-config"` +} + type Config struct { - Host string `yaml:"host"` - Port int `yaml:"port"` - RedirectUri []string `yaml:"redirect-uri"` - State string `yaml:"state"` - ResponseType string `yaml:"response-type"` - Scope []string `yaml:"scope"` - ClientId string `yaml:"client.id"` - ClientSecret string `yaml:"client.secret"` - OIDCHost string `yaml:"oidc.host"` - OIDCPort int `yaml:"oidc.port"` - IdentitiesUrl string `yaml:"identities-url"` - AccessTokenUrl string `yaml:"access-token-url"` + Server Server `yaml:"server"` + Client oauth.Client `yaml:"client"` + IdentityProvider oidc.IdentityProvider `yaml:"oidc"` + State string `yaml:"state"` + ResponseType string `yaml:"response-type"` + Scope []string `yaml:"scope"` + AuthEndpoints AuthEndpoints `yaml:"urls"` + OpenBrowser bool `yaml:"open-browser"` } func NewConfig() Config { return Config{ - Host: "127.0.0.1", - Port: 3333, - RedirectUri: []string{""}, - State: util.RandomString(20), - ResponseType: "code", - Scope: []string{"openid", "profile", "email"}, - ClientId: "", - ClientSecret: "", - OIDCHost: "127.0.0.1", - OIDCPort: 80, - IdentitiesUrl: "", - AccessTokenUrl: "", + Server: Server{ + Host: "127.0.0.1", + Port: 3333, + }, + Client: oauth.Client{ + Id: "", + Secret: "", + RedirectUris: []string{""}, + }, + IdentityProvider: *oidc.NewIdentityProvider(), + State: util.RandomString(20), + ResponseType: "code", + Scope: []string{"openid", "profile", "email"}, + AuthEndpoints: AuthEndpoints{ + Identities: "", + AccessToken: "", + TrustedIssuers: "", + ServerConfig: "", + }, + OpenBrowser: false, } } diff --git a/cmd/login.go b/cmd/login.go index d0a44fa..5f1a11a 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -1,9 +1,9 @@ package cmd import ( - "davidallendj/oidc-auth/internal/api" - "davidallendj/oidc-auth/internal/oidc" - "davidallendj/oidc-auth/internal/util" + "davidallendj/opal/internal/api" + "davidallendj/opal/internal/oidc" + "davidallendj/opal/internal/util" "encoding/json" "errors" "fmt" @@ -13,83 +13,123 @@ import ( "github.com/spf13/cobra" ) -var ( - identitiesUrl = "" - accessTokenUrl = "" -) +func hasRequiredParams(config *Config) bool { + return config.Client.Id != "" && config.Client.Secret != "" +} var loginCmd = &cobra.Command{ Use: "login", Short: "Start the login flow", Run: func(cmd *cobra.Command, args []string) { + // load config if found if configPath != "" { - config = LoadConfig(configPath) - } else { - config = NewConfig() + exists, err := util.PathExists(configPath) + if err != nil { + fmt.Printf("failed to load config") + os.Exit(1) + } else if exists { + config = LoadConfig(configPath) + } else { + config = NewConfig() + } + } + // try and fetch server configuration if provided URL + idp := oidc.NewIdentityProvider() + if config.AuthEndpoints.ServerConfig != "" { + idp.FetchServerConfig(config.AuthEndpoints.ServerConfig) + } else { + // otherwise, use what's provided in config file + idp.Issuer = config.IdentityProvider.Issuer + idp.Endpoints = config.IdentityProvider.Endpoints + idp.Supported = config.IdentityProvider.Supported } - oidcProvider := oidc.NewOIDCProvider() - oidcProvider.Host = config.OIDCHost - oidcProvider.Port = config.OIDCPort - // check if the client ID is set - if config.ClientId == "" { + // check if all appropriate parameters are set in config + if !hasRequiredParams(&config) { fmt.Printf("client ID must be set\n") os.Exit(1) } + + // build the authorization URL to redirect user for social sign-in var authorizationUrl = util.BuildAuthorizationUrl( - oidcProvider.GetAuthorizeUrl(), - config.ClientId, - config.RedirectUri, + idp.Endpoints.Authorize, + config.Client.Id, + config.Client.RedirectUris, config.State, config.ResponseType, config.Scope, ) - // print the authorization URL for the user to log in - fmt.Printf("login with identity provider: %s\n", authorizationUrl) + // print the authorization URL for sharing + serverAddr := fmt.Sprintf("%s:%d", config.IdentityProvider.Issuer) + fmt.Printf(`Login with identity provider: + %s/login + %s\n`, + serverAddr, 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 response from OIDC provider...\n") - code, err := api.WaitForAuthorizationCode(config.Host, config.Port) + fmt.Printf("Waiting for authorization code redirect @%s/oidc/callback...\n", serverAddr) + code, err := api.WaitForAuthorizationCode(serverAddr, authorizationUrl) if errors.Is(err, http.ErrServerClosed) { - fmt.Printf("server closed\n") + fmt.Printf("Server closed.\n") } else if err != nil { - fmt.Printf("error starting server: %s\n", err) + fmt.Printf("Error starting server: %s\n", err) os.Exit(1) } - // use code from response and exchange for bearer token - tokenString, err := api.FetchToken(code, oidcProvider.GetTokenUrl(), config.ClientId, config.ClientSecret, config.State, config.RedirectUri) + // use code from response and exchange for bearer token (with ID token) + tokenString, err := api.FetchIssuerToken( + code, + idp.Endpoints.Token, + config.Client, + config.State, + ) if err != nil { fmt.Printf("%v\n", err) return } + // extract ID token from bearer as JSON string for easy consumption var data map[string]any json.Unmarshal([]byte(tokenString), &data) idToken := data["id_token"].(string) - // create a new identity with Ory Kratos if identitiesUrl is provided - if config.IdentitiesUrl != "" { - api.CreateIdentity(config.IdentitiesUrl, idToken) - api.FetchIdentities(config.IdentitiesUrl) + // create a new identity with identity and session manager if url is provided + if config.AuthEndpoints.Identities != "" { + api.CreateIdentity(config.AuthEndpoints.Identities, idToken) + api.FetchIdentities(config.AuthEndpoints.Identities) } - // use ID token/user info to get access token from Ory Hydra - if config.AccessTokenUrl != "" { - api.FetchAccessToken(config.AccessTokenUrl, config.ClientId, idToken) + // fetch JWKS and add issuer to authentication server to submit ID token + jwk, err := api.FetchJwk("") + if err != nil { + fmt.Printf("failed to fetch JWK: %v\n", err) + } else { + api.AddTrustedIssuer(config.AuthEndpoints.TrustedIssuers, jwk.(string)) + } + + // use ID token/user info to fetch access token from authentication server + if config.AuthEndpoints.AccessToken != "" { + api.FetchAccessToken(config.AuthEndpoints.AccessToken, config.Client.Id, idToken, config.Scope) } }, } func init() { - loginCmd.Flags().StringVar(&config.ClientId, "client.id", config.ClientId, "set the client ID") - loginCmd.Flags().StringVar(&config.ClientSecret, "client.secret", config.ClientSecret, "set the client secret") - loginCmd.Flags().StringSliceVar(&config.RedirectUri, "redirect-uri", config.RedirectUri, "set the redirect URI") + 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.Host, "host", config.Host, "set the listening host") - loginCmd.Flags().IntVar(&config.Port, "port", config.Port, "set the listening port") + 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") rootCmd.AddCommand(loginCmd) } diff --git a/go.mod b/go.mod index cb17d59..6c5275a 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,23 @@ -module davidallendj/oidc-auth +module davidallendj/opal go 1.22.0 require ( + github.com/lestrrat-go/jwx v1.2.28 github.com/spf13/cobra v1.8.0 gopkg.in/yaml.v2 v2.4.0 ) require ( + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect + github.com/lestrrat-go/blackmagic v1.0.2 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/crypto v0.17.0 // indirect ) diff --git a/go.sum b/go.sum index d90dbd8..bd6cd57 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,87 @@ github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +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/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/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 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/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/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/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 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.6.1/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/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/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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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= diff --git a/internal/api/api.go b/internal/api/api.go index a770344..3f844b1 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -2,40 +2,65 @@ package api import ( "bytes" + "davidallendj/opal/internal/oauth" "fmt" "io" "net/http" "net/url" "strings" + "time" ) -func WaitForAuthorizationCode(host string, port int) (string, error) { +func WaitForAuthorizationCode(serverAddr string, loginUrl string) (string, error) { var code string - s := &http.Server{ - Addr: fmt.Sprintf("%s:%d", host, port), - } + s := &http.Server{Addr: serverAddr} + http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { + // redirect directly to identity provider with this endpoint + http.Redirect(w, r, loginUrl, http.StatusSeeOther) + }) http.HandleFunc("/oidc/callback", func(w http.ResponseWriter, r *http.Request) { // get the code from the OIDC provider if r != nil { code = r.URL.Query().Get("code") - fmt.Printf("authorization code: %v\n", code) + fmt.Printf("Authorization code: %v\n", code) } s.Close() - }) - fmt.Printf("listening for authorization code at %s/oidc/callback\n", s.Addr) return code, s.ListenAndServe() } -func FetchToken(code string, remoteUrl string, clientId string, clientSecret string, state string, redirectUri []string) (string, error) { +func FetchIssuerToken(code string, remoteUrl string, client oauth.Client, state string) (string, error) { var token string data := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, - "client_id": {clientId}, - "client_secret": {clientSecret}, + "client_id": {client.Id}, + "client_secret": {client.Secret}, "state": {state}, - "redirect_uri": {strings.Join(redirectUri, ",")}, + "redirect_uri": {strings.Join(client.RedirectUris, ",")}, + } + res, err := http.PostForm(remoteUrl, data) + if err != nil { + return "", fmt.Errorf("failed to get ID token: %s", err) + } + defer res.Body.Close() + + b, err := io.ReadAll(res.Body) + if err != nil { + return "", fmt.Errorf("failed to read response body: %v", err) + } + token = string(b) + + fmt.Printf("%v\n", token) + return token, nil +} + +func FetchAccessToken(remoteUrl string, clientId string, jwt string, scopes []string) (string, error) { + // hydra endpoint: /oauth/token + var token string + data := url.Values{ + "grant_type": {"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"}, + "assertion": {jwt}, } res, err := http.PostForm(remoteUrl, data) if err != nil { @@ -53,31 +78,34 @@ func FetchToken(code string, remoteUrl string, clientId string, clientSecret str return token, nil } -func FetchAccessToken(remoteUrl string, clientId string, jwt string) (string, error) { - var token string - data := url.Values{ - "grant_type": {"client_credentials"}, - "client_id": {clientId}, - "client_assertion_type": {"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"}, - "client_assertion": {jwt}, - } - res, err := http.PostForm(remoteUrl, data) - if err != nil { - return "", fmt.Errorf("failed to get token: %s", err) - } - defer res.Body.Close() +func AddTrustedIssuer(remoteUrl string, issuer string, subject string, duration time.Duration, jwk string, scope []string) error { + // hydra endpoint: /admin/trust/grants/jwt-bearer/issuers + data := []byte(fmt.Sprintf(`{ + "allow_any_subject": false, + "issuer": "%s", + "subject": "%s" + "expires_at": "%v" + "jwk": %v, + "scope": [ j%s ], + }`, issuer, subject, time.Now().Add(duration), jwk, strings.Join(scope, ","))) - b, err := io.ReadAll(res.Body) + req, err := http.NewRequest("POST", remoteUrl, bytes.NewBuffer(data)) if err != nil { - return "", fmt.Errorf("failed to read response body: %v", err) + return fmt.Errorf("failed to create a new request: %v", err) } - token = string(b) - - fmt.Printf("%v\n", token) - return token, nil + req.Header.Add("Content-Type", "application/json") + // req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", idToken)) + client := &http.Client{} + res, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to do request: %v", err) + } + fmt.Printf("%d\n", res.StatusCode) + return nil } func CreateIdentity(remoteUrl string, idToken string) error { + // kratos endpoint: /admin/identities data := []byte(`{ "schema_id": "preset://email", "traits": { @@ -114,3 +142,7 @@ func FetchIdentities(remoteUrl string) error { fmt.Printf("%v\n", res) return nil } + +func RedirectSuccess() { + // show a success page with the user's access token +} diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index af578c5..93ab687 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -1,15 +1,15 @@ package oauth type Client struct { - Id string - Secret string - Issuer string + Id string `yaml:"id"` + Secret string `yaml:"secret"` + RedirectUris []string `yaml:"redirect_uris"` } func NewClient() *Client { return &Client{ - Id: "", - Secret: "", - Issuer: "", + Id: "", + Secret: "", + RedirectUris: []string{""}, } } diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index 4f1821e..9f965f2 100644 --- a/internal/oidc/oidc.go +++ b/internal/oidc/oidc.go @@ -1,38 +1,100 @@ package oidc -import "fmt" +import ( + "context" + "fmt" -type OpenIDConnectProvider struct { - Host string - Port int - AuthorizeEndpoint string - TokenEndpoint string - ConfigEndpoint string + "github.com/lestrrat-go/jwx/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 } -func NewOIDCProvider() *OpenIDConnectProvider { - return &OpenIDConnectProvider{ - Host: "127.0.0.1", - Port: 80, - AuthorizeEndpoint: "/oauth/authorize", - TokenEndpoint: "/oauth/token", +type Endpoints struct { + Authorize string `json:"authorize_endpoint" yaml:"authorize"` + 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"` +} +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"` +} + +func NewIdentityProvider() *IdentityProvider { + p := &IdentityProvider{Issuer: "127.0.0.1"} + p.Endpoints = Endpoints{ + Authorize: p.Issuer + "/oauth/authorize", + Token: p.Issuer + "/oauth/token", + Revocation: p.Issuer + "/oauth/revocation", + Introspection: p.Issuer + "/oauth/introspect", + UserInfo: p.Issuer + "/oauth/userinfo", + Jwks: p.Issuer + "/oauth/discovery/keys", } -} - -func (oidc *OpenIDConnectProvider) GetAuthorizeUrl() string { - if oidc.Port != 80 { - return fmt.Sprintf("%s:%d", oidc.Host, oidc.Port) + oidc.AuthorizeEndpoint + p.Supported = Supported{ + ResponseTypes: []string{"code"}, + ResponseModes: []string{"query"}, + GrantTypes: []string{ + "authorization_code", + "client_credentials", + "refresh_token", + }, + TokenEndpointAuthMethods: []string{ + "client_secret_basic", + "client_secret_post", + }, + SubjectTypes: []string{"public"}, + IdTokenSigningAlgValues: []string{"RS256"}, + ClaimTypes: []string{"normal"}, + Claims: []string{ + "iss", + "sub", + "aud", + "exp", + "iat", + }, } - return oidc.Host + oidc.AuthorizeEndpoint + return p } -func (oidc *OpenIDConnectProvider) GetTokenUrl() string { - if oidc.Port != 80 { - return fmt.Sprintf("%s:%d", oidc.Host, oidc.Port) + oidc.TokenEndpoint - } - return oidc.Host + oidc.TokenEndpoint -} - -func (oidc *OpenIDConnectProvider) FetchServerConfiguration(url string) { +func (p *IdentityProvider) FetchServerConfig(url string) { // make a request to a server's openid-configuration } + +func (p *IdentityProvider) FetchJwk(url string) error { + // + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + set, err := jwk.Fetch(ctx, url) + 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 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 +} diff --git a/internal/util/util.go b/internal/util/util.go index 9bf6de6..244dc98 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -5,6 +5,8 @@ import ( "math/rand" "net/url" "os" + "os/exec" + "runtime" "strings" ) @@ -58,3 +60,22 @@ func PathExists(path string) (bool, error) { } 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() +}