main
  1package main
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/base64"
  7	"net/http"
  8	"net/url"
  9	"strings"
 10	"testing"
 11	"time"
 12
 13	"github.com/playwright-community/playwright-go"
 14	"github.com/stretchr/testify/assert"
 15	"github.com/stretchr/testify/require"
 16	"github.com/xlgmokha/x/pkg/env"
 17	"github.com/xlgmokha/x/pkg/serde"
 18	"github.com/xlgmokha/x/pkg/x"
 19	"golang.org/x/oauth2"
 20)
 21
 22func TestAuthx(t *testing.T) {
 23	if env.Fetch("SKIP_E2E", "") != "" {
 24		t.Skip()
 25	}
 26
 27	_ = playwright.Install()
 28
 29	pw := x.Must(playwright.Run())
 30	browser := x.Must(pw.Firefox.Launch(playwright.BrowserTypeLaunchOptions{
 31		Headless: playwright.Bool(env.Fetch("HEADLESS", "true") == "true"),
 32		SlowMo:   playwright.Float(1000),
 33	}))
 34	page := x.Must(browser.NewPage())
 35
 36	client := &http.Client{Timeout: 2 * time.Second}
 37
 38	defer func() {
 39		x.Check(browser.Close())
 40		x.Check(pw.Stop())
 41	}()
 42
 43	t.Run("SAML", func(t *testing.T) {
 44		for _, url := range []string{"http://idp.example.com:8080/saml/metadata.xml", "http://ui.example.com:8080/saml/metadata.xml"} {
 45			t.Run("GET "+url, func(t *testing.T) {
 46				response := x.Must(http.Get(url))
 47				assert.Equal(t, http.StatusOK, response.StatusCode)
 48			})
 49		}
 50
 51		t.Run("GET http://ui.example.com:8080/saml/new", func(t *testing.T) {
 52			assert.NoError(t, page.Context().ClearCookies())
 53			x.Must(page.Goto("http://ui.example.com:8080/saml/new"))
 54			action := x.Must(page.Locator("#idp-form").GetAttribute("action"))
 55			assert.Equal(t, "http://idp.example.com:8080/saml/new", action)
 56			assert.NoError(t, page.Locator("#submit-button").Click())
 57
 58			page.Locator("#username").Fill("root")
 59			page.Locator("#password").Fill("root")
 60			assert.NoError(t, page.Locator("#login-button").Click())
 61
 62			action = x.Must(page.Locator("#postback-form").GetAttribute("action"))
 63			assert.Equal(t, "http://ui.example.com:8080/saml/assertions", action)
 64			assert.NoError(t, page.Locator("#submit-button").Click())
 65			assert.Contains(t, x.Must(page.Content()), "Received SAML Response")
 66
 67			t.Run("generates a usable access token", func(t *testing.T) {
 68				rawToken := x.Must(page.Locator("#access-token").TextContent())
 69				accessToken := strings.Replace(rawToken, "\"", "", -1)
 70				assert.NotEmpty(t, accessToken)
 71
 72				t.Run("GET http://api.example.com:8080/projects.json", func(t *testing.T) {
 73					request := x.Must(http.NewRequestWithContext(t.Context(), "GET", "http://api.example.com:8080/projects.json", nil))
 74					request.Header.Add("Authorization", "Bearer "+accessToken)
 75					response := x.Must(client.Do(request))
 76					require.Equal(t, http.StatusOK, response.StatusCode)
 77					projects := x.Must(serde.FromJSON[[]map[string]string](response.Body))
 78					assert.NotNil(t, projects)
 79				})
 80			})
 81
 82			t.Run("exchange SAML assertion for access token", func(t *testing.T) {
 83				samlAssertion := x.Must(page.Locator("#raw-saml-response").TextContent())
 84				io := bytes.NewBuffer(nil)
 85				assert.NoError(t, serde.ToJSON(io, map[string]string{
 86					"assertion":  samlAssertion,
 87					"grant_type": "urn:ietf:params:oauth:grant-type:saml2-bearer",
 88				}))
 89				request := x.Must(http.NewRequestWithContext(t.Context(), "POST", "http://idp.example.com:8080/oauth/token", io))
 90				request.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("client_id:client_secret")))
 91				request.Header.Add("Content-Type", "application/json ")
 92				response := x.Must(client.Do(request))
 93				require.Equal(t, http.StatusOK, response.StatusCode)
 94			})
 95		})
 96	})
 97
 98	t.Run("OIDC", func(t *testing.T) {
 99		t.Run("GET http://ui.example.com:8080/oidc/new", func(t *testing.T) {
100			assert.NoError(t, page.Context().ClearCookies())
101			x.Must(page.Goto("http://ui.example.com:8080/oidc/new"))
102
103			assert.Contains(t, page.URL(), "http://idp.example.com:8080/sessions/new")
104			page.Locator("#username").Fill("root")
105			page.Locator("#password").Fill("root")
106			assert.NoError(t, page.Locator("#login-button").Click())
107
108			assert.Contains(t, page.URL(), "http://idp.example.com:8080/oauth/authorize/continue")
109			assert.NoError(t, page.Locator("#submit-button").Click())
110
111			assert.Contains(t, page.URL(), "http://ui.example.com:8080/oauth/callback")
112			content := x.Must(page.Locator("pre").First().InnerText())
113			item := x.Must(serde.FromJSON[oauth2.Token](strings.NewReader(content)))
114			require.NotEmpty(t, item.AccessToken)
115			require.Equal(t, "Bearer", item.TokenType)
116			require.NotEmpty(t, item.RefreshToken)
117
118			t.Run("GET http://api.example.com:8080/organizations.json", func(t *testing.T) {
119				response := x.Must(http.Get("http://api.example.com:8080/organizations.json"))
120				assert.Equal(t, http.StatusForbidden, response.StatusCode)
121			})
122
123			t.Run("GET http://api.example.com:8080/organizations.json with Authorization", func(t *testing.T) {
124				request := x.Must(http.NewRequestWithContext(t.Context(), "GET", "http://api.example.com:8080/organizations.json", nil))
125				request.Header.Add("Authorization", "Bearer "+item.AccessToken)
126				response := x.Must(client.Do(request))
127				require.Equal(t, http.StatusOK, response.StatusCode)
128				organizations := x.Must(serde.FromJSON[[]map[string]string](response.Body))
129				assert.NotNil(t, organizations)
130			})
131
132			t.Run("GET http://api.example.com:8080/groups.json", func(t *testing.T) {
133				response := x.Must(http.Get("http://api.example.com:8080/groups.json"))
134				assert.Equal(t, http.StatusForbidden, response.StatusCode)
135			})
136
137			t.Run("GET http://api.example.com:8080/groups.json with Authorization", func(t *testing.T) {
138				request := x.Must(http.NewRequestWithContext(t.Context(), "GET", "http://api.example.com:8080/groups.json", nil))
139				request.Header.Add("Authorization", "Bearer "+item.AccessToken)
140				response := x.Must(client.Do(request))
141				require.Equal(t, http.StatusOK, response.StatusCode)
142				groups := x.Must(serde.FromJSON[[]map[string]string](response.Body))
143				assert.NotNil(t, groups)
144			})
145
146			t.Run("GET http://api.example.com:8080/projects.json", func(t *testing.T) {
147				response := x.Must(http.Get("http://api.example.com:8080/projects.json"))
148				assert.Equal(t, http.StatusForbidden, response.StatusCode)
149			})
150
151			t.Run("GET http://api.example.com:8080/projects.json with Authorization", func(t *testing.T) {
152				request := x.Must(http.NewRequestWithContext(t.Context(), "GET", "http://api.example.com:8080/projects.json", nil))
153				request.Header.Add("Authorization", "Bearer "+item.AccessToken)
154				response := x.Must(client.Do(request))
155				require.Equal(t, http.StatusOK, response.StatusCode)
156				projects := x.Must(serde.FromJSON[[]map[string]string](response.Body))
157				assert.NotNil(t, projects)
158			})
159
160			t.Run("creates a new project", func(t *testing.T) {
161				io := bytes.NewBuffer(nil)
162				assert.NoError(t, serde.ToJSON(io, map[string]string{"name": "example"}))
163				request := x.Must(http.NewRequestWithContext(t.Context(), "POST", "http://api.example.com:8080/projects", io))
164				request.Header.Add("Authorization", "Bearer "+item.AccessToken)
165				response := x.Must(client.Do(request))
166				require.Equal(t, http.StatusCreated, response.StatusCode)
167				project := x.Must(serde.FromJSON[map[string]string](response.Body))
168				assert.Equal(t, "example", project["name"])
169			})
170
171			t.Run("creates another project", func(t *testing.T) {
172				io := bytes.NewBuffer(nil)
173				assert.NoError(t, serde.ToJSON(io, map[string]string{"name": "example2"}))
174				request := x.Must(http.NewRequestWithContext(t.Context(), "POST", "http://api.example.com:8080/projects.json", io))
175				request.Header.Add("Authorization", "Bearer "+item.AccessToken)
176				response := x.Must(client.Do(request))
177				require.Equal(t, http.StatusCreated, response.StatusCode)
178				project := x.Must(serde.FromJSON[map[string]string](response.Body))
179				assert.Equal(t, "example2", project["name"])
180			})
181		})
182	})
183
184	t.Run("OAuth", func(t *testing.T) {
185		t.Run("GET /.well-known/oauth-authorization-server", func(t *testing.T) {
186			response := x.Must(client.Get("http://idp.example.com:8080/.well-known/oauth-authorization-server"))
187			require.Equal(t, http.StatusOK, response.StatusCode)
188			metadata := x.Must(serde.FromJSON[map[string]interface{}](response.Body))
189			assert.Equal(t, "http://idp.example.com:8080/.well-known/oauth-authorization-server", metadata["issuer"])
190			assert.Equal(t, "http://idp.example.com:8080/oauth/authorize", metadata["authorization_endpoint"])
191			assert.Equal(t, "http://idp.example.com:8080/oauth/token", metadata["token_endpoint"])
192			// assert.NotEmpty(t, metadata["jwks_uri"])
193			// assert.NotEmpty(t, metadata["registration_endpoint"])
194			assert.NotEmpty(t, metadata["scopes_supported"])
195			assert.NotEmpty(t, metadata["response_types_supported"])
196			assert.NotEmpty(t, metadata["response_modes_supported"])
197			assert.NotEmpty(t, metadata["grant_types_supported"])
198			assert.NotEmpty(t, metadata["token_endpoint_auth_methods_supported"])
199			assert.NotEmpty(t, metadata["token_endpoint_auth_signing_alg_values_supported"])
200			// assert.NotEmpty(t, metadata["service_documentation"])
201			assert.NotEmpty(t, metadata["ui_locales_supported"])
202			// assert.NotEmpty(t, metadata["op_policy_uri"])
203			// assert.NotEmpty(t, metadata["op_tos_uri"])
204			assert.NotEmpty(t, metadata["revocation_endpoint"])
205			assert.NotEmpty(t, metadata["revocation_endpoint_auth_methods_supported"])
206			assert.NotEmpty(t, metadata["revocation_endpoint_auth_signing_alg_values_supported"])
207			assert.NotEmpty(t, metadata["introspection_endpoint"])
208			assert.NotEmpty(t, metadata["introspection_endpoint_auth_methods_supported"])
209			assert.NotEmpty(t, metadata["introspection_endpoint_auth_signing_alg_values_supported"])
210			// assert.NotEmpty(t, metadata["code_challenge_methods_supported"])
211		})
212
213		t.Run("GET /.well-known/openid-configuration", func(t *testing.T) {
214			response := x.Must(client.Get("http://idp.example.com:8080/.well-known/openid-configuration"))
215			require.Equal(t, http.StatusOK, response.StatusCode)
216			metadata := x.Must(serde.FromJSON[map[string]interface{}](response.Body))
217			assert.Equal(t, "http://idp.example.com:8080/.well-known/oauth-authorization-server", metadata["issuer"])
218			assert.Equal(t, "http://idp.example.com:8080/oauth/authorize", metadata["authorization_endpoint"])
219			assert.Equal(t, "http://idp.example.com:8080/oauth/token", metadata["token_endpoint"])
220			assert.NotEmpty(t, metadata["userinfo_endpoint"])
221			// assert.NotEmpty(t, metadata["jwks_uri"])
222			// assert.NotEmpty(t, metadata["registration_endpoint"])
223			assert.NotEmpty(t, metadata["scopes_supported"])
224			assert.NotEmpty(t, metadata["response_types_supported"])
225			assert.NotEmpty(t, metadata["response_modes_supported"])
226			assert.NotEmpty(t, metadata["grant_types_supported"])
227			// assert.NotEmpty(t, metadata["acr_values_supported"])
228			assert.NotEmpty(t, metadata["subject_types_supported"])
229			assert.NotEmpty(t, metadata["id_token_signing_alg_values_supported"])
230			// assert.NotEmpty(t, metadata["id_token_encryption_alg_values_supported"])
231			// assert.NotEmpty(t, metadata["id_token_encryption_enc_values_supported"])
232			assert.NotEmpty(t, metadata["userinfo_signing_alg_values_supported"])
233			// assert.NotEmpty(t, metadata["userinfo_encryption_alg_values_supported"])
234			// assert.NotEmpty(t, metadata["userinfo_encryption_enc_values_supported"])
235			assert.NotEmpty(t, metadata["request_object_signing_alg_values_supported"])
236			// assert.NotEmpty(t, metadata["request_object_encryption_alg_values_supported"])
237			// assert.NotEmpty(t, metadata["request_object_encryption_enc_values_supported"])
238			assert.NotEmpty(t, metadata["token_endpoint_auth_methods_supported"])
239			// assert.NotEmpty(t, metadata["token_endpoint_auth_signing_alg_values_supported"])
240			// assert.NotEmpty(t, metadata["display_values_supported"])
241			assert.NotEmpty(t, metadata["claim_types_supported"])
242			assert.NotEmpty(t, metadata["claims_supported"])
243			// assert.NotEmpty(t, metadata["service_documentation"])
244			// assert.NotEmpty(t, metadata["claims_locales_supported"])
245			assert.NotEmpty(t, metadata["ui_locales_supported"])
246			// assert.True(t, metadata["claims_parameter_supported"])
247			// assert.True(t, metadata["request_parameter_supported"])
248			// assert.True(t, metadata["request_uri_parameter_supported"])
249			// assert.True(t, metadata["require_request_uri_registration"])
250			// assert.NotEmpty(t, metadata["op_policy_uri"])
251			// assert.NotEmpty(t, metadata["op_tos_uri"])
252		})
253
254		t.Run("authorization code grant", func(t *testing.T) {
255			conf := &oauth2.Config{
256				ClientID:     "client_id",
257				ClientSecret: "client_secret",
258				Scopes:       []string{"openid"},
259				Endpoint: oauth2.Endpoint{
260					TokenURL: "http://idp.example.com:8080/oauth/token",
261					AuthURL:  "http://idp.example.com:8080/oauth/authorize",
262				},
263			}
264
265			authURL := conf.AuthCodeURL(
266				"state",
267				oauth2.SetAuthURLParam("client_id", "client_id"),
268				oauth2.SetAuthURLParam("scope", "openid"),
269				oauth2.SetAuthURLParam("redirect_uri", "http://example.org/oauth/callback"),
270				oauth2.SetAuthURLParam("response_type", "code"),
271				oauth2.SetAuthURLParam("response_mode", "fragment"),
272			)
273			assert.NoError(t, page.Context().ClearCookies())
274			x.Must(page.Goto(authURL))
275
276			page.Locator("#username").Fill("root")
277			page.Locator("#password").Fill("root")
278			assert.NoError(t, page.Locator("#login-button").Click())
279
280			assert.NoError(t, page.Locator("#submit-button").Click())
281
282			uri := x.Must(url.Parse(page.URL()))
283			values := x.Must(url.ParseQuery(uri.Fragment))
284			code := values.Get("code")
285
286			ctx := t.Context()
287			ctx = context.WithValue(ctx, oauth2.HTTPClient, client)
288			credentials := x.Must(conf.Exchange(ctx, code))
289			assert.NotEmpty(t, credentials.AccessToken)
290			assert.Equal(t, "Bearer", credentials.TokenType)
291			assert.NotEmpty(t, credentials.RefreshToken)
292
293			t.Run("cannot re-use the same authorization grant", func(t *testing.T) {
294				newCredentials, err := conf.Exchange(ctx, code)
295
296				assert.Error(t, err)
297				assert.Empty(t, newCredentials)
298			})
299
300			t.Run("token is usable against REST API", func(t *testing.T) {
301				client := conf.Client(ctx, credentials)
302				response := x.Must(client.Get("http://api.example.com:8080/projects.json"))
303				require.Equal(t, http.StatusOK, response.StatusCode)
304				projects := x.Must(serde.FromJSON[[]map[string]string](response.Body))
305				assert.NotNil(t, projects)
306
307				io := bytes.NewBuffer(nil)
308				assert.NoError(t, serde.ToJSON(io, map[string]string{"name": "foo"}))
309				response = x.Must(client.Post("http://api.example.com:8080/projects", "application/json", io))
310				require.Equal(t, http.StatusCreated, response.StatusCode)
311				project := x.Must(serde.FromJSON[map[string]string](response.Body))
312				assert.Equal(t, "foo", project["name"])
313			})
314
315			t.Run("token can be introspected", func(t *testing.T) {
316				client := conf.Client(ctx, credentials)
317
318				io := bytes.NewBuffer(nil)
319				assert.NoError(t, serde.ToJSON(io, map[string]string{"token": credentials.AccessToken}))
320				response := x.Must(client.Post("http://idp.example.com:8080/oauth/introspect", "application/json", io))
321				require.Equal(t, http.StatusOK, response.StatusCode)
322
323				claims := x.Must(serde.FromJSON[map[string]interface{}](response.Body))
324				assert.Equal(t, true, claims["active"])
325				assert.Equal(t, "gid://example/User/1", claims["sub"])
326			})
327		})
328	})
329}