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