Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

identity/tokens: adds plugin issuer with openid-configuration and keys #24898

Merged
merged 4 commits into from
Jan 17, 2024
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
3 changes: 3 additions & 0 deletions changelog/24898.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:improvement
identity/tokens: adds plugin issuer with openid-configuration and keys APIs
```
1 change: 1 addition & 0 deletions vault/identity_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func NewIdentityStore(ctx context.Context, core *Core, config *logical.BackendCo
PathsSpecial: &logical.Paths{
Unauthenticated: []string{
"oidc/.well-known/*",
"oidc/+/.well-known/*",
"oidc/provider/+/.well-known/*",
"oidc/provider/+/token",
},
Expand Down
93 changes: 84 additions & 9 deletions vault/identity_store_oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,36 @@ import (
)

type oidcConfig struct {
// Issuer is the scheme://host:port component of the issuer set as
// configuration in the Vault API. It is the URL base.
Issuer string `json:"issuer"`

// effectiveIssuer is a calculated field and will be either Issuer (if
// that's set) or the Vault instance's api_addr.
// that's set) or the Vault instance's api_addr, followed by the path
// /v1/<namespace_path>/identity/oidc.
effectiveIssuer string
}

// fullIssuer returns the full issuer for the config, suitable for OpenID metadata and
// token claims. It takes an optional child, which must be of the value "" or "plugins".
// The child will be appended as the last path segment on the returned issuer URL.
func (c *oidcConfig) fullIssuer(child string) (string, error) {
if !validChildIssuer(child) {
return "", fmt.Errorf("invalid child issuer %q", child)
}

issuer, err := url.JoinPath(c.effectiveIssuer, child)
if err != nil {
return "", fmt.Errorf("failed to join issuer: %w", err)
}

return issuer, nil
}

func validChildIssuer(child string) bool {
return child == baseIdentityTokenIssuer || child == pluginIdentityTokenIssuer
}

type expireableKey struct {
KeyID string `json:"key_id"`
ExpireAt time.Time `json:"expire_at"`
Expand Down Expand Up @@ -113,6 +136,10 @@ const (
namedKeyConfigPath = oidcTokensPrefix + "named_keys/"
publicKeysConfigPath = oidcTokensPrefix + "public_keys/"
roleConfigPath = oidcTokensPrefix + "roles/"

// Identity tokens have a base issuer and plugin issuer
baseIdentityTokenIssuer = ""
pluginIdentityTokenIssuer = "plugins"
)

var (
Expand All @@ -135,6 +162,13 @@ var (
// pseudo-namespace for cache items that don't belong to any real namespace.
var noNamespace = &namespace.Namespace{ID: "__NO_NAMESPACE"}

// optionalChildIssuerRegex is a regex for optionally accepting a field in an
// API request as a single path segment. Adapted from framework.OptionalParamRegex
// to not include additional forward slashes.
func optionalChildIssuerRegex(name string) string {
return fmt.Sprintf(`(/(?P<%s>[^/]+))?`, name)
}

func oidcPaths(i *IdentityStore) []*framework.Path {
return []*framework.Path{
{
Expand Down Expand Up @@ -252,23 +286,35 @@ func oidcPaths(i *IdentityStore) []*framework.Path {
HelpDescription: "List all named OIDC keys",
},
{
Pattern: "oidc/\\.well-known/openid-configuration/?$",
Pattern: "oidc" + optionalChildIssuerRegex("child") + "/\\.well-known/openid-configuration/?$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "oidc",
OperationSuffix: "open-id-configuration",
},
Fields: map[string]*framework.FieldSchema{
"child": {
Type: framework.TypeString,
Description: "Name of the child issuer",
},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.ReadOperation: i.pathOIDCDiscovery,
},
HelpSynopsis: "Query OIDC configurations",
HelpDescription: "Query this path to retrieve the configured OIDC Issuer and Keys endpoints, response types, subject types, and signing algorithms used by the OIDC backend.",
},
{
Pattern: "oidc/\\.well-known/keys/?$",
Pattern: "oidc" + optionalChildIssuerRegex("child") + "/\\.well-known/keys/?$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "oidc",
OperationSuffix: "public-keys",
},
Fields: map[string]*framework.FieldSchema{
"child": {
Type: framework.TypeString,
Description: "Name of the child issuer",
},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.ReadOperation: i.pathOIDCReadPublicKeys,
},
Expand Down Expand Up @@ -920,9 +966,14 @@ func (i *IdentityStore) pathOIDCGenerateToken(ctx context.Context, req *logical.
"than the verification_ttl of the key it references, setting token ttl to %d", expiry))
}

issuer, err := config.fullIssuer(baseIdentityTokenIssuer)
if err != nil {
return logical.ErrorResponse(err.Error()), nil
}

now := time.Now()
idToken := idToken{
Issuer: config.effectiveIssuer,
Issuer: issuer,
Namespace: ns.ID,
Subject: req.EntityID,
Audience: role.ClientID,
Expand Down Expand Up @@ -1339,7 +1390,13 @@ func (i *IdentityStore) pathOIDCDiscovery(ctx context.Context, req *logical.Requ
return nil, err
}

v, ok, err := i.oidcCache.Get(ns, "discoveryResponse")
var child string
if childRaw, ok := d.GetOk("child"); ok {
child = childRaw.(string)
}

cacheKey := fmt.Sprintf("%s/discoveryResponse", child)
v, ok, err := i.oidcCache.Get(ns, cacheKey)
if err != nil {
return nil, err
}
Expand All @@ -1352,9 +1409,14 @@ func (i *IdentityStore) pathOIDCDiscovery(ctx context.Context, req *logical.Requ
return nil, err
}

issuer, err := c.fullIssuer(child)
if err != nil {
return logical.ErrorResponse(err.Error()), nil
}

disc := discovery{
Issuer: c.effectiveIssuer,
Keys: c.effectiveIssuer + "/.well-known/keys",
Issuer: issuer,
Keys: issuer + "/.well-known/keys",
ResponseTypes: []string{"id_token"},
Subjects: []string{"public"},
IDTokenAlgs: supportedAlgs,
Expand All @@ -1365,7 +1427,7 @@ func (i *IdentityStore) pathOIDCDiscovery(ctx context.Context, req *logical.Requ
return nil, err
}

if err := i.oidcCache.SetDefault(ns, "discoveryResponse", data); err != nil {
if err := i.oidcCache.SetDefault(ns, cacheKey, data); err != nil {
return nil, err
}
}
Expand Down Expand Up @@ -1426,6 +1488,14 @@ func (i *IdentityStore) pathOIDCReadPublicKeys(ctx context.Context, req *logical
return nil, err
}

var child string
if childRaw, ok := d.GetOk("child"); ok {
child = childRaw.(string)
}
if !validChildIssuer(child) {
return logical.ErrorResponse("invalid child issuer %q", child), nil
}

v, ok, err := i.oidcCache.Get(ns, "jwksResponse")
if err != nil {
return nil, err
Expand Down Expand Up @@ -1539,8 +1609,13 @@ func (i *IdentityStore) pathOIDCIntrospect(ctx context.Context, req *logical.Req
return nil, err
}

issuer, err := c.fullIssuer(baseIdentityTokenIssuer)
if err != nil {
return logical.ErrorResponse(err.Error()), nil
}

expected := jwt.Expected{
Issuer: c.effectiveIssuer,
Issuer: issuer,
Time: time.Now(),
}

Expand Down
142 changes: 142 additions & 0 deletions vault/identity_store_oidc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ package vault
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"testing"
Expand All @@ -20,6 +22,7 @@ import (
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
gocache "github.com/patrickmn/go-cache"
"github.com/stretchr/testify/require"
)

// TestOIDC_Path_OIDC_RoleNoKeyParameter tests that a role cannot be created
Expand Down Expand Up @@ -1728,3 +1731,142 @@ func expectStrings(t *testing.T, actualStrings []string, expectedStrings map[str
}
}
}

// Test_oidcConfig_fullIssuer tests that the full issuer matches expectations
// given different issuer bases and children.
func Test_oidcConfig_fullIssuer(t *testing.T) {
c, _, _ := TestCoreUnsealed(t)
ctx := namespace.RootContext(nil)
storage := &logical.InmemStorage{}

tests := []struct {
name string
issuer string
child string
want string
wantErr bool
}{
{
name: "issuer with valid empty child",
issuer: "https://vault.dev",
child: baseIdentityTokenIssuer,
want: fmt.Sprintf("https://vault.dev/v1/%s", issuerPath),
},
{
name: "issuer with valid plugin child",
issuer: "http://127.0.0.1:8200",
child: pluginIdentityTokenIssuer,
want: fmt.Sprintf("http://127.0.0.1:8200/v1/%s/%s", issuerPath, pluginIdentityTokenIssuer),
},
{
name: "issuer with invalid child",
issuer: "http://127.0.0.1:8200",
child: "invalid",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, err := c.identityStore.HandleRequest(ctx, &logical.Request{
Path: "oidc/config",
Operation: logical.UpdateOperation,
Storage: storage,
Data: map[string]interface{}{
"issuer": tt.issuer,
},
})
expectSuccess(t, resp, err)

config, err := c.identityStore.getOIDCConfig(ctx, storage)
require.NoError(t, err)

got, err := config.fullIssuer(tt.child)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equalf(t, tt.want, got, "fullIssuer(%v)", tt.child)
})
}
}

// Test_validChildIssuer tests that only valid child issuers are accepted.
func Test_validChildIssuer(t *testing.T) {
tests := []struct {
name string
child string
want bool
}{
{
name: "valid child issuer",
child: baseIdentityTokenIssuer,
want: true,
},
{
name: "valid child issuer",
child: pluginIdentityTokenIssuer,
want: true,
},
{
name: "invalid child issuer",
child: "test",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equalf(t, tt.want, validChildIssuer(tt.child), "validChildIssuer(%v)", tt.child)
})
}
}

// Test_optionalChildIssuerRegex tests that the regex returned from
// optionalChildIssuerRegex produces the expected captures given different
// input paths.
func Test_optionalChildIssuerRegex(t *testing.T) {
tests := []struct {
name string
pattern string
path string
captures map[string]string
}{
{
name: "valid match with capture",
pattern: "oidc" + optionalChildIssuerRegex("child") + "/.well-known/keys",
path: "oidc/plugins/.well-known/keys",
captures: map[string]string{"child": "plugins"},
},
{
name: "valid match with capture name, segment, and path change",
pattern: "oidc" + optionalChildIssuerRegex("name") + "/.well-known/openid-configuration",
path: "oidc/test/.well-known/openid-configuration",
captures: map[string]string{"name": "test"},
},
{
name: "valid match with empty capture",
pattern: "oidc" + optionalChildIssuerRegex("child") + "/.well-known/keys",
path: "oidc/.well-known/keys",
captures: map[string]string{"child": ""},
},
{
name: "invalid match with multiple path segments",
pattern: "oidc" + optionalChildIssuerRegex("child") + "/.well-known/keys",
path: "oidc/plugins/invalid/.well-known/keys",
captures: map[string]string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
re := regexp.MustCompile(tt.pattern)
matches := re.FindStringSubmatch(tt.path)
actualCaptures := make(map[string]string)
for i, name := range re.SubexpNames() {
if name != "" && i < len(matches) {
actualCaptures[name] = matches[i]
}
}
require.Equal(t, tt.captures, actualCaptures)
})
}
}
Loading