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

Allow setting a token name template on auth methods #19135

Merged
merged 10 commits into from
Nov 28, 2023
3 changes: 3 additions & 0 deletions .changelog/19135.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:improvement
sso: Allow adding a token name format to auth methods which can be used to generate token names when signing in via SSO
```
3 changes: 3 additions & 0 deletions api/acl.go
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,9 @@ type ACLAuthMethod struct {
// ACLAuthMethodTokenLocalityGlobal for convenience.
TokenLocality string

// TokenNameFormat defines the HIL template to use when building the token name
TokenNameFormat string

// MaxTokenTTL is the maximum life of a token created by this method.
MaxTokenTTL time.Duration

Expand Down
1 change: 1 addition & 0 deletions command/acl_auth_method.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ func formatAuthMethod(authMethod *api.ACLAuthMethod) string {
fmt.Sprintf("Type|%s", authMethod.Type),
fmt.Sprintf("Locality|%s", authMethod.TokenLocality),
fmt.Sprintf("Max Token TTL|%s", authMethod.MaxTokenTTL.String()),
fmt.Sprintf("Token Name Format|%s", authMethod.TokenNameFormat),
fmt.Sprintf("Default|%t", authMethod.Default),
fmt.Sprintf("Create Index|%d", authMethod.CreateIndex),
fmt.Sprintf("Modify Index|%d", authMethod.ModifyIndex),
Expand Down
52 changes: 30 additions & 22 deletions command/acl_auth_method_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@ var _ cli.Command = &ACLAuthMethodCreateCommand{}
type ACLAuthMethodCreateCommand struct {
Meta

name string
methodType string
tokenLocality string
maxTokenTTL time.Duration
isDefault bool
config string
json bool
tmpl string
name string
methodType string
tokenLocality string
tokenNameFormat string
maxTokenTTL time.Duration
isDefault bool
config string
json bool
tmpl string

testStdin io.Reader
}
Expand Down Expand Up @@ -63,6 +64,10 @@ ACL Auth Method Create Options:
Defines the kind of token that this auth method should produce. This can be
either 'local' or 'global'.

-token-name-format
Sets the token format for the authenticated users. This can be lightly templated
using HIL '${foo}' syntax. Defaults to '${auth_method_type}-${auth_method_name}'

-default
Specifies whether this auth method should be treated as a default one in
case no auth method is explicitly specified for a login command.
Expand All @@ -84,14 +89,15 @@ ACL Auth Method Create Options:
func (a *ACLAuthMethodCreateCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(a.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-name": complete.PredictAnything,
"-type": complete.PredictSet("OIDC", "JWT"),
"-max-token-ttl": complete.PredictAnything,
"-token-locality": complete.PredictSet("local", "global"),
"-default": complete.PredictSet("true", "false"),
"-config": complete.PredictNothing,
"-json": complete.PredictNothing,
"-t": complete.PredictAnything,
"-name": complete.PredictAnything,
"-type": complete.PredictSet("OIDC", "JWT"),
"-max-token-ttl": complete.PredictAnything,
"-token-locality": complete.PredictSet("local", "global"),
"-token-name-format": complete.PredictNothing,
"-default": complete.PredictSet("true", "false"),
"-config": complete.PredictNothing,
"-json": complete.PredictNothing,
"-t": complete.PredictAnything,
})
}

Expand All @@ -113,6 +119,7 @@ func (a *ACLAuthMethodCreateCommand) Run(args []string) int {
flags.StringVar(&a.name, "name", "", "")
flags.StringVar(&a.methodType, "type", "", "")
flags.StringVar(&a.tokenLocality, "token-locality", "", "")
flags.StringVar(&a.tokenNameFormat, "token-name-format", "", "")
flags.DurationVar(&a.maxTokenTTL, "max-token-ttl", 0, "")
flags.BoolVar(&a.isDefault, "default", false, "")
flags.StringVar(&a.config, "config", "", "")
Expand Down Expand Up @@ -166,12 +173,13 @@ func (a *ACLAuthMethodCreateCommand) Run(args []string) int {

// Set up the auth method with the passed parameters.
authMethod := api.ACLAuthMethod{
Name: a.name,
Type: strings.ToUpper(a.methodType),
TokenLocality: a.tokenLocality,
MaxTokenTTL: a.maxTokenTTL,
Default: a.isDefault,
Config: &configJSON,
Name: a.name,
Type: strings.ToUpper(a.methodType),
TokenLocality: a.tokenLocality,
TokenNameFormat: a.tokenNameFormat,
MaxTokenTTL: a.maxTokenTTL,
Default: a.isDefault,
Config: &configJSON,
}

// Get the HTTP client.
Expand Down
41 changes: 26 additions & 15 deletions command/acl_auth_method_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,14 @@ var _ cli.Command = &ACLAuthMethodUpdateCommand{}
type ACLAuthMethodUpdateCommand struct {
Meta

methodType string
tokenLocality string
maxTokenTTL time.Duration
isDefault bool
config string
json bool
tmpl string
methodType string
tokenLocality string
tokenNameFormat string
maxTokenTTL time.Duration
isDefault bool
config string
json bool
tmpl string

testStdin io.Reader
}
Expand Down Expand Up @@ -59,6 +60,10 @@ ACL Auth Method Update Options:
Updates the kind of token that this auth method should produce. This can be
either 'local' or 'global'.

-token-name-format
Sets the token format for the authenticated users. This can be lightly templated
using HIL '${foo}' syntax. Defaults to '${auth_method_type}-${auth_method_name}'

-default
Specifies whether this auth method should be treated as a default one in
case no auth method is explicitly specified for a login command.
Expand All @@ -81,13 +86,14 @@ ACL Auth Method Update Options:
func (a *ACLAuthMethodUpdateCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(a.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-type": complete.PredictSet("OIDC", "JWT"),
"-max-token-ttl": complete.PredictAnything,
"-token-locality": complete.PredictSet("local", "global"),
"-default": complete.PredictSet("true", "false"),
"-config": complete.PredictNothing,
"-json": complete.PredictNothing,
"-t": complete.PredictAnything,
"-type": complete.PredictSet("OIDC", "JWT"),
"-max-token-ttl": complete.PredictAnything,
"-token-locality": complete.PredictSet("local", "global"),
"-token-name-format": complete.PredictNothing,
"-default": complete.PredictSet("true", "false"),
"-config": complete.PredictNothing,
"-json": complete.PredictNothing,
"-t": complete.PredictAnything,
})
}

Expand All @@ -108,6 +114,7 @@ func (a *ACLAuthMethodUpdateCommand) Run(args []string) int {
flags.Usage = func() { a.Ui.Output(a.Help()) }
flags.StringVar(&a.methodType, "type", "", "")
flags.StringVar(&a.tokenLocality, "token-locality", "", "")
flags.StringVar(&a.tokenNameFormat, "token-name-format", "", "")
flags.DurationVar(&a.maxTokenTTL, "max-token-ttl", 0, "")
flags.StringVar(&a.config, "config", "", "")
flags.BoolVar(&a.isDefault, "default", false, "")
Expand Down Expand Up @@ -142,7 +149,7 @@ func (a *ACLAuthMethodUpdateCommand) Run(args []string) int {

// Check if any command-specific flags were set
setFlags := []string{}
for _, f := range []string{"type", "token-locality", "max-token-ttl", "config", "default"} {
for _, f := range []string{"type", "token-locality", "token-name-format", "max-token-ttl", "config", "default"} {
if flagPassed(flags, f) {
setFlags = append(setFlags, f)
}
Expand All @@ -162,6 +169,10 @@ func (a *ACLAuthMethodUpdateCommand) Run(args []string) int {
updatedMethod.TokenLocality = a.tokenLocality
}

if slices.Contains(setFlags, "token-name-format") {
updatedMethod.TokenNameFormat = a.tokenNameFormat
}

if slices.Contains(setFlags, "type") {
if !slices.Contains([]string{"OIDC", "JWT"}, strings.ToUpper(a.methodType)) {
a.Ui.Error("ACL auth method type must be set to 'OIDC' or 'JWT'")
Expand Down
6 changes: 3 additions & 3 deletions lib/auth/binder.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ func (b *Binder) Bind(authMethod *structs.ACLAuthMethod, identity *Identity) (*B
// - If the computed name is not valid for the type ("INVALID_NAME", false, nil) is returned.
// - If the computed name is valid for the type ("VALID_NAME", true, nil) is returned.
func computeBindName(bindType, bindName string, claimMappings map[string]string) (string, bool, error) {
bindName, err := interpolateHIL(bindName, claimMappings, true)
bindName, err := InterpolateHIL(bindName, claimMappings, true)
if err != nil {
return "", false, err
}
Expand Down Expand Up @@ -170,9 +170,9 @@ func doesSelectorMatch(selector string, selectableVars interface{}) bool {
return result
}

// interpolateHIL processes the string as if it were HIL and interpolates only
// InterpolateHIL processes the string as if it were HIL and interpolates only
// the provided string->string map as possible variables.
func interpolateHIL(s string, vars map[string]string, lowercase bool) (string, error) {
func InterpolateHIL(s string, vars map[string]string, lowercase bool) (string, error) {
if !strings.Contains(s, "${") {
// Skip going to the trouble of parsing something that has no HIL.
return s, nil
Expand Down
34 changes: 32 additions & 2 deletions nomad/acl_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -2769,8 +2769,13 @@ func (a *ACL) OIDCCompleteAuth(
// logic, so we do not want to call Raft directly or copy that here. In the
// future we should try and extract out the logic into an interface, or at
// least a separate function.
name, err := formatTokenName(authMethod.TokenNameFormat, structs.ACLAuthMethodTypeOIDC, authMethod.Name, oidcInternalClaims.Value)
if err != nil {
return err
}

token := structs.ACLToken{
Name: "OIDC-" + authMethod.Name,
Name: name,
Global: authMethod.TokenLocalityIsGlobal(),
ExpirationTTL: authMethod.MaxTokenTTL,
}
Expand Down Expand Up @@ -2917,8 +2922,13 @@ func (a *ACL) Login(args *structs.ACLLoginRequest, reply *structs.ACLLoginRespon
// logic, so we do not want to call Raft directly or copy that here. In the
// future we should try and extract out the logic into an interface, or at
// least a separate function.
name, err := formatTokenName(authMethod.TokenNameFormat, structs.ACLAuthMethodTypeJWT, authMethod.Name, jwtClaims.Value)
if err != nil {
return err
}

token := structs.ACLToken{
Name: "JWT-" + authMethod.Name,
Name: name,
Global: authMethod.TokenLocalityIsGlobal(),
ExpirationTTL: authMethod.MaxTokenTTL,
}
Expand Down Expand Up @@ -2952,3 +2962,23 @@ func (a *ACL) Login(args *structs.ACLLoginRequest, reply *structs.ACLLoginRespon

return nil
}

func formatTokenName(format, authType, authName string, claims map[string]string) (string, error) {
claimMappings := map[string]string{
"auth_method_type": authType,
"auth_method_name": authName,
}
for k, v := range claims {
claimMappings["value."+k] = v
}

if format == "" {
format = structs.DefaultACLAuthMethodTokenNameFormat
}
tokenName, err := auth.InterpolateHIL(format, claimMappings, false)
if err != nil {
return "", fmt.Errorf("failed to generate ACL token name: %w", err)
}

return tokenName, nil
}
27 changes: 25 additions & 2 deletions nomad/acl_endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3764,12 +3764,14 @@ func TestACL_Login(t *testing.T) {
iat := time.Now().Unix()
nbf := time.Now().Unix()
exp := time.Now().Add(time.Hour).Unix()
user := "John"
testToken, testPubKey, err := mock.SampleJWTokenWithKeys(jwt.MapClaims{
"http://nomad.internal/policies": []string{"engineering"},
"http://nomad.internal/roles": []string{"engineering"},
"iat": iat,
"nbf": nbf,
"exp": exp,
"sub": user,
"iss": "nomad test suite",
"aud": []string{"sales", "engineering"},
}, nil)
Expand Down Expand Up @@ -3810,7 +3812,9 @@ func TestACL_Login(t *testing.T) {
mockedAuthMethod.Config.BoundIssuer = []string{"nomad test suite"}
mockedAuthMethod.Config.ExpirationLeeway = time.Duration(3600)
mockedAuthMethod.Config.ClockSkewLeeway = time.Duration(3600)
mockedAuthMethod.Config.ClaimMappings = map[string]string{}
mockedAuthMethod.Config.ClaimMappings = map[string]string{
"sub": "user",
}
mockedAuthMethod.Config.ListClaimMappings = map[string]string{
"http://nomad.internal/roles": "roles",
"http://nomad.internal/policies": "policies",
Expand Down Expand Up @@ -3877,6 +3881,7 @@ func TestACL_Login(t *testing.T) {
must.Len(t, 1, completeAuthResp4.ACLToken.Roles)
must.Eq(t, mockACLRole.Name, completeAuthResp4.ACLToken.Roles[0].Name)
must.Eq(t, mockACLRole.ID, completeAuthResp4.ACLToken.Roles[0].ID)
must.Eq(t, mockedAuthMethod.Type+"-"+mockedAuthMethod.Name, completeAuthResp4.ACLToken.Name)

// Create a binding rule which generates management tokens. This should
// override the other rules, giving us a management token when we next
Expand All @@ -3901,8 +3906,26 @@ func TestACL_Login(t *testing.T) {
var completeAuthResp5 structs.ACLLoginResponse
err = msgpackrpc.CallWithCodec(codec, structs.ACLLoginRPCMethod, &loginReq5, &completeAuthResp5)
must.NoError(t, err)
must.NotNil(t, completeAuthResp4.ACLToken)
must.NotNil(t, completeAuthResp5.ACLToken)
must.Len(t, 0, completeAuthResp5.ACLToken.Policies)
must.Len(t, 0, completeAuthResp5.ACLToken.Roles)
must.Eq(t, structs.ACLManagementToken, completeAuthResp5.ACLToken.Type)

// Change the token name format
mockedAuthMethod.TokenNameFormat = "${auth_method_type}-${auth_method_name}-${value.user}"
must.NoError(t, testServer.fsm.State().UpsertACLAuthMethods(60, []*structs.ACLAuthMethod{mockedAuthMethod}))

loginReq6 := structs.ACLLoginRequest{
AuthMethodName: mockedAuthMethod.Name,
LoginToken: testToken,
WriteRequest: structs.WriteRequest{
Region: DefaultRegion,
},
}

var completeAuthResp6 structs.ACLLoginResponse
err = msgpackrpc.CallWithCodec(codec, structs.ACLLoginRPCMethod, &loginReq6, &completeAuthResp6)
must.NoError(t, err)
must.NotNil(t, completeAuthResp6.ACLToken)
must.Eq(t, mockedAuthMethod.Type+"-"+mockedAuthMethod.Name+"-"+user, completeAuthResp6.ACLToken.Name)
}
21 changes: 15 additions & 6 deletions nomad/structs/acl.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ const (
// ACLAuthMethodTypeJWT the ACLAuthMethod.Type and represents an auth-method
// which uses the JWT type.
ACLAuthMethodTypeJWT = "JWT"

DefaultACLAuthMethodTokenNameFormat = "${auth_method_type}-${auth_method_name}"
)

var (
Expand Down Expand Up @@ -742,12 +744,13 @@ type ACLRoleByNameResponse struct {
// ACLAuthMethod is used to capture the properties of an authentication method
// used for single sing-on
type ACLAuthMethod struct {
Name string
Type string
TokenLocality string // is the token valid locally or globally?
MaxTokenTTL time.Duration
Default bool
Config *ACLAuthMethodConfig
Name string
Type string
TokenLocality string // is the token valid locally or globally?
TokenNameFormat string
MaxTokenTTL time.Duration
Default bool
Config *ACLAuthMethodConfig

Hash []byte

Expand All @@ -771,6 +774,7 @@ func (a *ACLAuthMethod) SetHash() []byte {
_, _ = hash.Write([]byte(a.Name))
_, _ = hash.Write([]byte(a.Type))
_, _ = hash.Write([]byte(a.TokenLocality))
_, _ = hash.Write([]byte(a.TokenNameFormat))
_, _ = hash.Write([]byte(a.MaxTokenTTL.String()))
_, _ = hash.Write([]byte(strconv.FormatBool(a.Default)))

Expand Down Expand Up @@ -900,6 +904,10 @@ func (a *ACLAuthMethod) Canonicalize() {
a.CreateTime = t
}
a.ModifyTime = t

if a.TokenNameFormat == "" {
a.TokenNameFormat = DefaultACLAuthMethodTokenNameFormat
}
}

// Merge merges auth method a with method b. It sets all required empty fields
Expand All @@ -909,6 +917,7 @@ func (a *ACLAuthMethod) Merge(b *ACLAuthMethod) {
if b != nil {
a.Type = helper.Merge(a.Type, b.Type)
a.TokenLocality = helper.Merge(a.TokenLocality, b.TokenLocality)
a.TokenNameFormat = helper.Merge(a.TokenNameFormat, b.TokenNameFormat)
jorgemarey marked this conversation as resolved.
Show resolved Hide resolved
a.MaxTokenTTL = helper.Merge(a.MaxTokenTTL, b.MaxTokenTTL)
a.Config = helper.Merge(a.Config, b.Config)
}
Expand Down
Loading