Skip to content
Open
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
65 changes: 65 additions & 0 deletions internal/acceptance/openstack/identity/v3/oauth2mtls_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//go:build acceptance || identity || oauth2mtls

package v3

import (
"context"
"crypto/tls"
"crypto/x509"
"net/http"
"os"
"testing"

"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack"
"github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers"
"github.com/gophercloud/gophercloud/v2/openstack/identity/v3/oauth2mtls"
th "github.com/gophercloud/gophercloud/v2/testhelper"
)

func TestOAuth2MTLSAuthentication(t *testing.T) {
required := []string{"OS_AUTH_URL", "OS_OAUTH2_ENDPOINT", "OS_OAUTH2_CLIENT_ID", "OS_CERT", "OS_KEY"}
for _, name := range required {
if os.Getenv(name) == "" {
t.Skipf("%s must be set", name)
}
}

cert, err := tls.LoadX509KeyPair(os.Getenv("OS_CERT"), os.Getenv("OS_KEY"))
th.AssertNoErr(t, err)

tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
}
if caPath := os.Getenv("OS_CACERT"); caPath != "" {
ca, err := os.ReadFile(caPath)
th.AssertNoErr(t, err)
tlsConfig.RootCAs = x509.NewCertPool()
if !tlsConfig.RootCAs.AppendCertsFromPEM(ca) {
t.Fatal("failed to parse OS_CACERT")
}
}

provider, err := openstack.NewClient(os.Getenv("OS_AUTH_URL"))
th.AssertNoErr(t, err)
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = tlsConfig
provider.HTTPClient.Transport = transport

opts := &oauth2mtls.AuthOptions{
OAuth2Endpoint: os.Getenv("OS_OAUTH2_ENDPOINT"),
ClientID: os.Getenv("OS_OAUTH2_CLIENT_ID"),
AllowReauth: true,
}
ctx := context.Background()
th.AssertNoErr(t, openstack.AuthenticateV3(ctx, provider, opts, gophercloud.EndpointOpts{}))
th.AssertNoErr(t, provider.Reauthenticate(ctx, ""))

compute, err := openstack.NewComputeV2(ctx, provider, gophercloud.EndpointOpts{
Region: os.Getenv("OS_REGION_NAME"),
})
th.AssertNoErr(t, err)
_, err = servers.List(compute, servers.ListOpts{Limit: 1}).AllPages(ctx)
th.AssertNoErr(t, err)
}
13 changes: 13 additions & 0 deletions openstack/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
tokens2 "github.com/gophercloud/gophercloud/v2/openstack/identity/v2/tokens"
"github.com/gophercloud/gophercloud/v2/openstack/identity/v3/ec2tokens"
"github.com/gophercloud/gophercloud/v2/openstack/identity/v3/oauth1"
"github.com/gophercloud/gophercloud/v2/openstack/identity/v3/oauth2mtls"
tokens3 "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/tokens"
"github.com/gophercloud/gophercloud/v2/openstack/utils"
)
Expand Down Expand Up @@ -222,11 +223,17 @@ func v3auth(ctx context.Context, client *gophercloud.ProviderClient, endpoint st
}
} else {
var result tokens3.CreateResult
var authenticatedHeadersFunc func(string) map[string]string
switch opts.(type) {
case *ec2tokens.AuthOptions:
result = ec2tokens.Create(ctx, v3Client, opts)
case *oauth1.AuthOptions:
result = oauth1.Create(ctx, v3Client, opts)
case *oauth2mtls.AuthOptions:
result = oauth2mtls.Create(ctx, v3Client, opts)
authenticatedHeadersFunc = func(token string) map[string]string {
return map[string]string{"Authorization": "Bearer " + token}
}
default:
result = tokens3.Create(ctx, v3Client, opts)
}
Expand All @@ -240,6 +247,8 @@ func v3auth(ctx context.Context, client *gophercloud.ProviderClient, endpoint st
if err != nil {
return err
}

client.AuthenticatedHeadersFunc = authenticatedHeadersFunc
}

if opts.CanReauth() {
Expand Down Expand Up @@ -271,6 +280,10 @@ func v3auth(ctx context.Context, client *gophercloud.ProviderClient, endpoint st
o := *ot
o.AllowReauth = false
tao = &o
case *oauth2mtls.AuthOptions:
o := *ot
o.AllowReauth = false
tao = &o
default:
tao = opts
}
Expand Down
37 changes: 37 additions & 0 deletions openstack/identity/v3/oauth2mtls/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
Package oauth2mtls authenticates to Keystone using OAuth 2.0 mutual TLS client
authentication (RFC 8705).

The client certificate is mapped to a Keystone user, and the returned access
token is bound to that certificate. The same certificate must be presented to
protected service endpoints. The user must have a default project.

Example to authenticate a client using OAuth 2.0 mutual TLS

client, err := openstack.NewClient("https://keystone.example.com:5000/v3")
if err != nil {
panic(err)
}

tlsConfig := &tls.Config{
Certificates: []tls.Certificate{clientCert},
RootCAs: caCertPool,
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = tlsConfig
client.HTTPClient.Transport = transport

authOptions := &oauth2mtls.AuthOptions{
OAuth2Endpoint: "https://keystone.example.com:5000/v3/OS-OAUTH2/token",
ClientID: "6c3145f4-313d-4910-b3a8-9dfc72da9e75",
AllowReauth: true,
}

err = openstack.AuthenticateV3(context.Background(), client, authOptions, gophercloud.EndpointOpts{})
if err != nil {
panic(err)
}

See https://docs.openstack.org/keystone/latest/admin/oauth2-mtls-usage-guide.html.
*/
package oauth2mtls
124 changes: 124 additions & 0 deletions openstack/identity/v3/oauth2mtls/requests.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package oauth2mtls

import (
"context"
"fmt"
"net/http"
"net/url"
"strings"

"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack/identity/v3/tokens"
)

// AuthOptions contains OAuth2 mTLS client-credentials options.
type AuthOptions struct {
// OAuth2Endpoint specifies Keystone's OS-OAUTH2 token endpoint. When empty,
// it is derived from the identity ServiceClient endpoint.
OAuth2Endpoint string

// ClientID is the Keystone user ID associated with the client certificate.
ClientID string `required:"true"`

// AllowReauth enables automatic reauthentication.
AllowReauth bool
}

type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
}

// ToTokenV3ScopeMap implements tokens.AuthOptionsBuilder.
func (opts *AuthOptions) ToTokenV3ScopeMap() (map[string]any, error) {
return nil, nil
}

// ToTokenV3HeadersMap implements tokens.AuthOptionsBuilder.
func (opts *AuthOptions) ToTokenV3HeadersMap(map[string]any) (map[string]string, error) {
return nil, nil
}

// ToTokenV3CreateMap validates the options without building a request body.
func (opts *AuthOptions) ToTokenV3CreateMap(map[string]any) (map[string]any, error) {
_, err := gophercloud.BuildRequestBody(opts, "")
return nil, err
}

// CanReauth reports whether automatic reauthentication is enabled.
func (opts *AuthOptions) CanReauth() bool {
return opts.AllowReauth
}

// Create authenticates with OAuth2 mTLS client credentials.
func Create(ctx context.Context, c *gophercloud.ServiceClient, opts tokens.AuthOptionsBuilder) (r tokens.CreateResult) {
mtlsOpts, ok := opts.(*AuthOptions)
if !ok || mtlsOpts == nil {
r.Err = fmt.Errorf("oauth2mtls: expected non-nil *oauth2mtls.AuthOptions, got %T", opts)
return
}

if _, err := mtlsOpts.ToTokenV3CreateMap(nil); err != nil {
r.Err = err
return
}

if c == nil || c.ProviderClient == nil {
r.Err = fmt.Errorf("oauth2mtls: ServiceClient or ProviderClient is nil")
return
}

oauth2Endpoint := mtlsOpts.OAuth2Endpoint
if oauth2Endpoint == "" {
oauth2Endpoint = tokenURL(c)
}

formData := url.Values{
"grant_type": {"client_credentials"},
"client_id": {mtlsOpts.ClientID},
}

var tokenResp tokenResponse
authClient := unauthenticatedClient(c)
resp, err := authClient.Post(ctx, oauth2Endpoint, strings.NewReader(formData.Encode()), &tokenResp, &gophercloud.RequestOpts{
MoreHeaders: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
OkCodes: []int{http.StatusOK},
})
_, _, r.Err = gophercloud.ParseResponse(resp, err)
if r.Err != nil {
return
}

if tokenResp.AccessToken == "" {
r.Err = fmt.Errorf("oauth2mtls: token response missing access_token field")
return
}
if !strings.EqualFold(tokenResp.TokenType, "Bearer") {
r.Err = fmt.Errorf("oauth2mtls: token response has unsupported token_type %q", tokenResp.TokenType)
return
}

// The OS-OAUTH2 response has no catalog, so retrieve the full token.
resp, err = authClient.Get(ctx, validateURL(authClient), &r.Body, &gophercloud.RequestOpts{
MoreHeaders: map[string]string{
"X-Auth-Token": tokenResp.AccessToken,
"X-Subject-Token": tokenResp.AccessToken,
},
})
_, r.Header, r.Err = gophercloud.ParseResponse(resp, err)
if r.Err != nil {
return
}
r.Header.Set("X-Subject-Token", tokenResp.AccessToken)

return
}

func unauthenticatedClient(c *gophercloud.ServiceClient) *gophercloud.ServiceClient {
client := *c
provider := *c.ProviderClient
provider.Throwaway = true
provider.ReauthFunc = nil
client.ProviderClient = &provider
return &client
}
1 change: 1 addition & 0 deletions openstack/identity/v3/oauth2mtls/testing/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package testing
59 changes: 59 additions & 0 deletions openstack/identity/v3/oauth2mtls/testing/fixtures_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package testing

import (
"fmt"
"net/http"
"testing"

th "github.com/gophercloud/gophercloud/v2/testhelper"
)

const (
tokenID = "gAAAAABl-mTLS-token-abc123"
testClientID = "6c3145f4-313d-4910-b3a8-9dfc72da9e75"
tokenResponse = `{
"access_token": "gAAAAABl-mTLS-token-abc123",
"token_type": "Bearer",
"expires_in": 3600
}`
validateTokenResponse = `{
"token": {
"catalog": [{
"type": "compute",
"name": "nova",
"endpoints": [{
"interface": "public",
"region": "iad1",
"url": "http://127.0.0.1:8774/v2.1"
}]
}],
"expires_at": "2030-06-15T18:00:00.000000Z"
}
}`
)

func handleTokenSuccessfully(t *testing.T, fakeServer th.FakeServer) {
fakeServer.Mux.HandleFunc("/OS-OAUTH2/token", func(w http.ResponseWriter, r *http.Request) {
th.TestMethod(t, r, http.MethodPost)
th.TestHeader(t, r, "Content-Type", "application/x-www-form-urlencoded")
th.TestHeader(t, r, "Accept", "application/json")
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
th.AssertEquals(t, "client_credentials", r.PostForm.Get("grant_type"))
th.AssertEquals(t, testClientID, r.PostForm.Get("client_id"))
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, tokenResponse)
})
}

func handleValidateTokenSuccessfully(t *testing.T, fakeServer th.FakeServer) {
fakeServer.Mux.HandleFunc("/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
th.TestMethod(t, r, http.MethodGet)
th.TestHeader(t, r, "X-Auth-Token", tokenID)
th.TestHeader(t, r, "X-Subject-Token", tokenID)
w.Header().Set("X-Subject-Token", tokenID)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, validateTokenResponse)
})
}
Loading
Loading