diff --git a/path_config.go b/path_config.go index c4acf0a1..2347a0c4 100644 --- a/path_config.go +++ b/path_config.go @@ -103,6 +103,10 @@ func pathConfig(b *jwtAuthBackend) *framework.Path { Value: true, }, }, + "acr_values": { + Type: framework.TypeCommaStringSlice, + Description: "Authentication Context Class Reference values for all the authentication requests made with this provider. Addition to possible 'acr_values' of a role. Optional.", + }, }, Operations: map[logical.Operation]framework.OperationHandler{ @@ -204,6 +208,7 @@ func (b *jwtAuthBackend) pathConfigRead(ctx context.Context, req *logical.Reques "bound_issuer": config.BoundIssuer, "provider_config": providerConfig, "namespace_in_state": config.NamespaceInState, + "acr_values": config.ACRValues, }, } @@ -225,6 +230,7 @@ func (b *jwtAuthBackend) pathConfigWrite(ctx context.Context, req *logical.Reque JWTSupportedAlgs: d.Get("jwt_supported_algs").([]string), BoundIssuer: d.Get("bound_issuer").(string), ProviderConfig: d.Get("provider_config").(map[string]interface{}), + ACRValues: d.Get("acr_values").([]string), } // Check if the config already exists, to determine if this is a create or @@ -418,6 +424,7 @@ type jwtConfig struct { DefaultRole string `json:"default_role"` ProviderConfig map[string]interface{} `json:"provider_config"` NamespaceInState bool `json:"namespace_in_state"` + ACRValues []string `json:"acr_values"` ParsedJWTPubKeys []crypto.PublicKey `json:"-"` } diff --git a/path_config_test.go b/path_config_test.go index 474fcc91..6e1dd71a 100644 --- a/path_config_test.go +++ b/path_config_test.go @@ -33,6 +33,7 @@ func TestConfig_JWT_Read(t *testing.T) { "bound_issuer": "http://vault.example.com/", "provider_config": map[string]interface{}{}, "namespace_in_state": false, + "acr_values": []string{}, } req := &logical.Request{ @@ -142,6 +143,7 @@ func TestConfig_JWT_Write(t *testing.T) { BoundIssuer: "http://vault.example.com/", ProviderConfig: map[string]interface{}{}, NamespaceInState: true, + ACRValues: []string{}, } conf, err := b.(*jwtAuthBackend).config(context.Background(), storage) @@ -179,6 +181,7 @@ func TestConfig_JWKS_Update(t *testing.T) { "bound_issuer": "", "provider_config": map[string]interface{}{}, "namespace_in_state": false, + "acr_values": []string{}, } req := &logical.Request{ @@ -354,6 +357,7 @@ func TestConfig_OIDC_Write(t *testing.T) { OIDCClientSecret: "def", ProviderConfig: map[string]interface{}{}, NamespaceInState: true, + ACRValues: []string{}, } conf, err := b.(*jwtAuthBackend).config(context.Background(), storage) @@ -446,6 +450,7 @@ func TestConfig_OIDC_Write_ProviderConfig(t *testing.T) { "extraOptions": "abound", }, NamespaceInState: true, + ACRValues: []string{}, } conf, err := b.(*jwtAuthBackend).config(context.Background(), storage) @@ -503,6 +508,7 @@ func TestConfig_OIDC_Write_ProviderConfig(t *testing.T) { OIDCDiscoveryURL: "https://team-vault.auth0.com/", ProviderConfig: map[string]interface{}{}, NamespaceInState: true, + ACRValues: []string{}, } conf, err := b.(*jwtAuthBackend).config(context.Background(), storage) @@ -533,6 +539,7 @@ func TestConfig_OIDC_Create_Namespace(t *testing.T) { JWTSupportedAlgs: []string{}, JWTValidationPubKeys: []string{}, ProviderConfig: map[string]interface{}{}, + ACRValues: []string{}, }, }, "namespace_in_state true": { @@ -547,6 +554,7 @@ func TestConfig_OIDC_Create_Namespace(t *testing.T) { JWTSupportedAlgs: []string{}, JWTValidationPubKeys: []string{}, ProviderConfig: map[string]interface{}{}, + ACRValues: []string{}, }, }, "namespace_in_state false": { @@ -561,6 +569,7 @@ func TestConfig_OIDC_Create_Namespace(t *testing.T) { JWTSupportedAlgs: []string{}, JWTValidationPubKeys: []string{}, ProviderConfig: map[string]interface{}{}, + ACRValues: []string{}, }, }, } @@ -609,6 +618,7 @@ func TestConfig_OIDC_Update_Namespace(t *testing.T) { JWTSupportedAlgs: []string{}, JWTValidationPubKeys: []string{}, ProviderConfig: map[string]interface{}{}, + ACRValues: []string{}, }, }, "existing false, update something else": { @@ -628,6 +638,7 @@ func TestConfig_OIDC_Update_Namespace(t *testing.T) { JWTSupportedAlgs: []string{}, JWTValidationPubKeys: []string{}, ProviderConfig: map[string]interface{}{}, + ACRValues: []string{}, }, }, "existing true, update to false": { @@ -646,6 +657,7 @@ func TestConfig_OIDC_Update_Namespace(t *testing.T) { JWTSupportedAlgs: []string{}, JWTValidationPubKeys: []string{}, ProviderConfig: map[string]interface{}{}, + ACRValues: []string{}, }, }, "existing true, update something else": { @@ -665,6 +677,7 @@ func TestConfig_OIDC_Update_Namespace(t *testing.T) { JWTSupportedAlgs: []string{}, JWTValidationPubKeys: []string{}, ProviderConfig: map[string]interface{}{}, + ACRValues: []string{}, }, }, } diff --git a/path_oidc.go b/path_oidc.go index d793d29c..f9602761 100644 --- a/path_oidc.go +++ b/path_oidc.go @@ -493,6 +493,18 @@ func (b *jwtAuthBackend) createOIDCRequest(config *jwtConfig, role *jwtRole, rol options = append(options, oidc.WithMaxAge(uint(role.MaxAge.Seconds()))) } + acrValues := []string{} + + if len(role.ACRValues) > 0 { + acrValues = append(acrValues, role.ACRValues...) + } + if len(config.ACRValues) > 0 { + acrValues = append(acrValues, config.ACRValues...) + } + if len(acrValues) > 0 { + options = append(options, oidc.WithACRValues(strings.Join(acrValues[:], " "))) + } + request, err := oidc.NewRequest(oidcRequestTimeout, redirectURI, options...) if err != nil { return nil, err diff --git a/path_oidc_test.go b/path_oidc_test.go index e1f807e7..9183ff53 100644 --- a/path_oidc_test.go +++ b/path_oidc_test.go @@ -1638,3 +1638,125 @@ func TestParseMount(t *testing.T) { t.Fatalf("unexpected result: %s", result) } } + +// The acr_values parameter refers to authentication context class reference. +func TestOIDC_AuthURL_acr_values(t *testing.T) { + b, storage := getBackend(t) + + // Configure the backend without any ACRs, will be added later in the tests + req := &logical.Request{ + Operation: logical.UpdateOperation, + Path: configPath, + Storage: storage, + Data: map[string]interface{}{ + "oidc_discovery_url": "https://team-vault.auth0.com/", + "oidc_client_id": "abc", + "oidc_client_secret": "def", + }, + } + resp, err := b.HandleRequest(context.Background(), req) + require.NoError(t, err) + require.False(t, resp.IsError()) + + // Configure the role without any ACRs, will be added later in the tests + req = &logical.Request{ + Operation: logical.CreateOperation, + Path: "role/test", + Storage: storage, + Data: map[string]interface{}{ + "user_claim": "email", + "allowed_redirect_uris": []string{"https://example.com"}, + }, + } + resp, err = b.HandleRequest(context.Background(), req) + require.NoError(t, err) + require.False(t, resp.IsError()) + + tests := map[string]struct { + config_acr_values []string + role_acr_values []string + expectedAcrValue string + shouldExist bool + }{ + "auth URL with acr_values for role": { + role_acr_values: []string{"role_acr1", "role_acr2"}, + config_acr_values: []string{}, + expectedAcrValue: "role_acr1 role_acr2", + shouldExist: true, + }, + "auth URL with acr_values for config": { + role_acr_values: []string{}, + config_acr_values: []string{"config_acr1", "config_acr2"}, + expectedAcrValue: "config_acr1 config_acr2", + shouldExist: true, + }, + "auth URL with acr_values for both role and config": { + role_acr_values: []string{"role_acr1", "role_acr2"}, + config_acr_values: []string{"config_acr1", "config_acr2"}, + expectedAcrValue: "role_acr1 role_acr2 config_acr1 config_acr2", + shouldExist: true, + }, + "auth URL for empty role acr_values": { + role_acr_values: []string{}, + config_acr_values: []string{}, + shouldExist: false, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + + req = &logical.Request{ + Operation: logical.UpdateOperation, + Path: configPath, + Storage: storage, + Data: map[string]interface{}{ + "oidc_discovery_url": "https://team-vault.auth0.com/", + "oidc_client_id": "abc", + "oidc_client_secret": "def", + "acr_values": tt.config_acr_values, + }, + } + resp, err = b.HandleRequest(context.Background(), req) + require.NoError(t, err) + require.False(t, resp.IsError()) + + req = &logical.Request{ + Operation: logical.UpdateOperation, + Path: "role/test", + Storage: storage, + Data: map[string]interface{}{ + "user_claim": "email", + "allowed_redirect_uris": []string{"https://example.com"}, + "acr_values": tt.role_acr_values, + }, + } + resp, err = b.HandleRequest(context.Background(), req) + require.NoError(t, err) + require.False(t, resp.IsError()) + + // Request for generation of an auth URL + req = &logical.Request{ + Operation: logical.UpdateOperation, + Path: "oidc/auth_url", + Storage: storage, + Data: map[string]interface{}{ + "role": "test", + "redirect_uri": "https://example.com", + }, + } + resp, err = b.HandleRequest(context.Background(), req) + require.NoError(t, err) + require.False(t, resp.IsError()) + + // Parse the auth URL and assert the expected acr_values query parameter + parsedAuthURL, err := url.Parse(resp.Data["auth_url"].(string)) + require.NoError(t, err) + queryParams := parsedAuthURL.Query() + if tt.shouldExist { + assert.Equal(t, tt.expectedAcrValue, queryParams.Get("acr_values")) + } else { + assert.Empty(t, queryParams.Get("acr_values")) + } + }) + } +} diff --git a/path_role.go b/path_role.go index f94c03cf..9f44f8bf 100644 --- a/path_role.go +++ b/path_role.go @@ -165,6 +165,10 @@ in OIDC responses.`, Description: `Specifies the allowable elapsed time in seconds since the last time the user was actively authenticated.`, }, + "acr_values": { + Type: framework.TypeCommaStringSlice, + Description: `Specifies the Authentication Context Class Reference values for the authentication request made for this role. Addition to possible 'acr_values' of global config. Optional.`, + }, }, ExistenceCheck: b.pathRoleExistenceCheck, Operations: map[logical.Operation]framework.OperationHandler{ @@ -225,6 +229,7 @@ type jwtRole struct { VerboseOIDCLogging bool `json:"verbose_oidc_logging"` MaxAge time.Duration `json:"max_age"` UserClaimJSONPointer bool `json:"user_claim_json_pointer"` + ACRValues []string `json:"acr_values"` // Deprecated by TokenParams Policies []string `json:"policies"` @@ -333,6 +338,7 @@ func (b *jwtAuthBackend) pathRoleRead(ctx context.Context, req *logical.Request, "oidc_scopes": role.OIDCScopes, "verbose_oidc_logging": role.VerboseOIDCLogging, "max_age": int64(role.MaxAge.Seconds()), + "acr_values": role.ACRValues, } role.PopulateTokenData(d) @@ -470,6 +476,10 @@ func (b *jwtAuthBackend) pathRoleCreateUpdate(ctx context.Context, req *logical. role.MaxAge = time.Duration(maxAgeRaw.(int)) * time.Second } + if acrValues, ok := data.GetOk("acr_values"); ok { + role.ACRValues = acrValues.([]string) + } + boundClaimsType := data.Get("bound_claims_type").(string) switch boundClaimsType { case boundClaimsTypeString, boundClaimsTypeGlob: diff --git a/path_role_test.go b/path_role_test.go index 5628433f..0740156f 100644 --- a/path_role_test.go +++ b/path_role_test.go @@ -92,6 +92,7 @@ func TestPath_Create(t *testing.T) { BoundCIDRs: []*sockaddr.SockAddrMarshaler{{SockAddr: expectedSockAddr}}, AllowedRedirectURIs: []string(nil), MaxAge: 60 * time.Second, + ACRValues: []string(nil), } req := &logical.Request{ @@ -792,6 +793,7 @@ func TestPath_Read(t *testing.T) { "token_no_default_policy": false, "token_explicit_max_ttl": int64(0), "max_age": int64(0), + "acr_values": []string(nil), } req := &logical.Request{