Adds OIDC Token and UserInfo endpoints (#12711)

This commit is contained in:
Austin Gebauer 2021-10-13 18:59:36 -07:00 committed by GitHub
parent 15fb265f85
commit 0551f91068
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 2077 additions and 733 deletions

1
go.mod
View File

@ -59,6 +59,7 @@ require (
github.com/google/go-github v17.0.0+incompatible
github.com/google/go-metrics-stackdriver v0.2.0
github.com/gorilla/mux v1.7.3 // indirect
github.com/hashicorp/cap v0.1.0
github.com/hashicorp/consul-template v0.27.1
github.com/hashicorp/consul/api v1.11.0
github.com/hashicorp/errwrap v1.1.0

View File

@ -411,9 +411,10 @@ func TestSysMounts_headerAuth(t *testing.T) {
"type": "identity",
"external_entropy_access": false,
"config": map[string]interface{}{
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"passthrough_request_headers": []interface{}{"Authorization"},
},
"local": false,
"seal_wrap": false,
@ -465,9 +466,10 @@ func TestSysMounts_headerAuth(t *testing.T) {
"type": "identity",
"external_entropy_access": false,
"config": map[string]interface{}{
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"passthrough_request_headers": []interface{}{"Authorization"},
},
"local": false,
"seal_wrap": false,

View File

@ -510,10 +510,18 @@ WRITE_RESPONSE:
w.Header().Set("Content-Type", contentType)
}
if cacheControl, ok := resp.Data[logical.HTTPRawCacheControl].(string); ok {
if cacheControl, ok := resp.Data[logical.HTTPCacheControlHeader].(string); ok {
w.Header().Set("Cache-Control", cacheControl)
}
if pragma, ok := resp.Data[logical.HTTPPragmaHeader].(string); ok {
w.Header().Set("Pragma", pragma)
}
if wwwAuthn, ok := resp.Data[logical.HTTPWWWAuthenticateHeader].(string); ok {
w.Header().Set("WWW-Authenticate", wwwAuthn)
}
w.WriteHeader(status)
w.Write(body)
}

View File

@ -73,9 +73,10 @@ func TestSysMounts(t *testing.T) {
"type": "identity",
"external_entropy_access": false,
"config": map[string]interface{}{
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"passthrough_request_headers": []interface{}{"Authorization"},
},
"local": false,
"seal_wrap": false,
@ -127,9 +128,10 @@ func TestSysMounts(t *testing.T) {
"type": "identity",
"external_entropy_access": false,
"config": map[string]interface{}{
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"passthrough_request_headers": []interface{}{"Authorization"},
},
"local": false,
"seal_wrap": false,
@ -241,9 +243,10 @@ func TestSysMount(t *testing.T) {
"type": "identity",
"external_entropy_access": false,
"config": map[string]interface{}{
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"passthrough_request_headers": []interface{}{"Authorization"},
},
"local": false,
"seal_wrap": false,
@ -308,9 +311,10 @@ func TestSysMount(t *testing.T) {
"type": "identity",
"external_entropy_access": false,
"config": map[string]interface{}{
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"passthrough_request_headers": []interface{}{"Authorization"},
},
"local": false,
"seal_wrap": false,
@ -441,9 +445,10 @@ func TestSysRemount(t *testing.T) {
"type": "identity",
"external_entropy_access": false,
"config": map[string]interface{}{
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"passthrough_request_headers": []interface{}{"Authorization"},
},
"local": false,
"seal_wrap": false,
@ -508,9 +513,10 @@ func TestSysRemount(t *testing.T) {
"type": "identity",
"external_entropy_access": false,
"config": map[string]interface{}{
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"passthrough_request_headers": []interface{}{"Authorization"},
},
"local": false,
"seal_wrap": false,
@ -609,9 +615,10 @@ func TestSysUnmount(t *testing.T) {
"type": "identity",
"external_entropy_access": false,
"config": map[string]interface{}{
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"passthrough_request_headers": []interface{}{"Authorization"},
},
"local": false,
"seal_wrap": false,
@ -663,9 +670,10 @@ func TestSysUnmount(t *testing.T) {
"type": "identity",
"external_entropy_access": false,
"config": map[string]interface{}{
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"passthrough_request_headers": []interface{}{"Authorization"},
},
"local": false,
"seal_wrap": false,
@ -863,9 +871,10 @@ func TestSysTuneMount(t *testing.T) {
"type": "identity",
"external_entropy_access": false,
"config": map[string]interface{}{
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"passthrough_request_headers": []interface{}{"Authorization"},
},
"local": false,
"seal_wrap": false,
@ -930,9 +939,10 @@ func TestSysTuneMount(t *testing.T) {
"type": "identity",
"external_entropy_access": false,
"config": map[string]interface{}{
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"passthrough_request_headers": []interface{}{"Authorization"},
},
"local": false,
"seal_wrap": false,
@ -1070,9 +1080,10 @@ func TestSysTuneMount(t *testing.T) {
"type": "identity",
"external_entropy_access": false,
"config": map[string]interface{}{
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"passthrough_request_headers": []interface{}{"Authorization"},
},
"local": false,
"seal_wrap": false,
@ -1137,9 +1148,10 @@ func TestSysTuneMount(t *testing.T) {
"type": "identity",
"external_entropy_access": false,
"config": map[string]interface{}{
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"default_lease_ttl": json.Number("0"),
"max_lease_ttl": json.Number("0"),
"force_no_cache": false,
"passthrough_request_headers": []interface{}{"Authorization"},
},
"local": false,
"seal_wrap": false,

View File

@ -34,9 +34,17 @@ const (
// ignore errors.
HTTPRawBodyAlreadyJSONDecoded = "http_raw_body_already_json_decoded"
// If set, HTTPRawCacheControl will replace the default Cache-Control=no-store header
// If set, HTTPCacheControlHeader will replace the default Cache-Control=no-store header
// set by the generic wrapping handler. The value must be a string.
HTTPRawCacheControl = "http_raw_cache_control"
HTTPCacheControlHeader = "http_raw_cache_control"
// If set, HTTPPragmaHeader will set the Pragma response header.
// The value must be a string.
HTTPPragmaHeader = "http_raw_pragma"
// If set, HTTPWWWAuthenticateHeader will set the WWW-Authenticate response header.
// The value must be a string.
HTTPWWWAuthenticateHeader = "http_www_authenticate"
)
// Response is a struct that stores the response of a request.

View File

@ -0,0 +1,381 @@
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))
}

View File

@ -70,12 +70,16 @@ type role struct {
// include top-level keys, but those keys may not overwrite any of the
// required OIDC fields.
type idToken struct {
Issuer string `json:"iss"` // api_addr or custom Issuer
Namespace string `json:"namespace"` // Namespace of issuer
Subject string `json:"sub"` // Entity ID
Audience string `json:"aud"` // role ID will be used here.
Expiry int64 `json:"exp"` // Expiration, as determined by the role.
IssuedAt int64 `json:"iat"` // Time of token creation
Issuer string `json:"iss"` // api_addr or custom Issuer
Namespace string `json:"namespace"` // Namespace of issuer
Subject string `json:"sub"` // Entity ID
Audience string `json:"aud"` // Role or client ID will be used here.
Expiry int64 `json:"exp"` // Expiration, as determined by the role or client.
IssuedAt int64 `json:"iat"` // Time of token creation
Nonce string `json:"nonce"` // Nonce given in OIDC authentication requests
AuthTime int64 `json:"auth_time"` // AuthTime given in OIDC authentication requests
AccessTokenHash string `json:"at_hash"` // Access token hash value
CodeHash string `json:"c_hash"` // Authorization code hash value
}
// discovery contains a subset of the required elements of OIDC discovery needed
@ -107,8 +111,12 @@ const (
)
var (
requiredClaims = []string{"iat", "aud", "exp", "iss", "sub", "namespace"}
supportedAlgs = []string{
requiredClaims = []string{
"iat", "aud", "exp", "iss",
"sub", "namespace", "nonce",
"auth_time", "at_hash", "c_hash",
}
supportedAlgs = []string{
string(jose.RS256),
string(jose.RS384),
string(jose.RS512),
@ -826,29 +834,14 @@ func (i *IdentityStore) pathOIDCGenerateToken(ctx context.Context, req *logical.
return logical.ErrorResponse("role %q not found", roleName), nil
}
var key *namedKey
keyRaw, found, err := i.oidcCache.Get(ns, "namedKeys/"+role.Key)
key, err := i.getNamedKey(ctx, req.Storage, role.Key)
if err != nil {
return nil, err
}
if found {
key = keyRaw.(*namedKey)
} else {
entry, _ := req.Storage.Get(ctx, namedKeyConfigPath+role.Key)
if entry == nil {
return logical.ErrorResponse("key %q not found", role.Key), nil
}
if err := entry.DecodeJSON(&key); err != nil {
return nil, err
}
if err := i.oidcCache.SetDefault(ns, "namedKeys/"+role.Key, key); err != nil {
return nil, err
}
if key == nil {
return logical.ErrorResponse("key %q not found", role.Key), nil
}
// Validate that the role is allowed to sign with its key (the key could have been updated)
if !strutil.StrListContains(key.AllowedClientIDs, "*") && !strutil.StrListContains(key.AllowedClientIDs, role.ClientID) {
return logical.ErrorResponse("the key %q does not list the client ID of the role %q as an allowed client ID", role.Key, roleName), nil
@ -897,7 +890,21 @@ func (i *IdentityStore) pathOIDCGenerateToken(ctx context.Context, req *logical.
groups = append(groups, inheritedGroups...)
payload, err := idToken.generatePayload(i.Logger(), role.Template, e, groups)
// Parse and integrate the populated template. Structural errors with the template _should_
// be caught during configuration. Error found during runtime will be logged, but they will
// not block generation of the basic ID token. They should not be returned to the requester.
_, populatedTemplate, err := identitytpl.PopulateString(identitytpl.PopulateStringInput{
Mode: identitytpl.JSONTemplating,
String: role.Template,
Entity: identity.ToSDKEntity(e),
Groups: identity.ToSDKGroups(groups),
NamespaceID: ns.ID,
})
if err != nil {
i.Logger().Warn("error populating OIDC token template", "template", role.Template, "error", err)
}
payload, err := idToken.generatePayload(i.Logger(), populatedTemplate)
if err != nil {
i.Logger().Warn("error populating OIDC token template", "error", err)
}
@ -915,7 +922,43 @@ func (i *IdentityStore) pathOIDCGenerateToken(ctx context.Context, req *logical.
return retResp, nil
}
func (tok *idToken) generatePayload(logger hclog.Logger, template string, entity *identity.Entity, groups []*identity.Group) ([]byte, error) {
func (i *IdentityStore) getNamedKey(ctx context.Context, s logical.Storage, name string) (*namedKey, error) {
ns, err := namespace.FromContext(ctx)
if err != nil {
return nil, err
}
// Attempt to get the key from the cache
keyRaw, found, err := i.oidcCache.Get(ns, "namedKeys/"+name)
if err != nil {
return nil, err
}
if key, ok := keyRaw.(*namedKey); ok && found {
return key, nil
}
// Fall back to reading the key from storage
entry, err := s.Get(ctx, namedKeyConfigPath+name)
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
var key namedKey
if err := entry.DecodeJSON(&key); err != nil {
return nil, err
}
// Cache the key
if err := i.oidcCache.SetDefault(ns, "namedKeys/"+name, &key); err != nil {
i.logger.Warn("failed to cache key", "error", err)
}
return &key, nil
}
func (tok *idToken) generatePayload(logger hclog.Logger, templates ...string) ([]byte, error) {
output := map[string]interface{}{
"iss": tok.Issuer,
"namespace": tok.Namespace,
@ -925,23 +968,41 @@ func (tok *idToken) generatePayload(logger hclog.Logger, template string, entity
"iat": tok.IssuedAt,
}
// Parse and integrate the populated role template. Structural errors with the template _should_
// be caught during role configuration. Error found during runtime will be logged, but they will
// not block generation of the basic ID token. They should not be returned to the requester.
_, populatedTemplate, err := identitytpl.PopulateString(identitytpl.PopulateStringInput{
Mode: identitytpl.JSONTemplating,
String: template,
Entity: identity.ToSDKEntity(entity),
Groups: identity.ToSDKGroups(groups),
// namespace?
})
if err != nil {
logger.Warn("error populating OIDC token template", "template", template, "error", err)
if len(tok.Nonce) > 0 {
output["nonce"] = tok.Nonce
}
if tok.AuthTime > 0 {
output["auth_time"] = tok.AuthTime
}
if len(tok.AccessTokenHash) > 0 {
output["at_hash"] = tok.AccessTokenHash
}
if len(tok.CodeHash) > 0 {
output["c_hash"] = tok.CodeHash
}
if populatedTemplate != "" {
// Merge each of the populated JSON templates into output
err := mergeJSONTemplates(logger, output, templates...)
if err != nil {
logger.Error("failed to populate templates for ID token generation", "error", err)
return nil, err
}
payload, err := json.Marshal(output)
if err != nil {
return nil, err
}
return payload, nil
}
// mergeJSONTemplates will merge each of the given JSON templates into the given
// output map. It will simply merge the top-level keys of the unmarshalled JSON
// templates into output, which means that any conflicting keys will be overwritten.
func mergeJSONTemplates(logger hclog.Logger, output map[string]interface{}, templates ...string) error {
for _, template := range templates {
var parsed map[string]interface{}
if err := json.Unmarshal([]byte(populatedTemplate), &parsed); err != nil {
if err := json.Unmarshal([]byte(template), &parsed); err != nil {
logger.Warn("error parsing OIDC template", "template", template, "err", err)
}
@ -954,12 +1015,7 @@ func (tok *idToken) generatePayload(logger hclog.Logger, template string, entity
}
}
payload, err := json.Marshal(output)
if err != nil {
return nil, err
}
return payload, nil
return nil
}
func (k *namedKey) signPayload(payload []byte) (string, error) {
@ -1215,10 +1271,10 @@ func (i *IdentityStore) pathOIDCDiscovery(ctx context.Context, req *logical.Requ
resp := &logical.Response{
Data: map[string]interface{}{
logical.HTTPStatusCode: 200,
logical.HTTPRawBody: data,
logical.HTTPContentType: "application/json",
logical.HTTPRawCacheControl: "max-age=3600",
logical.HTTPStatusCode: 200,
logical.HTTPRawBody: data,
logical.HTTPContentType: "application/json",
logical.HTTPCacheControlHeader: "max-age=3600",
},
}
@ -1312,7 +1368,7 @@ func (i *IdentityStore) pathOIDCReadPublicKeys(ctx context.Context, req *logical
}
if header != "" {
resp.Data[logical.HTTPRawCacheControl] = header
resp.Data[logical.HTTPCacheControlHeader] = header
}
}
@ -1878,6 +1934,15 @@ func (c *oidcCache) SetDefault(ns *namespace.Namespace, key string, obj interfac
return nil
}
func (c *oidcCache) Delete(ns *namespace.Namespace, key string) error {
if ns == nil {
return errNilNamespace
}
c.c.Delete(c.nskey(ns, key))
return nil
}
func (c *oidcCache) Flush(ns *namespace.Namespace) error {
if ns == nil {
return errNilNamespace

View File

@ -2,10 +2,14 @@ package vault
import (
"context"
"crypto/sha256"
"crypto/sha512"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"hash"
"net/http"
"net/url"
"sort"
@ -15,6 +19,7 @@ import (
"github.com/hashicorp/go-memdb"
"github.com/hashicorp/go-secure-stdlib/base62"
"github.com/hashicorp/go-secure-stdlib/strutil"
"github.com/hashicorp/vault/helper/identity"
"github.com/hashicorp/vault/helper/namespace"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/helper/identitytpl"
@ -24,7 +29,10 @@ import (
const (
// OIDC-related constants
openIDScope = "openid"
openIDScope = "openid"
scopesDelimiter = " "
accessTokenScopesMeta = "scopes"
accessTokenClientIDMeta = "client_id"
// Storage path constants
oidcProviderPrefix = "oidc_provider/"
@ -47,6 +55,21 @@ const (
ErrAuthRequestNotSupported = "request_not_supported"
ErrAuthRequestURINotSupported = "request_uri_not_supported"
// Error constants used in the Token Endpoint. See details at
// https://openid.net/specs/openid-connect-core-1_0.html#TokenErrorResponse
ErrTokenInvalidRequest = "invalid_request"
ErrTokenInvalidClient = "invalid_client"
ErrTokenInvalidGrant = "invalid_grant"
ErrTokenUnsupportedGrantType = "unsupported_grant_type"
ErrTokenServerError = "server_error"
// Error constants used in the UserInfo Endpoint. See details at
// https://openid.net/specs/openid-connect-core-1_0.html#UserInfoError
ErrUserInfoServerError = "server_error"
ErrUserInfoInvalidRequest = "invalid_request"
ErrUserInfoInvalidToken = "invalid_token"
ErrUserInfoAccessDenied = "access_denied"
// The following errors are used by the UI for specific behavior of
// the OIDC specification. Any changes to their values must come with
// a corresponding change in the UI code.
@ -398,6 +421,62 @@ func oidcProviderPaths(i *IdentityStore) []*framework.Path {
HelpSynopsis: "Provides the OIDC Authorization Endpoint.",
HelpDescription: "The OIDC Authorization Endpoint performs authentication and authorization by using request parameters defined by OpenID Connect (OIDC).",
},
{
Pattern: "oidc/provider/" + framework.GenericNameRegex("name") + "/token",
Fields: map[string]*framework.FieldSchema{
"name": {
Type: framework.TypeString,
Description: "Name of the provider",
},
"code": {
Type: framework.TypeString,
Description: "The authorization code received from the provider's authorization endpoint.",
Required: true,
},
"grant_type": {
Type: framework.TypeString,
Description: "The authorization grant type. The following grant types are supported: 'authorization_code'.",
Required: true,
},
"redirect_uri": {
Type: framework.TypeString,
Description: "The callback location where the authentication response was sent.",
Required: true,
},
// The client_id and client_secret are provided to the token endpoint via
// the client_secret_basic authentication method, which uses the HTTP Basic
// authentication scheme. See the OIDC spec for details at:
// https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.UpdateOperation: &framework.PathOperation{
Callback: i.pathOIDCToken,
ForwardPerformanceStandby: true,
ForwardPerformanceSecondary: false,
},
},
HelpSynopsis: "Provides the OIDC Token Endpoint.",
HelpDescription: "The OIDC Token Endpoint allows a client to exchange its Authorization Grant for an Access Token and ID Token.",
},
{
Pattern: "oidc/provider/" + framework.GenericNameRegex("name") + "/userinfo",
Fields: map[string]*framework.FieldSchema{
"name": {
Type: framework.TypeString,
Description: "Name of the provider",
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{
Callback: i.pathOIDCUserInfo,
},
logical.UpdateOperation: &framework.PathOperation{
Callback: i.pathOIDCUserInfo,
},
},
HelpSynopsis: "Provides the OIDC UserInfo Endpoint.",
HelpDescription: "The OIDC UserInfo Endpoint returns claims about the authenticated end-user.",
},
}
}
@ -733,7 +812,24 @@ func (i *IdentityStore) pathOIDCListScope(ctx context.Context, req *logical.Requ
func (i *IdentityStore) pathOIDCReadScope(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
name := d.Get("name").(string)
entry, err := req.Storage.Get(ctx, scopePath+name)
scope, err := i.getOIDCScope(ctx, req.Storage, name)
if err != nil {
return nil, err
}
if scope == nil {
return nil, nil
}
return &logical.Response{
Data: map[string]interface{}{
"template": scope.Template,
"description": scope.Description,
},
}, nil
}
func (i *IdentityStore) getOIDCScope(ctx context.Context, s logical.Storage, name string) (*scope, error) {
entry, err := s.Get(ctx, scopePath+name)
if err != nil {
return nil, err
}
@ -745,12 +841,8 @@ func (i *IdentityStore) pathOIDCReadScope(ctx context.Context, req *logical.Requ
if err := entry.DecodeJSON(&scope); err != nil {
return nil, err
}
return &logical.Response{
Data: map[string]interface{}{
"template": scope.Template,
"description": scope.Description,
},
}, nil
return &scope, nil
}
// pathOIDCDeleteScope is used to delete an scope
@ -1052,24 +1144,19 @@ func (i *IdentityStore) pathOIDCCreateUpdateProvider(ctx context.Context, req *l
scopeTemplateKeyNames := make(map[string]string)
for _, scopeName := range provider.Scopes {
entry, err := req.Storage.Get(ctx, scopePath+scopeName)
scope, err := i.getOIDCScope(ctx, req.Storage, scopeName)
if err != nil {
return nil, err
}
// enforce scope existence on provider create and update
if entry == nil {
if scope == nil {
return logical.ErrorResponse("scope %q does not exist", scopeName), nil
}
// ensure no two templates have the same top-level keys
var storedScope scope
if err := entry.DecodeJSON(&storedScope); err != nil {
return nil, err
}
_, populatedTemplate, err := identitytpl.PopulateString(identitytpl.PopulateStringInput{
Mode: identitytpl.JSONTemplating,
String: storedScope.Template,
String: scope.Template,
Entity: new(logical.Entity),
Groups: make([]*logical.Group, 0),
})
@ -1223,10 +1310,10 @@ func (i *IdentityStore) pathOIDCProviderDiscovery(ctx context.Context, req *logi
resp := &logical.Response{
Data: map[string]interface{}{
logical.HTTPStatusCode: 200,
logical.HTTPRawBody: data,
logical.HTTPContentType: "application/json",
logical.HTTPRawCacheControl: "max-age=3600",
logical.HTTPStatusCode: 200,
logical.HTTPRawBody: data,
logical.HTTPContentType: "application/json",
logical.HTTPCacheControlHeader: "max-age=3600",
},
}
@ -1359,12 +1446,20 @@ func (i *IdentityStore) pathOIDCAuthorize(ctx context.Context, req *logical.Requ
}
// Validate that a scope parameter is present and contains the openid scope value
scopes := strutil.ParseStringSlice(d.Get("scope").(string), " ")
if len(scopes) == 0 || !strutil.StrListContains(scopes, openIDScope) {
requestedScopes := strutil.ParseDedupAndSortStrings(d.Get("scope").(string), scopesDelimiter)
if len(requestedScopes) == 0 || !strutil.StrListContains(requestedScopes, openIDScope) {
return authResponse("", state, ErrAuthInvalidRequest,
fmt.Sprintf("scope parameter must contain the %q value", openIDScope))
}
// Scope values that are not supported by the provider should be ignored
scopes := make([]string, 0)
for _, scope := range requestedScopes {
if strutil.StrListContains(provider.Scopes, scope) && scope != openIDScope {
scopes = append(scopes, scope)
}
}
// Validate the response type
responseType := d.Get("response_type").(string)
if responseType == "" {
@ -1426,9 +1521,8 @@ func (i *IdentityStore) pathOIDCAuthorize(ctx context.Context, req *logical.Requ
return authResponse("", state, ErrAuthAccessDenied, "identity entity must be associated with the request")
}
// Validate that the identity entity associated with the request
// is a member of the client assignments' groups or entities
isMember, err := i.entityHasAssignment(ctx, req.Storage, entity.GetID(), client.Assignments)
// Validate that the entity is a member of the client's assignments
isMember, err := i.entityHasAssignment(ctx, req.Storage, entity, client.Assignments)
if err != nil {
return authResponse("", state, ErrAuthServerError, err.Error())
}
@ -1516,7 +1610,7 @@ func authResponse(code, state, errorCode, errorDescription string) (*logical.Res
}
}
data, err := json.Marshal(response)
body, err := json.Marshal(response)
if err != nil {
return nil, err
}
@ -1524,17 +1618,543 @@ func authResponse(code, state, errorCode, errorDescription string) (*logical.Res
return &logical.Response{
Data: map[string]interface{}{
logical.HTTPStatusCode: statusCode,
logical.HTTPRawBody: data,
logical.HTTPRawBody: body,
logical.HTTPContentType: "application/json",
},
}, nil
}
// entityHasAssignment returns true if the entity is a member of any of the
// assignments' groups or entities. Otherwise, returns false or an error.
func (i *IdentityStore) entityHasAssignment(ctx context.Context, s logical.Storage, entityID string, assignments []string) (bool, error) {
func (i *IdentityStore) pathOIDCToken(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
// Get the namespace
ns, err := namespace.FromContext(ctx)
if err != nil {
return tokenResponse(nil, ErrTokenServerError, err.Error())
}
// Get the OIDC provider
name := d.Get("name").(string)
provider, err := i.getOIDCProvider(ctx, req.Storage, name)
if err != nil {
return tokenResponse(nil, ErrTokenServerError, err.Error())
}
if provider == nil {
return tokenResponse(nil, ErrTokenInvalidRequest, "provider not found")
}
// Authenticate the client using the client_secret_basic authentication method.
// The authentication method uses the HTTP Basic authentication scheme. Details at
// https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication
headerReq := &http.Request{Header: req.Headers}
clientID, clientSecret, ok := headerReq.BasicAuth()
if !ok {
return tokenResponse(nil, ErrTokenInvalidRequest, "client failed to authenticate")
}
client, err := i.clientByID(ctx, req.Storage, clientID)
if err != nil {
return tokenResponse(nil, ErrTokenServerError, err.Error())
}
if client == nil {
i.Logger().Debug("client failed to authenticate with client not found", "client_id", clientID)
return tokenResponse(nil, ErrTokenInvalidClient, "client failed to authenticate")
}
if subtle.ConstantTimeCompare([]byte(client.ClientSecret), []byte(clientSecret)) == 0 {
i.Logger().Debug("client failed to authenticate with invalid client secret", "client_id", clientID)
return tokenResponse(nil, ErrTokenInvalidClient, "client failed to authenticate")
}
// Validate that the client is authorized to use the provider
if !strutil.StrListContains(provider.AllowedClientIDs, "*") &&
!strutil.StrListContains(provider.AllowedClientIDs, clientID) {
return tokenResponse(nil, ErrTokenInvalidClient, "client is not authorized to use the provider")
}
// Get the key that the client uses to sign ID tokens
key, err := i.getNamedKey(ctx, req.Storage, client.Key)
if err != nil {
return tokenResponse(nil, ErrTokenServerError, err.Error())
}
if key == nil {
return tokenResponse(nil, ErrTokenServerError, fmt.Sprintf("client key %q not found", client.Key))
}
// Validate that the client is authorized to use the key
if !strutil.StrListContains(key.AllowedClientIDs, "*") &&
!strutil.StrListContains(key.AllowedClientIDs, clientID) {
return tokenResponse(nil, ErrTokenInvalidClient, "client is not authorized to use the key")
}
// Validate the grant type
grantType := d.Get("grant_type").(string)
if grantType == "" {
return tokenResponse(nil, ErrTokenInvalidRequest, "grant_type parameter is required")
}
if grantType != "authorization_code" {
return tokenResponse(nil, ErrTokenUnsupportedGrantType, "unsupported grant_type value")
}
// Validate the authorization code
code := d.Get("code").(string)
if code == "" {
return tokenResponse(nil, ErrTokenInvalidRequest, "code parameter is required")
}
// Get the authorization code entry and defer its deletion (single use)
authCodeEntryRaw, ok, err := i.oidcAuthCodeCache.Get(ns, code)
defer i.oidcAuthCodeCache.Delete(ns, code)
if err != nil {
return tokenResponse(nil, ErrTokenServerError, err.Error())
}
if !ok {
return tokenResponse(nil, ErrTokenInvalidGrant, "authorization grant is invalid or expired")
}
authCodeEntry, ok := authCodeEntryRaw.(*authCodeCacheEntry)
if !ok {
return tokenResponse(nil, ErrTokenServerError, "authorization grant is invalid or expired")
}
// Ensure the authorization code was issued to the authenticated client
if authCodeEntry.clientID != clientID {
return tokenResponse(nil, ErrTokenInvalidGrant, "authorization grant is invalid or expired")
}
// Ensure that the redirect_uri parameter value is identical to the redirect_uri
// parameter value that was included in the initial authorization request.
redirectURI := d.Get("redirect_uri").(string)
if redirectURI == "" {
return tokenResponse(nil, ErrTokenInvalidRequest, "redirect_uri parameter is required")
}
if authCodeEntry.redirectURI != redirectURI {
return tokenResponse(nil, ErrTokenInvalidGrant, "redirect_uri does not match the redirect_uri used in the authorization request")
}
// Get the entity associated with the initial authorization request
entity, err := i.MemDBEntityByID(authCodeEntry.entityID, true)
if err != nil {
return tokenResponse(nil, ErrTokenServerError, err.Error())
}
if entity == nil {
return tokenResponse(nil, ErrTokenInvalidRequest, "identity entity associated with request not found")
}
// Validate that the entity is a member of the client's assignments
isMember, err := i.entityHasAssignment(ctx, req.Storage, entity, client.Assignments)
if err != nil {
return tokenResponse(nil, ErrTokenServerError, err.Error())
}
if !isMember {
return tokenResponse(nil, ErrTokenInvalidRequest, "identity entity not authorized by client assignment")
}
// The access token is a Vault batch token with a policy that only
// provides access to the issuing provider's userinfo endpoint.
accessTokenIssuedAt := time.Now()
accessTokenExpiry := accessTokenIssuedAt.Add(client.AccessTokenTTL)
accessToken := &logical.TokenEntry{
Type: logical.TokenTypeBatch,
NamespaceID: ns.ID,
Path: req.Path,
TTL: client.AccessTokenTTL,
CreationTime: accessTokenIssuedAt.Unix(),
EntityID: entity.ID,
NoIdentityPolicies: true,
Meta: map[string]string{
"oidc_token_type": "access token",
},
InternalMeta: map[string]string{
accessTokenClientIDMeta: client.ClientID,
accessTokenScopesMeta: strings.Join(authCodeEntry.scopes, scopesDelimiter),
},
InlinePolicy: fmt.Sprintf(`
path "identity/oidc/provider/%s/userinfo" {
capabilities = ["read", "update"]
}
`, name),
}
err = i.tokenStorer.CreateToken(ctx, accessToken)
if err != nil {
return tokenResponse(nil, ErrTokenServerError, err.Error())
}
// Compute the access token hash claim (at_hash)
atHash, err := computeHashClaim(key.Algorithm, accessToken.ID)
if err != nil {
return tokenResponse(nil, ErrTokenServerError, err.Error())
}
// Compute the authorization code hash claim (c_hash)
cHash, err := computeHashClaim(key.Algorithm, code)
if err != nil {
return tokenResponse(nil, ErrTokenServerError, err.Error())
}
// Set the ID token claims
idTokenIssuedAt := time.Now()
idTokenExpiry := idTokenIssuedAt.Add(client.IDTokenTTL)
idToken := idToken{
Namespace: ns.ID,
Issuer: provider.effectiveIssuer,
Subject: authCodeEntry.entityID,
Audience: authCodeEntry.clientID,
Nonce: authCodeEntry.nonce,
Expiry: idTokenExpiry.Unix(),
IssuedAt: idTokenIssuedAt.Unix(),
AccessTokenHash: atHash,
CodeHash: cHash,
}
// Add the auth_time claim if it's not the zero time instant
if !authCodeEntry.authTime.IsZero() {
idToken.AuthTime = authCodeEntry.authTime.Unix()
}
// Populate each of the requested scope templates
templates, conflict, err := i.populateScopeTemplates(ctx, req.Storage, ns, entity, authCodeEntry.scopes...)
if !conflict && err != nil {
return tokenResponse(nil, ErrTokenServerError, err.Error())
}
if conflict && err != nil {
return tokenResponse(nil, ErrTokenInvalidRequest, err.Error())
}
// Generate the ID token payload
payload, err := idToken.generatePayload(i.Logger(), templates...)
if err != nil {
return tokenResponse(nil, ErrTokenServerError, err.Error())
}
// Sign the ID token using the client's key
signedIDToken, err := key.signPayload(payload)
if err != nil {
return tokenResponse(nil, ErrTokenServerError, err.Error())
}
return tokenResponse(map[string]interface{}{
"token_type": "Bearer",
"access_token": accessToken.ID,
"id_token": signedIDToken,
"expires_in": int64(accessTokenExpiry.Sub(accessTokenIssuedAt).Seconds()),
}, "", "")
}
// tokenResponse returns the OIDC Token Response. An error response is
// returned if the given error code is non-empty. For details, see spec at
// - https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse
// - https://openid.net/specs/openid-connect-core-1_0.html#TokenErrorResponse
func tokenResponse(response map[string]interface{}, errorCode, errorDescription string) (*logical.Response, error) {
statusCode := http.StatusOK
// Set the error response and status code if error code isn't empty
if errorCode != "" {
switch errorCode {
case ErrTokenInvalidClient:
statusCode = http.StatusUnauthorized
case ErrTokenServerError:
statusCode = http.StatusInternalServerError
default:
statusCode = http.StatusBadRequest
}
response = map[string]interface{}{
"error": errorCode,
"error_description": errorDescription,
}
}
body, err := json.Marshal(response)
if err != nil {
return nil, err
}
data := map[string]interface{}{
logical.HTTPStatusCode: statusCode,
logical.HTTPRawBody: body,
logical.HTTPContentType: "application/json",
// Token responses must include the following HTTP response headers
// https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse
logical.HTTPCacheControlHeader: "no-store",
logical.HTTPPragmaHeader: "no-cache",
}
// Set the WWW-Authenticate response header when returning the
// invalid_client error code per the OAuth 2.0 spec at
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
if errorCode == ErrTokenInvalidClient {
data[logical.HTTPWWWAuthenticateHeader] = "Basic"
}
return &logical.Response{
Data: data,
}, nil
}
func (i *IdentityStore) pathOIDCUserInfo(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
// Get the namespace
ns, err := namespace.FromContext(ctx)
if err != nil {
return userInfoResponse(nil, ErrUserInfoServerError, err.Error())
}
// Get the OIDC provider
name := d.Get("name").(string)
provider, err := i.getOIDCProvider(ctx, req.Storage, name)
if err != nil {
return userInfoResponse(nil, ErrUserInfoServerError, err.Error())
}
if provider == nil {
return userInfoResponse(nil, ErrUserInfoInvalidRequest, "provider not found")
}
// Validate that the access token was sent as a Bearer token
if req.ClientTokenSource != logical.ClientTokenFromAuthzHeader {
return userInfoResponse(nil, ErrUserInfoInvalidToken, "access token must be sent as a Bearer token")
}
// Look up the access token
te, err := i.tokenStorer.LookupToken(ctx, req.ClientToken)
if err != nil {
return userInfoResponse(nil, ErrUserInfoServerError, err.Error())
}
if te == nil {
return userInfoResponse(nil, ErrUserInfoInvalidToken, "access token is expired")
}
if te.Type != logical.TokenTypeBatch {
return userInfoResponse(nil, ErrUserInfoInvalidToken, "access token is malformed or invalid")
}
// Get the client ID that originated the request from the token metadata
clientID, ok := te.InternalMeta[accessTokenClientIDMeta]
if !ok {
return userInfoResponse(nil, ErrUserInfoServerError, "expected client ID in token metadata")
}
client, err := i.clientByID(ctx, req.Storage, clientID)
if err != nil {
return userInfoResponse(nil, ErrUserInfoServerError, err.Error())
}
if client == nil {
return userInfoResponse(nil, ErrUserInfoAccessDenied, "client not found")
}
// Get the entity associated with the request
entity, err := i.MemDBEntityByID(req.EntityID, false)
if err != nil {
return userInfoResponse(nil, ErrUserInfoServerError, err.Error())
}
if entity == nil {
return userInfoResponse(nil, ErrUserInfoAccessDenied, "identity entity must be associated with the request")
}
// Validate that the entity is a member of the client's assignments
isMember, err := i.entityHasAssignment(ctx, req.Storage, entity, client.Assignments)
if err != nil {
return userInfoResponse(nil, ErrUserInfoServerError, err.Error())
}
if !isMember {
return userInfoResponse(nil, ErrUserInfoAccessDenied, "identity entity not authorized by client assignment")
}
claims := map[string]interface{}{
// The subject claim must always be in the response
"sub": entity.ID,
}
// Get the scopes for the access token
scopes, ok := te.InternalMeta[accessTokenScopesMeta]
if !ok || len(scopes) == 0 {
return userInfoResponse(claims, "", "")
}
parsedScopes := strutil.ParseStringSlice(scopes, scopesDelimiter)
// Populate each of the token's scope templates
templates, conflict, err := i.populateScopeTemplates(ctx, req.Storage, ns, entity, parsedScopes...)
if !conflict && err != nil {
return userInfoResponse(nil, ErrUserInfoServerError, err.Error())
}
if conflict && err != nil {
return userInfoResponse(nil, ErrUserInfoInvalidRequest, err.Error())
}
// Merge all of the populated JSON scope templates into claims
if err := mergeJSONTemplates(i.Logger(), claims, templates...); err != nil {
return userInfoResponse(nil, ErrUserInfoServerError, err.Error())
}
return userInfoResponse(claims, "", "")
}
// userInfoResponse returns the OIDC UserInfo Response. An error response is
// returned if the given error code is non-empty. For details, see spec at
// - https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
// - https://openid.net/specs/openid-connect-core-1_0.html#UserInfoError
func userInfoResponse(response map[string]interface{}, errorCode, errorDescription string) (*logical.Response, error) {
statusCode := http.StatusOK
// Set the error response and status code if error code isn't empty
if errorCode != "" {
switch errorCode {
case ErrUserInfoInvalidRequest:
statusCode = http.StatusBadRequest
case ErrUserInfoInvalidToken:
statusCode = http.StatusUnauthorized
case ErrUserInfoAccessDenied:
statusCode = http.StatusForbidden
case ErrUserInfoServerError:
statusCode = http.StatusInternalServerError
}
response = map[string]interface{}{
"error": errorCode,
"error_description": errorDescription,
}
}
body, err := json.Marshal(response)
if err != nil {
return nil, err
}
data := map[string]interface{}{
logical.HTTPStatusCode: statusCode,
logical.HTTPRawBody: body,
logical.HTTPContentType: "application/json",
}
// Set the WWW-Authenticate response header when returning error codes
// defined in https://datatracker.ietf.org/doc/html/rfc6750#section-3
if errorCode == ErrUserInfoInvalidRequest || errorCode == ErrUserInfoInvalidToken {
data[logical.HTTPWWWAuthenticateHeader] = fmt.Sprintf("Bearer error=%q,error_description=%q",
errorCode, errorDescription)
}
return &logical.Response{
Data: data,
}, nil
}
// getScopeTemplates returns a mapping from scope names to
// their templates for each of the given scopes.
func (i *IdentityStore) getScopeTemplates(ctx context.Context, s logical.Storage, scopes ...string) (map[string]string, error) {
templates := make(map[string]string)
for _, name := range scopes {
if name == openIDScope {
// No template for the openid scope
continue
}
// Get the scope template
scope, err := i.getOIDCScope(ctx, s, name)
if err != nil {
return nil, err
}
if scope == nil {
// Scope values used that are not understood by an implementation should be ignored.
// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
continue
}
templates[name] = scope.Template
}
return templates, nil
}
// populateScopeTemplates populates the templates for each of the passed scopes.
// Returns a slice of the populated JSON template strings and a bool to indicate
// if a conflict in scope template claims occurred.
func (i *IdentityStore) populateScopeTemplates(ctx context.Context, s logical.Storage, ns *namespace.Namespace, entity *identity.Entity, scopes ...string) ([]string, bool, error) {
// Gather the templates for each scope
templates, err := i.getScopeTemplates(ctx, s, scopes...)
if err != nil {
return nil, false, err
}
// Get the groups for the entity
groups, inheritedGroups, err := i.groupsByEntityID(entity.ID)
if err != nil {
return nil, false, err
}
groups = append(groups, inheritedGroups...)
claimsToScopes := make(map[string]string)
populatedTemplates := make([]string, 0)
for scope, template := range templates {
// Parse and integrate the populated template. Structural errors with the template
// should be caught during configuration. Errors found during runtime will be logged.
_, populatedTemplate, err := identitytpl.PopulateString(identitytpl.PopulateStringInput{
Mode: identitytpl.JSONTemplating,
String: template,
Entity: identity.ToSDKEntity(entity),
Groups: identity.ToSDKGroups(groups),
NamespaceID: ns.ID,
})
if err != nil {
i.Logger().Warn("error populating OIDC token template", "scope", scope,
"template", template, "error", err)
}
if populatedTemplate != "" {
claimsMap := make(map[string]interface{})
if err := json.Unmarshal([]byte(populatedTemplate), &claimsMap); err != nil {
i.Logger().Warn("error parsing OIDC template", "template", template, "err", err)
}
// Check top-level claim keys for conflicts with other scopes
for claimKey := range claimsMap {
if conflictScope, ok := claimsToScopes[claimKey]; ok {
return nil, true, fmt.Errorf("found scopes with conflicting top-level claim: claim %q in scopes %q, %q",
claimKey, scope, conflictScope)
}
claimsToScopes[claimKey] = scope
}
populatedTemplates = append(populatedTemplates, populatedTemplate)
}
}
return populatedTemplates, false, nil
}
// computeHashClaim computes the hash value to be used for the at_hash
// and c_hash claims. For details on how this value is computed and the
// class of attacks it's used to prevent, see the spec at
// - https://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken
// - https://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken
// - https://openid.net/specs/openid-connect-core-1_0.html#TokenSubstitution
func computeHashClaim(alg string, input string) (string, error) {
signatureAlgToHash := map[jose.SignatureAlgorithm]func() hash.Hash{
jose.RS256: sha256.New,
jose.RS384: sha512.New384,
jose.RS512: sha512.New,
jose.ES256: sha256.New,
jose.ES384: sha512.New384,
jose.ES512: sha512.New,
// We use the Ed25519 curve key for EdDSA, which uses
// SHA-512 for its digest algorithm. See details at
// https://bitbucket.org/openid/connect/issues/1125.
jose.EdDSA: sha512.New,
}
newHash, ok := signatureAlgToHash[jose.SignatureAlgorithm(alg)]
if !ok {
return "", fmt.Errorf("unsupported signature algorithm: %q", alg)
}
h := newHash()
// Writing to the hash will never return an error
_, _ = h.Write([]byte(input))
sum := h.Sum(nil)
return base64.RawURLEncoding.EncodeToString(sum[:len(sum)/2]), nil
}
// entityHasAssignment returns true if the entity is enabled and a member of any
// of the assignments' groups or entities. Otherwise, returns false or an error.
func (i *IdentityStore) entityHasAssignment(ctx context.Context, s logical.Storage, entity *identity.Entity, assignments []string) (bool, error) {
if entity.GetDisabled() {
return false, nil
}
// Get the group IDs that the entity is a member of
entityGroups, err := i.MemDBGroupsByMemberEntityID(entityID, true, false)
entityGroups, err := i.MemDBGroupsByMemberEntityID(entity.GetID(), true, false)
if err != nil {
return false, err
}
@ -1560,7 +2180,7 @@ func (i *IdentityStore) entityHasAssignment(ctx context.Context, s logical.Stora
}
// Check if the entity is a member of the assignment's entities
if strutil.StrListContains(assignment.EntityIDs, entityID) {
if strutil.StrListContains(assignment.EntityIDs, entity.GetID()) {
return true, nil
}
}

File diff suppressed because it is too large Load Diff

View File

@ -131,7 +131,8 @@ type GroupUpdater interface {
var _ GroupUpdater = &Core{}
type TokenStorer interface {
LookupToken(ctx context.Context, token string) (*logical.TokenEntry, error)
LookupToken(context.Context, string) (*logical.TokenEntry, error)
CreateToken(context.Context, *logical.TokenEntry) error
}
var _ TokenStorer = &Core{}

View File

@ -207,9 +207,10 @@ func TestSystemBackend_mounts(t *testing.T) {
"accessor": resp.Data["identity/"].(map[string]interface{})["accessor"],
"uuid": resp.Data["identity/"].(map[string]interface{})["uuid"],
"config": map[string]interface{}{
"default_lease_ttl": resp.Data["identity/"].(map[string]interface{})["config"].(map[string]interface{})["default_lease_ttl"].(int64),
"max_lease_ttl": resp.Data["identity/"].(map[string]interface{})["config"].(map[string]interface{})["max_lease_ttl"].(int64),
"force_no_cache": false,
"default_lease_ttl": resp.Data["identity/"].(map[string]interface{})["config"].(map[string]interface{})["default_lease_ttl"].(int64),
"max_lease_ttl": resp.Data["identity/"].(map[string]interface{})["config"].(map[string]interface{})["max_lease_ttl"].(int64),
"force_no_cache": false,
"passthrough_request_headers": []string{"Authorization"},
},
"local": false,
"seal_wrap": false,
@ -308,9 +309,10 @@ func TestSystemBackend_mount(t *testing.T) {
"accessor": resp.Data["identity/"].(map[string]interface{})["accessor"],
"uuid": resp.Data["identity/"].(map[string]interface{})["uuid"],
"config": map[string]interface{}{
"default_lease_ttl": resp.Data["identity/"].(map[string]interface{})["config"].(map[string]interface{})["default_lease_ttl"].(int64),
"max_lease_ttl": resp.Data["identity/"].(map[string]interface{})["config"].(map[string]interface{})["max_lease_ttl"].(int64),
"force_no_cache": false,
"default_lease_ttl": resp.Data["identity/"].(map[string]interface{})["config"].(map[string]interface{})["default_lease_ttl"].(int64),
"max_lease_ttl": resp.Data["identity/"].(map[string]interface{})["config"].(map[string]interface{})["max_lease_ttl"].(int64),
"force_no_cache": false,
"passthrough_request_headers": []string{"Authorization"},
},
"local": false,
"seal_wrap": false,
@ -2476,9 +2478,10 @@ func TestSystemBackend_InternalUIMounts(t *testing.T) {
"accessor": resp.Data["secret"].(map[string]interface{})["identity/"].(map[string]interface{})["accessor"],
"uuid": resp.Data["secret"].(map[string]interface{})["identity/"].(map[string]interface{})["uuid"],
"config": map[string]interface{}{
"default_lease_ttl": resp.Data["secret"].(map[string]interface{})["identity/"].(map[string]interface{})["config"].(map[string]interface{})["default_lease_ttl"].(int64),
"max_lease_ttl": resp.Data["secret"].(map[string]interface{})["identity/"].(map[string]interface{})["config"].(map[string]interface{})["max_lease_ttl"].(int64),
"force_no_cache": false,
"default_lease_ttl": resp.Data["secret"].(map[string]interface{})["identity/"].(map[string]interface{})["config"].(map[string]interface{})["default_lease_ttl"].(int64),
"max_lease_ttl": resp.Data["secret"].(map[string]interface{})["identity/"].(map[string]interface{})["config"].(map[string]interface{})["max_lease_ttl"].(int64),
"force_no_cache": false,
"passthrough_request_headers": []string{"Authorization"},
},
"local": false,
"seal_wrap": false,

View File

@ -1516,6 +1516,9 @@ func (c *Core) requiredMountTable() *MountTable {
UUID: identityUUID,
Accessor: identityAccessor,
BackendAwareUUID: identityBackendUUID,
Config: MountConfig{
PassthroughRequestHeaders: []string{"Authorization"},
},
}
table.Entries = append(table.Entries, cubbyholeMount)

View File

@ -481,6 +481,15 @@ func (c *Core) LookupToken(ctx context.Context, token string) (*logical.TokenEnt
return c.tokenStore.Lookup(ctx, token)
}
// CreateToken creates the given token in the core's token store.
func (c *Core) CreateToken(ctx context.Context, entry *logical.TokenEntry) error {
if c.tokenStore == nil {
return errors.New("unable to create token with nil token store")
}
return c.tokenStore.create(ctx, entry)
}
// TokenStore is used to manage client tokens. Tokens are used for
// clients to authenticate, and each token is mapped to an applicable
// set of policy which is used for authorization.