Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 7 additions & 7 deletions docs/20_setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
6 changes: 4 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/mia-platform/miactl

go 1.25
go 1.25.0

toolchain go1.25.6

Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
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=
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=
Expand Down Expand Up @@ -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=
Expand Down
2 changes: 2 additions & 0 deletions internal/authorization/authenticator.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package authorization
import (
"fmt"
"net/http"
"os"

"github.com/mia-platform/miactl/internal/client"
)
Expand Down Expand Up @@ -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)
}
Expand Down
103 changes: 103 additions & 0 deletions internal/authorization/discovery.go
Original file line number Diff line number Diff line change
@@ -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))
}
131 changes: 131 additions & 0 deletions internal/authorization/discovery_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading