382 lines
11 KiB
Go
382 lines
11 KiB
Go
package identity
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hashicorp/cap/oidc"
|
|
"github.com/hashicorp/vault/api"
|
|
"github.com/hashicorp/vault/builtin/credential/userpass"
|
|
vaulthttp "github.com/hashicorp/vault/http"
|
|
"github.com/hashicorp/vault/sdk/logical"
|
|
"github.com/hashicorp/vault/vault"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const (
|
|
testPassword = "testpassword"
|
|
testRedirectURI = "https://127.0.0.1:8251/callback"
|
|
testGroupScopeTemplate = `
|
|
{
|
|
"groups": {{identity.entity.groups.names}}
|
|
}
|
|
`
|
|
testUserScopeTemplate = `
|
|
{
|
|
"username": {{identity.entity.aliases.%s.name}},
|
|
"contact": {
|
|
"email": {{identity.entity.metadata.email}},
|
|
"phone_number": {{identity.entity.metadata.phone_number}}
|
|
}
|
|
}
|
|
`
|
|
)
|
|
|
|
// TestOIDC_Auth_Code_Flow_CAP_Client tests the authorization code flow
|
|
// using a Vault OIDC provider. The test uses the CAP OIDC client to verify
|
|
// that the Vault OIDC provider's responses pass the various client-side
|
|
// validation requirements of the OIDC spec.
|
|
func TestOIDC_Auth_Code_Flow_CAP_Client(t *testing.T) {
|
|
cluster := setupOIDCTestCluster(t, 2)
|
|
defer cluster.Cleanup()
|
|
active := cluster.Cores[0].Client
|
|
standby := cluster.Cores[1].Client
|
|
|
|
// Create an entity with some metadata
|
|
resp, err := active.Logical().Write("identity/entity", map[string]interface{}{
|
|
"name": "test-entity",
|
|
"metadata": map[string]string{
|
|
"email": "test@hashicorp.com",
|
|
"phone_number": "123-456-7890",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
entityID := resp.Data["id"].(string)
|
|
|
|
// Create a group
|
|
resp, err = active.Logical().Write("identity/group", map[string]interface{}{
|
|
"name": "engineering",
|
|
"member_entity_ids": []string{entityID},
|
|
})
|
|
require.NoError(t, err)
|
|
groupID := resp.Data["id"].(string)
|
|
|
|
// Enable userpass auth and create a user
|
|
err = active.Sys().EnableAuthWithOptions("userpass", &api.EnableAuthOptions{
|
|
Type: "userpass",
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = active.Logical().Write("auth/userpass/users/end-user", map[string]interface{}{
|
|
"password": testPassword,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Get the userpass mount accessor
|
|
mounts, err := active.Sys().ListAuth()
|
|
require.NoError(t, err)
|
|
var mountAccessor string
|
|
for k, v := range mounts {
|
|
if k == "userpass/" {
|
|
mountAccessor = v.Accessor
|
|
break
|
|
}
|
|
}
|
|
require.NotEmpty(t, mountAccessor)
|
|
|
|
// Create an entity alias
|
|
_, err = active.Logical().Write("identity/entity-alias", map[string]interface{}{
|
|
"name": "end-user",
|
|
"canonical_id": entityID,
|
|
"mount_accessor": mountAccessor,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create some custom scopes
|
|
_, err = active.Logical().Write("identity/oidc/scope/groups", map[string]interface{}{
|
|
"template": testGroupScopeTemplate,
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = active.Logical().Write("identity/oidc/scope/user", map[string]interface{}{
|
|
"template": fmt.Sprintf(testUserScopeTemplate, mountAccessor),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create a key
|
|
_, err = active.Logical().Write("identity/oidc/key/test-key", map[string]interface{}{
|
|
"allowed_client_ids": []string{"*"},
|
|
"algorithm": "RS256",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create an assignment
|
|
_, err = active.Logical().Write("identity/oidc/assignment/test-assignment", map[string]interface{}{
|
|
"entity_ids": []string{entityID},
|
|
"group_ids": []string{groupID},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create a client
|
|
_, err = active.Logical().Write("identity/oidc/client/test-client", map[string]interface{}{
|
|
"key": "test-key",
|
|
"redirect_uris": []string{testRedirectURI},
|
|
"assignments": []string{"test-assignment"},
|
|
"id_token_ttl": "1h",
|
|
"access_token_ttl": "30m",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Read the client ID and secret in order to configure the OIDC client
|
|
resp, err = active.Logical().Read("identity/oidc/client/test-client")
|
|
require.NoError(t, err)
|
|
clientID := resp.Data["client_id"].(string)
|
|
clientSecret := resp.Data["client_secret"].(string)
|
|
|
|
// Create the OIDC provider
|
|
_, err = active.Logical().Write("identity/oidc/provider/test-provider", map[string]interface{}{
|
|
"allowed_client_ids": []string{clientID},
|
|
"scopes": []string{"user", "groups"},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// We aren't going to open up a browser to facilitate the login and redirect
|
|
// from this test, so we'll log in via userpass and set the client's token as
|
|
// the token that results from the authentication.
|
|
resp, err = active.Logical().Write("auth/userpass/login/end-user", map[string]interface{}{
|
|
"password": testPassword,
|
|
})
|
|
require.NoError(t, err)
|
|
clientToken := resp.Auth.ClientToken
|
|
|
|
// Look up the token to get its creation time. This will be used for test
|
|
// cases that make assertions on the max_age parameter and auth_time claim.
|
|
resp, err = active.Logical().Write("auth/token/lookup", map[string]interface{}{
|
|
"token": clientToken,
|
|
})
|
|
require.NoError(t, err)
|
|
expectedAuthTime, err := strconv.Atoi(string(resp.Data["creation_time"].(json.Number)))
|
|
require.NoError(t, err)
|
|
|
|
// Read the issuer from the OIDC provider's discovery document
|
|
var discovery struct {
|
|
Issuer string `json:"issuer"`
|
|
}
|
|
decodeRawRequest(t, active, http.MethodGet,
|
|
"/v1/identity/oidc/provider/test-provider/.well-known/openid-configuration",
|
|
nil, &discovery)
|
|
|
|
// Create the client-side OIDC provider config
|
|
pc, err := oidc.NewConfig(discovery.Issuer, clientID,
|
|
oidc.ClientSecret(clientSecret), []oidc.Alg{oidc.RS256},
|
|
[]string{testRedirectURI}, oidc.WithProviderCA(string(cluster.CACertPEM)))
|
|
require.NoError(t, err)
|
|
|
|
// Create the client-side OIDC provider
|
|
p, err := oidc.NewProvider(pc)
|
|
require.NoError(t, err)
|
|
defer p.Done()
|
|
|
|
type args struct {
|
|
useStandby bool
|
|
options []oidc.Option
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
args args
|
|
expected string
|
|
}{
|
|
{
|
|
name: "active: authorization code flow",
|
|
args: args{
|
|
options: []oidc.Option{
|
|
oidc.WithScopes("openid user"),
|
|
},
|
|
},
|
|
expected: fmt.Sprintf(`{
|
|
"iss": "%s",
|
|
"aud": "%s",
|
|
"sub": "%s",
|
|
"namespace": "root",
|
|
"username": "end-user",
|
|
"contact": {
|
|
"email": "test@hashicorp.com",
|
|
"phone_number": "123-456-7890"
|
|
}
|
|
}`, discovery.Issuer, clientID, entityID),
|
|
},
|
|
{
|
|
name: "active: authorization code flow with additional scopes",
|
|
args: args{
|
|
options: []oidc.Option{
|
|
oidc.WithScopes("openid user groups"),
|
|
},
|
|
},
|
|
expected: fmt.Sprintf(`{
|
|
"iss": "%s",
|
|
"aud": "%s",
|
|
"sub": "%s",
|
|
"namespace": "root",
|
|
"username": "end-user",
|
|
"contact": {
|
|
"email": "test@hashicorp.com",
|
|
"phone_number": "123-456-7890"
|
|
},
|
|
"groups": ["engineering"]
|
|
}`, discovery.Issuer, clientID, entityID),
|
|
},
|
|
{
|
|
name: "active: authorization code flow with max_age parameter",
|
|
args: args{
|
|
options: []oidc.Option{
|
|
oidc.WithScopes("openid"),
|
|
oidc.WithMaxAge(60),
|
|
},
|
|
},
|
|
expected: fmt.Sprintf(`{
|
|
"iss": "%s",
|
|
"aud": "%s",
|
|
"sub": "%s",
|
|
"namespace": "root",
|
|
"auth_time": %d
|
|
}`, discovery.Issuer, clientID, entityID, expectedAuthTime),
|
|
},
|
|
{
|
|
name: "standby: authorization code flow with additional scopes",
|
|
args: args{
|
|
useStandby: true,
|
|
options: []oidc.Option{
|
|
oidc.WithScopes("openid user groups"),
|
|
},
|
|
},
|
|
expected: fmt.Sprintf(`{
|
|
"iss": "%s",
|
|
"aud": "%s",
|
|
"sub": "%s",
|
|
"namespace": "root",
|
|
"username": "end-user",
|
|
"contact": {
|
|
"email": "test@hashicorp.com",
|
|
"phone_number": "123-456-7890"
|
|
},
|
|
"groups": ["engineering"]
|
|
}`, discovery.Issuer, clientID, entityID),
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
client := active
|
|
if tt.args.useStandby {
|
|
client = standby
|
|
}
|
|
client.SetToken(clientToken)
|
|
|
|
// Create the client-side OIDC request state
|
|
oidcRequest, err := oidc.NewRequest(10*time.Minute, testRedirectURI, tt.args.options...)
|
|
require.NoError(t, err)
|
|
|
|
// Get the URL for the authorization endpoint from the OIDC client
|
|
authURL, err := p.AuthURL(context.Background(), oidcRequest)
|
|
require.NoError(t, err)
|
|
parsedAuthURL, err := url.Parse(authURL)
|
|
require.NoError(t, err)
|
|
|
|
// This replace only occurs because we're not using the browser in this test
|
|
authURLPath := strings.Replace(parsedAuthURL.Path, "/ui/vault/", "/v1/", 1)
|
|
|
|
// Kick off the authorization code flow
|
|
var authResp struct {
|
|
Code string `json:"code"`
|
|
State string `json:"state"`
|
|
}
|
|
decodeRawRequest(t, client, http.MethodGet, authURLPath, parsedAuthURL.Query(), &authResp)
|
|
|
|
// The returned state must match the OIDC client state
|
|
require.Equal(t, oidcRequest.State(), authResp.State)
|
|
|
|
// Exchange the authorization code for an ID token and access token.
|
|
// The ID token signature is verified using the provider's public keys after
|
|
// the exchange takes place. The ID token is also validated according to the
|
|
// client-side requirements of the OIDC spec. See the validation code at:
|
|
// - https://github.com/hashicorp/cap/blob/main/oidc/provider.go#L240
|
|
// - https://github.com/hashicorp/cap/blob/main/oidc/provider.go#L441
|
|
token, err := p.Exchange(context.Background(), oidcRequest, authResp.State, authResp.Code)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, token)
|
|
idToken := token.IDToken()
|
|
accessToken := token.StaticTokenSource()
|
|
|
|
// Get the ID token claims
|
|
allClaims := make(map[string]interface{})
|
|
require.NoError(t, idToken.Claims(&allClaims))
|
|
|
|
// Get the sub claim for userinfo validation
|
|
require.NotEmpty(t, allClaims["sub"])
|
|
subject := allClaims["sub"].(string)
|
|
|
|
// Request userinfo using the access token
|
|
err = p.UserInfo(context.Background(), accessToken, subject, &allClaims)
|
|
require.NoError(t, err)
|
|
|
|
// Assert that claims computed during the flow (i.e., not known
|
|
// ahead of time in this test) are present as top-level keys
|
|
for _, claim := range []string{"iat", "exp", "nonce", "at_hash", "c_hash"} {
|
|
_, ok := allClaims[claim]
|
|
require.True(t, ok)
|
|
}
|
|
|
|
// Assert that all other expected claims are populated
|
|
expectedClaims := make(map[string]interface{})
|
|
require.NoError(t, json.Unmarshal([]byte(tt.expected), &expectedClaims))
|
|
for k, expectedVal := range expectedClaims {
|
|
actualVal, ok := allClaims[k]
|
|
require.True(t, ok)
|
|
require.EqualValues(t, expectedVal, actualVal)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func setupOIDCTestCluster(t *testing.T, numCores int) *vault.TestCluster {
|
|
t.Helper()
|
|
|
|
coreConfig := &vault.CoreConfig{
|
|
CredentialBackends: map[string]logical.Factory{
|
|
"userpass": userpass.Factory,
|
|
},
|
|
}
|
|
clusterOptions := &vault.TestClusterOptions{
|
|
NumCores: numCores,
|
|
HandlerFunc: vaulthttp.Handler,
|
|
}
|
|
cluster := vault.NewTestCluster(t, coreConfig, clusterOptions)
|
|
cluster.Start()
|
|
vault.TestWaitActive(t, cluster.Cores[0].Core)
|
|
|
|
return cluster
|
|
}
|
|
|
|
func decodeRawRequest(t *testing.T, client *api.Client, method, path string, params url.Values, v interface{}) {
|
|
t.Helper()
|
|
|
|
// Create the request and add query params if provided
|
|
req := client.NewRequest(method, path)
|
|
req.Params = params
|
|
|
|
// Send the raw request
|
|
r, err := client.RawRequest(req)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, r)
|
|
require.Equal(t, http.StatusOK, r.StatusCode)
|
|
defer r.Body.Close()
|
|
|
|
// Decode the body into v
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(v))
|
|
}
|