1package login
2
3import (
4	"io/ioutil"
5	"os"
6	"path/filepath"
7	"strings"
8	"testing"
9	"time"
10
11	"github.com/hashicorp/consul/agent"
12	"github.com/hashicorp/consul/agent/consul/authmethod/kubeauth"
13	"github.com/hashicorp/consul/agent/consul/authmethod/testauth"
14	"github.com/hashicorp/consul/api"
15	"github.com/hashicorp/consul/command/acl"
16	"github.com/hashicorp/consul/internal/go-sso/oidcauth/oidcauthtest"
17	"github.com/hashicorp/consul/sdk/freeport"
18	"github.com/hashicorp/consul/sdk/testutil"
19	"github.com/hashicorp/consul/testrpc"
20	"github.com/mitchellh/cli"
21	"github.com/stretchr/testify/require"
22	"gopkg.in/square/go-jose.v2/jwt"
23)
24
25func TestLoginCommand_noTabs(t *testing.T) {
26	t.Parallel()
27
28	if strings.ContainsRune(New(cli.NewMockUi()).Help(), '\t') {
29		t.Fatal("help has tabs")
30	}
31}
32
33func TestLoginCommand(t *testing.T) {
34	if testing.Short() {
35		t.Skip("too slow for testing.Short")
36	}
37
38	t.Parallel()
39
40	testDir := testutil.TempDir(t, "acl")
41
42	a := agent.NewTestAgent(t, `
43	primary_datacenter = "dc1"
44	acl {
45		enabled = true
46		tokens {
47			master = "root"
48		}
49	}`)
50
51	defer a.Shutdown()
52	testrpc.WaitForLeader(t, a.RPC, "dc1")
53
54	client := a.Client()
55
56	t.Run("method is required", func(t *testing.T) {
57		ui := cli.NewMockUi()
58		cmd := New(ui)
59
60		args := []string{
61			"-http-addr=" + a.HTTPAddr(),
62			"-token=root",
63		}
64
65		code := cmd.Run(args)
66		require.Equal(t, code, 1, "err: %s", ui.ErrorWriter.String())
67		require.Contains(t, ui.ErrorWriter.String(), "Missing required '-method' flag")
68	})
69
70	tokenSinkFile := filepath.Join(testDir, "test.token")
71
72	t.Run("token-sink-file is required", func(t *testing.T) {
73		ui := cli.NewMockUi()
74		cmd := New(ui)
75
76		args := []string{
77			"-http-addr=" + a.HTTPAddr(),
78			"-token=root",
79			"-method=test",
80		}
81
82		code := cmd.Run(args)
83		require.Equal(t, code, 1, "err: %s", ui.ErrorWriter.String())
84		require.Contains(t, ui.ErrorWriter.String(), "Missing required '-token-sink-file' flag")
85	})
86
87	t.Run("bearer-token-file is required", func(t *testing.T) {
88		defer os.Remove(tokenSinkFile)
89
90		ui := cli.NewMockUi()
91		cmd := New(ui)
92
93		args := []string{
94			"-http-addr=" + a.HTTPAddr(),
95			"-token=root",
96			"-method=test",
97			"-token-sink-file", tokenSinkFile,
98		}
99
100		code := cmd.Run(args)
101		require.Equal(t, code, 1, "err: %s", ui.ErrorWriter.String())
102		require.Contains(t, ui.ErrorWriter.String(), "Missing required '-bearer-token-file' flag")
103	})
104
105	bearerTokenFile := filepath.Join(testDir, "bearer.token")
106
107	t.Run("bearer-token-file is empty", func(t *testing.T) {
108		defer os.Remove(tokenSinkFile)
109
110		require.NoError(t, ioutil.WriteFile(bearerTokenFile, []byte(""), 0600))
111
112		ui := cli.NewMockUi()
113		cmd := New(ui)
114
115		args := []string{
116			"-http-addr=" + a.HTTPAddr(),
117			"-token=root",
118			"-method=test",
119			"-token-sink-file", tokenSinkFile,
120			"-bearer-token-file", bearerTokenFile,
121		}
122
123		code := cmd.Run(args)
124		require.Equal(t, code, 1, "err: %s", ui.ErrorWriter.String())
125		require.Contains(t, ui.ErrorWriter.String(), "No bearer token found in")
126	})
127
128	require.NoError(t, ioutil.WriteFile(bearerTokenFile, []byte("demo-token"), 0600))
129
130	t.Run("try login with no method configured", func(t *testing.T) {
131		defer os.Remove(tokenSinkFile)
132
133		ui := cli.NewMockUi()
134		cmd := New(ui)
135
136		args := []string{
137			"-http-addr=" + a.HTTPAddr(),
138			"-token=root",
139			"-method=test",
140			"-token-sink-file", tokenSinkFile,
141			"-bearer-token-file", bearerTokenFile,
142		}
143
144		code := cmd.Run(args)
145		require.Equal(t, code, 1, "err: %s", ui.ErrorWriter.String())
146		require.Contains(t, ui.ErrorWriter.String(), "403 (ACL not found: auth method \"test\" not found")
147	})
148
149	testSessionID := testauth.StartSession()
150	defer testauth.ResetSession(testSessionID)
151
152	testauth.InstallSessionToken(
153		testSessionID,
154		"demo-token",
155		"default", "demo", "76091af4-4b56-11e9-ac4b-708b11801cbe",
156	)
157
158	{
159		_, _, err := client.ACL().AuthMethodCreate(
160			&api.ACLAuthMethod{
161				Name: "test",
162				Type: "testing",
163				Config: map[string]interface{}{
164					"SessionID": testSessionID,
165				},
166			},
167			&api.WriteOptions{Token: "root"},
168		)
169		require.NoError(t, err)
170	}
171
172	t.Run("try login with method configured but no binding rules", func(t *testing.T) {
173		defer os.Remove(tokenSinkFile)
174
175		ui := cli.NewMockUi()
176		cmd := New(ui)
177
178		args := []string{
179			"-http-addr=" + a.HTTPAddr(),
180			"-token=root",
181			"-method=test",
182			"-token-sink-file", tokenSinkFile,
183			"-bearer-token-file", bearerTokenFile,
184		}
185
186		code := cmd.Run(args)
187		require.Equal(t, 1, code, "err: %s", ui.ErrorWriter.String())
188		require.Contains(t, ui.ErrorWriter.String(), "403 (Permission denied)")
189	})
190
191	{
192		_, _, err := client.ACL().BindingRuleCreate(&api.ACLBindingRule{
193			AuthMethod: "test",
194			BindType:   api.BindingRuleBindTypeService,
195			BindName:   "${serviceaccount.name}",
196			Selector:   "serviceaccount.namespace==default",
197		},
198			&api.WriteOptions{Token: "root"},
199		)
200		require.NoError(t, err)
201	}
202
203	t.Run("try login with method configured and binding rules", func(t *testing.T) {
204		defer os.Remove(tokenSinkFile)
205
206		ui := cli.NewMockUi()
207		cmd := New(ui)
208
209		args := []string{
210			"-http-addr=" + a.HTTPAddr(),
211			"-token=root",
212			"-method=test",
213			"-token-sink-file", tokenSinkFile,
214			"-bearer-token-file", bearerTokenFile,
215		}
216
217		code := cmd.Run(args)
218		require.Equal(t, 0, code, "err: %s", ui.ErrorWriter.String())
219		require.Empty(t, ui.ErrorWriter.String())
220		require.Empty(t, ui.OutputWriter.String())
221
222		raw, err := ioutil.ReadFile(tokenSinkFile)
223		require.NoError(t, err)
224
225		token := strings.TrimSpace(string(raw))
226		require.Len(t, token, 36, "must be a valid uid: %s", token)
227	})
228}
229
230func TestLoginCommand_k8s(t *testing.T) {
231	if testing.Short() {
232		t.Skip("too slow for testing.Short")
233	}
234
235	t.Parallel()
236
237	testDir := testutil.TempDir(t, "acl")
238
239	a := agent.NewTestAgent(t, `
240	primary_datacenter = "dc1"
241	acl {
242		enabled = true
243		tokens {
244			master = "root"
245		}
246	}`)
247
248	defer a.Shutdown()
249	testrpc.WaitForLeader(t, a.RPC, "dc1")
250
251	client := a.Client()
252
253	tokenSinkFile := filepath.Join(testDir, "test.token")
254	bearerTokenFile := filepath.Join(testDir, "bearer.token")
255
256	// the "B" jwt will be the one being reviewed
257	require.NoError(t, ioutil.WriteFile(bearerTokenFile, []byte(acl.TestKubernetesJWT_B), 0600))
258
259	// spin up a fake api server
260	testSrv := kubeauth.StartTestAPIServer(t)
261	defer testSrv.Stop()
262
263	testSrv.AuthorizeJWT(acl.TestKubernetesJWT_A)
264	testSrv.SetAllowedServiceAccount(
265		"default",
266		"demo",
267		"76091af4-4b56-11e9-ac4b-708b11801cbe",
268		"",
269		acl.TestKubernetesJWT_B,
270	)
271
272	{
273		_, _, err := client.ACL().AuthMethodCreate(
274			&api.ACLAuthMethod{
275				Name: "k8s",
276				Type: "kubernetes",
277				Config: map[string]interface{}{
278					"Host":   testSrv.Addr(),
279					"CACert": testSrv.CACert(),
280					// the "A" jwt will be the one with token review privs
281					"ServiceAccountJWT": acl.TestKubernetesJWT_A,
282				},
283			},
284			&api.WriteOptions{Token: "root"},
285		)
286		require.NoError(t, err)
287	}
288
289	{
290		_, _, err := client.ACL().BindingRuleCreate(&api.ACLBindingRule{
291			AuthMethod: "k8s",
292			BindType:   api.BindingRuleBindTypeService,
293			BindName:   "${serviceaccount.name}",
294			Selector:   "serviceaccount.namespace==default",
295		},
296			&api.WriteOptions{Token: "root"},
297		)
298		require.NoError(t, err)
299	}
300
301	t.Run("try login with method configured and binding rules", func(t *testing.T) {
302		defer os.Remove(tokenSinkFile)
303
304		ui := cli.NewMockUi()
305		cmd := New(ui)
306
307		args := []string{
308			"-http-addr=" + a.HTTPAddr(),
309			"-token=root",
310			"-method=k8s",
311			"-token-sink-file", tokenSinkFile,
312			"-bearer-token-file", bearerTokenFile,
313		}
314
315		code := cmd.Run(args)
316		require.Equal(t, 0, code, "err: %s", ui.ErrorWriter.String())
317		require.Empty(t, ui.ErrorWriter.String())
318		require.Empty(t, ui.OutputWriter.String())
319
320		raw, err := ioutil.ReadFile(tokenSinkFile)
321		require.NoError(t, err)
322
323		token := strings.TrimSpace(string(raw))
324		require.Len(t, token, 36, "must be a valid uid: %s", token)
325	})
326}
327
328func TestLoginCommand_jwt(t *testing.T) {
329	if testing.Short() {
330		t.Skip("too slow for testing.Short")
331	}
332
333	t.Parallel()
334
335	testDir := testutil.TempDir(t, "acl")
336
337	a := agent.NewTestAgent(t, `
338	primary_datacenter = "dc1"
339	acl {
340		enabled = true
341		tokens {
342			master = "root"
343		}
344	}`)
345
346	defer a.Shutdown()
347	testrpc.WaitForLeader(t, a.RPC, "dc1")
348
349	client := a.Client()
350
351	tokenSinkFile := filepath.Join(testDir, "test.token")
352	bearerTokenFile := filepath.Join(testDir, "bearer.token")
353
354	// spin up a fake oidc server
355	oidcServer := startSSOTestServer(t)
356	pubKey, privKey := oidcServer.SigningKeys()
357
358	type mConfig = map[string]interface{}
359	cases := map[string]struct {
360		f         func(config mConfig)
361		issuer    string
362		expectErr string
363	}{
364		"success - jwt static keys": {func(config mConfig) {
365			config["BoundIssuer"] = "https://legit.issuer.internal/"
366			config["JWTValidationPubKeys"] = []string{pubKey}
367		},
368			"https://legit.issuer.internal/",
369			""},
370		"success - jwt jwks": {func(config mConfig) {
371			config["JWKSURL"] = oidcServer.Addr() + "/certs"
372			config["JWKSCACert"] = oidcServer.CACert()
373		},
374			"https://legit.issuer.internal/",
375			""},
376		"success - jwt oidc discovery": {func(config mConfig) {
377			config["OIDCDiscoveryURL"] = oidcServer.Addr()
378			config["OIDCDiscoveryCACert"] = oidcServer.CACert()
379		},
380			oidcServer.Addr(),
381			""},
382	}
383
384	for name, tc := range cases {
385		tc := tc
386		t.Run(name, func(t *testing.T) {
387			method := &api.ACLAuthMethod{
388				Name: "jwt",
389				Type: "jwt",
390				Config: map[string]interface{}{
391					"JWTSupportedAlgs": []string{"ES256"},
392					"ClaimMappings": map[string]string{
393						"first_name":   "name",
394						"/org/primary": "primary_org",
395					},
396					"ListClaimMappings": map[string]string{
397						"https://consul.test/groups": "groups",
398					},
399					"BoundAudiences": []string{"https://consul.test"},
400				},
401			}
402			if tc.f != nil {
403				tc.f(method.Config)
404			}
405			_, _, err := client.ACL().AuthMethodCreate(
406				method,
407				&api.WriteOptions{Token: "root"},
408			)
409			require.NoError(t, err)
410
411			_, _, err = client.ACL().BindingRuleCreate(&api.ACLBindingRule{
412				AuthMethod: "jwt",
413				BindType:   api.BindingRuleBindTypeService,
414				BindName:   "test--${value.name}--${value.primary_org}",
415				Selector:   "value.name == jeff2 and value.primary_org == engineering and foo in list.groups",
416			},
417				&api.WriteOptions{Token: "root"},
418			)
419			require.NoError(t, err)
420
421			cl := jwt.Claims{
422				Subject:   "r3qXcK2bix9eFECzsU3Sbmh0K16fatW6@clients",
423				Audience:  jwt.Audience{"https://consul.test"},
424				Issuer:    tc.issuer,
425				NotBefore: jwt.NewNumericDate(time.Now().Add(-5 * time.Second)),
426				Expiry:    jwt.NewNumericDate(time.Now().Add(5 * time.Second)),
427			}
428
429			type orgs struct {
430				Primary string `json:"primary"`
431			}
432
433			privateCl := struct {
434				FirstName string   `json:"first_name"`
435				Org       orgs     `json:"org"`
436				Groups    []string `json:"https://consul.test/groups"`
437			}{
438				FirstName: "jeff2",
439				Org:       orgs{"engineering"},
440				Groups:    []string{"foo", "bar"},
441			}
442
443			// Drop a JWT on disk.
444			jwtData, err := oidcauthtest.SignJWT(privKey, cl, privateCl)
445			require.NoError(t, err)
446			require.NoError(t, ioutil.WriteFile(bearerTokenFile, []byte(jwtData), 0600))
447
448			defer os.Remove(tokenSinkFile)
449			ui := cli.NewMockUi()
450			cmd := New(ui)
451
452			args := []string{
453				"-http-addr=" + a.HTTPAddr(),
454				"-token=root",
455				"-method=jwt",
456				"-token-sink-file", tokenSinkFile,
457				"-bearer-token-file", bearerTokenFile,
458			}
459
460			code := cmd.Run(args)
461			require.Equal(t, 0, code, "err: %s", ui.ErrorWriter.String())
462			require.Empty(t, ui.ErrorWriter.String())
463			require.Empty(t, ui.OutputWriter.String())
464
465			raw, err := ioutil.ReadFile(tokenSinkFile)
466			require.NoError(t, err)
467
468			token := strings.TrimSpace(string(raw))
469			require.Len(t, token, 36, "must be a valid uid: %s", token)
470		})
471	}
472}
473
474func startSSOTestServer(t *testing.T) *oidcauthtest.Server {
475	ports := freeport.MustTake(1)
476	return oidcauthtest.Start(t, oidcauthtest.WithPort(
477		ports[0],
478		func() { freeport.Return(ports) },
479	))
480}
481