diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e9ad7ba..d6b75c4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- support automatic OIDC endpoint discovery via OAuth2 Protected Resource Metadata ([RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)) when available, with fallback to the Mia-Platform browser login flow + ## [v0.24.0] - 2026-04-28 ### Added diff --git a/README.md b/README.md index a3ddce33..d1ca64a5 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Read the documentation [here](./docs/10_overview.md). To start developing the CLI you must have this requirements: -- golang 1.19+ +- golang 1.25+ - make Once you have pulled the code locally, you can build the code with make: diff --git a/docs/20_setup.md b/docs/20_setup.md index fb03e71f..4de8fc0c 100644 --- a/docs/20_setup.md +++ b/docs/20_setup.md @@ -24,17 +24,17 @@ brew install mia-platform/tap/miactl #### Go -If you have [Golang] installed with a version >= 1.13 in your system and you have the `$GOPATH`env set, you can +If you have [Golang] installed with a version >= 1.25 in your system and you have the `$GOPATH`env set, you can install `miactl` like this: ```sh -go install github.com/mia-platform/miactl/cmd/miactl@v0.23.0 +go install github.com/mia-platform/miactl/cmd/miactl@v0.24.0 ``` Or like this if the `install` command is not available ```sh -go get -u github.com/mia-platform/miactl/cmd/miactl@v0.23.0 +go get -u github.com/mia-platform/miactl/cmd/miactl@v0.24.0 ``` #### Binary Download @@ -43,11 +43,11 @@ You can install `miactl` with the use of `curl` or `wget` and downloading the la choosing the correct platform and operating system: ```sh -curl -fsSL --proto '=https' --tlsv1.2 https://github.com/mia-platform/miactl/releases/download/v0.23.0/miactl-linux-amd64 -o /tmp/miactl +curl -fsSL --proto '=https' --tlsv1.2 https://github.com/mia-platform/miactl/releases/download/v0.24.0/miactl-linux-amd64 -o /tmp/miactl ``` ```sh -wget -q --https-only --secure-protocol=TLSv1_2 https://github.com/mia-platform/miactl/releases/download/v0.23.0/miactl-linux-amd64 -O /tmp/miactl +wget -q --https-only --secure-protocol=TLSv1_2 https://github.com/mia-platform/miactl/releases/download/v0.24.0/miactl-linux-amd64 -O /tmp/miactl ``` After you have downloaded the file you can validate it against the checksum you can find at this [url] running the @@ -75,7 +75,7 @@ sudo mv /tmp/miactl /usr/local/bin If you want to run the cli in its environment or you want to test the cli you can use the Docker image: ```sh -docker run ghcr.io/mia-platform/miactl:v0.23.0 miactl +docker run ghcr.io/mia-platform/miactl:v0.24.0 miactl ``` ### Windows @@ -171,7 +171,7 @@ only via APIs. [Homebrew]: https://brew.sh "The Missing Package Manager for macOS (or Linux)" [Golang]: https://go.dev "Build simple, secure, scalable systems with Go" -[url]: https://github.com/mia-platform/miactl/releases/download/v0.23.0/checksums.txt "miactl checksums" +[url]: https://github.com/mia-platform/miactl/releases/download/v0.24.0/checksums.txt "miactl checksums" [`bash-completion`]: https://github.com/scop/bash-completion "Programmable completion functions for bash" [`oh-my-zsh`]: https://ohmyz.sh "Oh My Zsh is a delightful, open source, community-driven framework for managing your Zsh configuration" diff --git a/go.mod b/go.mod index 9114094a..b92e197a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/mia-platform/miactl -go 1.25 +go 1.25.0 toolchain go1.25.6 @@ -12,15 +12,17 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 - golang.org/x/oauth2 v0.34.0 + golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.19.0 golang.org/x/text v0.33.0 sigs.k8s.io/kustomize/kyaml v0.21.0 ) require ( + github.com/coreos/go-oidc/v3 v3.18.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-errors/errors v1.4.2 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect diff --git a/go.sum b/go.sum index 574f5fc9..0874f334 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= +github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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= @@ -7,6 +9,8 @@ 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/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= @@ -75,6 +79,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= diff --git a/internal/authorization/authenticator.go b/internal/authorization/authenticator.go index 8ed24a97..32e73a4f 100644 --- a/internal/authorization/authenticator.go +++ b/internal/authorization/authenticator.go @@ -18,6 +18,7 @@ package authorization import ( "fmt" "net/http" + "os" "github.com/mia-platform/miactl/internal/client" ) @@ -77,6 +78,7 @@ func (a *authenticator) Wrap(rt http.RoundTripper) http.RoundTripper { next: rt, userAuth: a.cacheReadWriter, serverReadyHandler: func(url string) error { + fmt.Fprintf(os.Stderr, "Opening your browser for login. If it does not open automatically, please visit:\n%s\n", url) if err := open(url); err != nil { return fmt.Errorf("could not open the browser: %w", err) } diff --git a/internal/authorization/discovery.go b/internal/authorization/discovery.go new file mode 100644 index 00000000..cba732de --- /dev/null +++ b/internal/authorization/discovery.go @@ -0,0 +1,103 @@ +// Copyright Mia srl +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package authorization + +import ( + "context" + "errors" + "fmt" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/google/uuid" + "golang.org/x/oauth2" + + "github.com/mia-platform/miactl/internal/client" +) + +const ( + protectedResourceMetadataPath = "/.well-known/oauth-protected-resource/api" +) + +// protectedResourceMetadata holds the relevant fields from RFC 9728. +type protectedResourceMetadata struct { + AuthorizationServers []string `json:"authorization_servers"` //nolint:tagliatelle +} + +// discoverOAuthConfig fetches /.well-known/oauth-protected-resource from the API +// base URL (RFC 9728), extracts the first authorization server, and performs OIDC +// discovery on it (RFC 8414 / OpenID Connect Discovery). Returns a ready +// *oauth2.Config, or an error if the resource metadata is unavailable or OIDC +// discovery fails. +func discoverOAuthConfig(ctx context.Context, apiClient client.Interface) (*oauth2.Config, error) { + response, err := apiClient.Get().APIPath(protectedResourceMetadataPath).Do(ctx) + if err != nil { + return nil, fmt.Errorf("fetching resource metadata: %w", err) + } + if err := response.Error(); err != nil { + return nil, fmt.Errorf("resource metadata not available: %w", err) + } + + var metadata protectedResourceMetadata + if err := response.ParseResponse(&metadata); err != nil { + return nil, fmt.Errorf("parsing resource metadata: %w", err) + } + + if len(metadata.AuthorizationServers) == 0 { + return nil, errors.New("no authorization_servers listed in resource metadata") + } + + oidcCtx := oidc.ClientContext(ctx, apiClient.HTTPClient()) + provider, err := oidc.NewProvider(oidcCtx, metadata.AuthorizationServers[0]) + if err != nil { + return nil, fmt.Errorf("OIDC discovery for %q: %w", metadata.AuthorizationServers[0], err) + } + + return &oauth2.Config{ + ClientID: appID, + Endpoint: provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID}, + }, nil +} + +// getTokenWithOIDC runs the OAuth2 authorization code flow with PKCE using the +// endpoints from the provided oauth2.Config. It starts a local callback server, +// opens the browser via readyFn, waits for the authorization code, and exchanges +// it for a token. +func getTokenWithOIDC(ctx context.Context, oauthCfg *oauth2.Config, apiClient client.Interface, readyFn LocalServerReadyHandler) (*oauth2.Token, error) { + listener, err := newListener([]string{"127.0.0.1:53535", "127.0.0.1:13535"}) + if err != nil { + return nil, err + } + + cfg := *oauthCfg + cfg.RedirectURL = "http://" + listener.Addr().String() + callbackEndpointString + + state := uuid.New().String() + verifier := oauth2.GenerateVerifier() + authURL := cfg.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)) + + authResp, err := startLocalServerForToken(ctx, authURL, listener, readyFn) + if err != nil { + return nil, err + } + + if authResp.State != state { + return nil, errors.New("state mismatch in OAuth2 callback") + } + + exchangeCtx := context.WithValue(ctx, oauth2.HTTPClient, apiClient.HTTPClient()) + return cfg.Exchange(exchangeCtx, authResp.Code, oauth2.VerifierOption(verifier)) +} diff --git a/internal/authorization/discovery_test.go b/internal/authorization/discovery_test.go new file mode 100644 index 00000000..4d88522e --- /dev/null +++ b/internal/authorization/discovery_test.go @@ -0,0 +1,131 @@ +// Copyright Mia srl +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package authorization + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mia-platform/miactl/internal/client" +) + +func TestDiscoverOAuthConfig(t *testing.T) { + t.Run("resource metadata endpoint not found", func(t *testing.T) { + server := testServer(t, func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + defer server.Close() + + apiClient := apiClientForServer(t, server) + cfg, err := discoverOAuthConfig(t.Context(), apiClient) + assert.Error(t, err) + assert.Nil(t, cfg) + }) + + t.Run("resource metadata returns invalid JSON", func(t *testing.T) { + server := testServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("not-json")) + }) + defer server.Close() + + apiClient := apiClientForServer(t, server) + cfg, err := discoverOAuthConfig(t.Context(), apiClient) + assert.Error(t, err) + assert.Nil(t, cfg) + }) + + t.Run("resource metadata has no authorization_servers", func(t *testing.T) { + server := testServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(protectedResourceMetadata{}) + }) + defer server.Close() + + apiClient := apiClientForServer(t, server) + cfg, err := discoverOAuthConfig(t.Context(), apiClient) + assert.Error(t, err) + assert.Nil(t, cfg) + }) + + t.Run("OIDC discovery succeeds", func(t *testing.T) { + // The server acts as both the protected resource and the authorization + // server. Its own URL is used as the issuer so the OIDC discovery + // document can reference it self-consistently. + var serverURL string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case protectedResourceMetadataPath: + json.NewEncoder(w).Encode(protectedResourceMetadata{ + AuthorizationServers: []string{serverURL}, + }) + case "/.well-known/openid-configuration": + // Minimal OIDC discovery document; issuer must match exactly. + json.NewEncoder(w).Encode(map[string]any{ + "issuer": serverURL, + "authorization_endpoint": serverURL + "/authorize", + "token_endpoint": serverURL + "/token", + }) + default: + http.NotFound(w, r) + } + })) + serverURL = server.URL + defer server.Close() + + apiClient := apiClientForServer(t, server) + cfg, err := discoverOAuthConfig(t.Context(), apiClient) + require.NoError(t, err) + require.NotNil(t, cfg) + assert.Equal(t, appID, cfg.ClientID) + assert.Equal(t, serverURL+"/authorize", cfg.Endpoint.AuthURL) + assert.Equal(t, serverURL+"/token", cfg.Endpoint.TokenURL) + }) + + t.Run("OIDC discovery fails for returned auth server", func(t *testing.T) { + server := testServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Return a non-reachable authorization server URL. + json.NewEncoder(w).Encode(protectedResourceMetadata{ + AuthorizationServers: []string{"http://127.0.0.1:0"}, + }) + }) + defer server.Close() + + apiClient := apiClientForServer(t, server) + cfg, err := discoverOAuthConfig(t.Context(), apiClient) + assert.Error(t, err) + assert.Nil(t, cfg) + }) +} + +// apiClientForServer returns an API client configured to talk to server. +func apiClientForServer(t *testing.T, server *httptest.Server) client.Interface { + t.Helper() + restConfig := &client.Config{ + Host: server.URL, + Transport: http.DefaultTransport, + } + apiClient, err := client.APIClientForConfig(restConfig) + require.NoError(t, err) + return apiClient +} diff --git a/internal/authorization/user_authenticator.go b/internal/authorization/user_authenticator.go index a3c8423e..76a9bf94 100644 --- a/internal/authorization/user_authenticator.go +++ b/internal/authorization/user_authenticator.go @@ -17,7 +17,9 @@ package authorization import ( "context" + "fmt" "net/http" + "os" "sync" "golang.org/x/oauth2" @@ -69,6 +71,17 @@ func (ua *userAuthenticator) refreshAuthWithToken(refreshToken string) (*oauth2. } func (ua *userAuthenticator) logUser() (*oauth2.Token, error) { + ctx := context.Background() + + // OIDC discovery via RFC 9728 resource metadata + if jwt, err := ua.logUserWithDiscovery(ctx); err == nil { + ua.userAuth.WriteJWTToken(jwt) + fmt.Fprintln(os.Stderr, "Login successful.") + fmt.Fprintln(os.Stderr, "") + return jwt, nil + } + + // In case of failure, fallback to the legacy browser login flow browserLoginConfig := &Config{ AppID: appID, LocalServerBindAddress: []string{"127.0.0.1:53535", "127.0.0.1:13535"}, @@ -76,14 +89,25 @@ func (ua *userAuthenticator) logUser() (*oauth2.Token, error) { ServerReadyHandler: ua.serverReadyHandler, } - jwt, err := browserLoginConfig.GetToken(context.Background()) + jwt, err := browserLoginConfig.GetToken(ctx) if jwt != nil { ua.userAuth.WriteJWTToken(jwt) + fmt.Fprintln(os.Stderr, "Login successful.") + fmt.Fprintln(os.Stderr, "") } return jwt, err } +func (ua *userAuthenticator) logUserWithDiscovery(ctx context.Context) (*oauth2.Token, error) { + oauthCfg, err := discoverOAuthConfig(ctx, ua.client) + if err != nil { + return nil, err + } + + return getTokenWithOIDC(ctx, oauthCfg, ua.client, ua.serverReadyHandler) +} + func (ua *userAuthenticator) refreshToken(token string) (*oauth2.Token, error) { browserLoginConfig := &Config{ Client: ua.client, diff --git a/internal/authorization/util_test.go b/internal/authorization/util_test.go index b6293dc6..44ef759f 100644 --- a/internal/authorization/util_test.go +++ b/internal/authorization/util_test.go @@ -59,6 +59,9 @@ func testServerForCompleteFlow(t *testing.T) *httptest.Server { accessToken := "new" return testServer(t, func(w http.ResponseWriter, r *http.Request) { switch { + case r.Method == http.MethodGet && r.RequestURI == protectedResourceMetadataPath: + // NOTE: For now, OIDC is always unavailable in tests + http.NotFound(w, r) case r.Method == http.MethodGet && r.RequestURI == fmt.Sprintf(providerEndpointStringTemplate, appID): testProvider := resources.AuthProvider{ ID: "foo",