package oauth2provider_test import ( "context" "fmt" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) // TestOAuth2ProviderAppValidation tests validation logic for OAuth2 provider app requests func TestOAuth2ProviderAppValidation(t *testing.T) { t.Parallel() t.Run("ValidationErrors", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) tests := []struct { name string req codersdk.PostOAuth2ProviderAppRequest }{ { name: "NameMissing", req: codersdk.PostOAuth2ProviderAppRequest{ CallbackURL: "http://localhost:3000", }, }, { name: "NameSpaces", req: codersdk.PostOAuth2ProviderAppRequest{ Name: "foo bar", CallbackURL: "http://localhost:3000", }, }, { name: "NameTooLong", req: codersdk.PostOAuth2ProviderAppRequest{ Name: "too loooooooooooooooooooooooooong", CallbackURL: "http://localhost:3000", }, }, { name: "URLMissing", req: codersdk.PostOAuth2ProviderAppRequest{ Name: "foo", }, }, { name: "URLLocalhostNoScheme", req: codersdk.PostOAuth2ProviderAppRequest{ Name: "foo", CallbackURL: "localhost:3000", }, }, { name: "URLNoScheme", req: codersdk.PostOAuth2ProviderAppRequest{ Name: "foo", CallbackURL: "coder.com", }, }, { name: "URLNoColon", req: codersdk.PostOAuth2ProviderAppRequest{ Name: "foo", CallbackURL: "http//coder", }, }, { name: "URLJustBar", req: codersdk.PostOAuth2ProviderAppRequest{ Name: "foo", CallbackURL: "bar", }, }, { name: "URLPathOnly", req: codersdk.PostOAuth2ProviderAppRequest{ Name: "foo", CallbackURL: "/bar/baz/qux", }, }, { name: "URLJustHttp", req: codersdk.PostOAuth2ProviderAppRequest{ Name: "foo", CallbackURL: "http", }, }, { name: "URLNoHost", req: codersdk.PostOAuth2ProviderAppRequest{ Name: "foo", CallbackURL: "http://", }, }, { name: "URLSpaces", req: codersdk.PostOAuth2ProviderAppRequest{ Name: "foo", CallbackURL: "bar baz qux", }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() testCtx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // OAuth2 app management requires owner permission. _, err := client.PostOAuth2ProviderApp(testCtx, test.req) require.Error(t, err) }) } }) t.Run("DuplicateNames", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) // Create multiple OAuth2 apps with the same name to verify RFC 7591 compliance // RFC 7591 allows multiple apps to have the same name appName := fmt.Sprintf("duplicate-name-%d", time.Now().UnixNano()%1000000) // Create first app //nolint:gocritic // OAuth2 app management requires owner permission. app1, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{ Name: appName, CallbackURL: "http://localhost:3001", }) require.NoError(t, err) require.Equal(t, appName, app1.Name) // Create second app with the same name //nolint:gocritic // OAuth2 app management requires owner permission. app2, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{ Name: appName, CallbackURL: "http://localhost:3002", }) require.NoError(t, err) require.Equal(t, appName, app2.Name) // Create third app with the same name //nolint:gocritic // OAuth2 app management requires owner permission. app3, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{ Name: appName, CallbackURL: "http://localhost:3003", }) require.NoError(t, err) require.Equal(t, appName, app3.Name) // Verify all apps have different IDs but same name require.NotEqual(t, app1.ID, app2.ID) require.NotEqual(t, app1.ID, app3.ID) require.NotEqual(t, app2.ID, app3.ID) }) } // TestOAuth2ClientRegistrationValidation tests OAuth2 client registration validation func TestOAuth2ClientRegistrationValidation(t *testing.T) { t.Parallel() t.Run("ValidURIs", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) validURIs := []string{ "https://example.com/callback", "http://localhost:8080/callback", "custom-scheme://app/callback", } req := codersdk.OAuth2ClientRegistrationRequest{ RedirectURIs: validURIs, ClientName: fmt.Sprintf("valid-uris-client-%d", time.Now().UnixNano()), } resp, err := client.PostOAuth2ClientRegistration(ctx, req) require.NoError(t, err) require.Equal(t, validURIs, resp.RedirectURIs) }) t.Run("InvalidURIs", func(t *testing.T) { t.Parallel() testCases := []struct { name string uris []string }{ { name: "InvalidURL", uris: []string{"not-a-url"}, }, { name: "EmptyFragment", uris: []string{"https://example.com/callback#"}, }, { name: "Fragment", uris: []string{"https://example.com/callback#fragment"}, }, } for _, tc := range testCases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() // Create new client for each sub-test to avoid shared state issues subClient := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, subClient) subCtx := testutil.Context(t, testutil.WaitLong) req := codersdk.OAuth2ClientRegistrationRequest{ RedirectURIs: tc.uris, ClientName: fmt.Sprintf("invalid-uri-client-%s-%d", tc.name, time.Now().UnixNano()), } _, err := subClient.PostOAuth2ClientRegistration(subCtx, req) require.Error(t, err) require.Contains(t, err.Error(), "invalid_client_metadata") }) } }) t.Run("ValidGrantTypes", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) req := codersdk.OAuth2ClientRegistrationRequest{ RedirectURIs: []string{"https://example.com/callback"}, ClientName: fmt.Sprintf("valid-grant-types-client-%d", time.Now().UnixNano()), GrantTypes: []string{"authorization_code", "refresh_token"}, } resp, err := client.PostOAuth2ClientRegistration(ctx, req) require.NoError(t, err) require.Equal(t, req.GrantTypes, resp.GrantTypes) }) t.Run("InvalidGrantTypes", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) req := codersdk.OAuth2ClientRegistrationRequest{ RedirectURIs: []string{"https://example.com/callback"}, ClientName: fmt.Sprintf("invalid-grant-types-client-%d", time.Now().UnixNano()), GrantTypes: []string{"unsupported_grant"}, } _, err := client.PostOAuth2ClientRegistration(ctx, req) require.Error(t, err) require.Contains(t, err.Error(), "invalid_client_metadata") }) t.Run("ValidResponseTypes", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) req := codersdk.OAuth2ClientRegistrationRequest{ RedirectURIs: []string{"https://example.com/callback"}, ClientName: fmt.Sprintf("valid-response-types-client-%d", time.Now().UnixNano()), ResponseTypes: []string{"code"}, } resp, err := client.PostOAuth2ClientRegistration(ctx, req) require.NoError(t, err) require.Equal(t, req.ResponseTypes, resp.ResponseTypes) }) t.Run("InvalidResponseTypes", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) req := codersdk.OAuth2ClientRegistrationRequest{ RedirectURIs: []string{"https://example.com/callback"}, ClientName: fmt.Sprintf("invalid-response-types-client-%d", time.Now().UnixNano()), ResponseTypes: []string{"token"}, // Not supported } _, err := client.PostOAuth2ClientRegistration(ctx, req) require.Error(t, err) require.Contains(t, err.Error(), "invalid_client_metadata") }) } // TestOAuth2ProviderAppOperations tests basic CRUD operations for OAuth2 provider apps func TestOAuth2ProviderAppOperations(t *testing.T) { t.Parallel() t.Run("DeleteNonExisting", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) owner := coderdtest.CreateFirstUser(t, client) another, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) ctx := testutil.Context(t, testutil.WaitLong) _, err := another.OAuth2ProviderApp(ctx, uuid.New()) require.Error(t, err) }) t.Run("BasicOperations", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) owner := coderdtest.CreateFirstUser(t, client) another, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) ctx := testutil.Context(t, testutil.WaitLong) // No apps yet. apps, err := another.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{}) require.NoError(t, err) require.Len(t, apps, 0) // Should be able to add apps. expectedApps := generateApps(ctx, t, client, "get-apps") expectedOrder := []codersdk.OAuth2ProviderApp{ expectedApps.Default, expectedApps.NoPort, expectedApps.Extra[0], expectedApps.Extra[1], expectedApps.Subdomain, } // Should get all the apps now. apps, err = another.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{}) require.NoError(t, err) require.Len(t, apps, 5) require.Equal(t, expectedOrder, apps) // Should be able to keep the same name when updating. req := codersdk.PutOAuth2ProviderAppRequest{ Name: expectedApps.Default.Name, CallbackURL: "http://coder.com", Icon: "test", } //nolint:gocritic // OAuth2 app management requires owner permission. newApp, err := client.PutOAuth2ProviderApp(ctx, expectedApps.Default.ID, req) require.NoError(t, err) require.Equal(t, req.Name, newApp.Name) require.Equal(t, req.CallbackURL, newApp.CallbackURL) require.Equal(t, req.Icon, newApp.Icon) require.Equal(t, expectedApps.Default.ID, newApp.ID) // Should be able to update name. req = codersdk.PutOAuth2ProviderAppRequest{ Name: "new-foo", CallbackURL: "http://coder.com", Icon: "test", } //nolint:gocritic // OAuth2 app management requires owner permission. newApp, err = client.PutOAuth2ProviderApp(ctx, expectedApps.Default.ID, req) require.NoError(t, err) require.Equal(t, req.Name, newApp.Name) require.Equal(t, req.CallbackURL, newApp.CallbackURL) require.Equal(t, req.Icon, newApp.Icon) require.Equal(t, expectedApps.Default.ID, newApp.ID) // Should be able to get a single app. got, err := another.OAuth2ProviderApp(ctx, expectedApps.Default.ID) require.NoError(t, err) require.Equal(t, newApp, got) // Should be able to delete an app. //nolint:gocritic // OAuth2 app management requires owner permission. err = client.DeleteOAuth2ProviderApp(ctx, expectedApps.Default.ID) require.NoError(t, err) // Should show the new count. newApps, err := another.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{}) require.NoError(t, err) require.Len(t, newApps, 4) require.Equal(t, expectedOrder[1:], newApps) }) t.Run("ByUser", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) owner := coderdtest.CreateFirstUser(t, client) another, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) ctx := testutil.Context(t, testutil.WaitLong) _ = generateApps(ctx, t, client, "by-user") apps, err := another.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{ UserID: user.ID, }) require.NoError(t, err) require.Len(t, apps, 0) }) } // Helper functions type provisionedApps struct { Default codersdk.OAuth2ProviderApp NoPort codersdk.OAuth2ProviderApp Subdomain codersdk.OAuth2ProviderApp // For sorting purposes these are included. You will likely never touch them. Extra []codersdk.OAuth2ProviderApp } func generateApps(ctx context.Context, t *testing.T, client *codersdk.Client, suffix string) provisionedApps { create := func(name, callback string) codersdk.OAuth2ProviderApp { name = fmt.Sprintf("%s-%s", name, suffix) //nolint:gocritic // OAuth2 app management requires owner permission. app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{ Name: name, CallbackURL: callback, Icon: "", }) require.NoError(t, err) require.Equal(t, name, app.Name) require.Equal(t, callback, app.CallbackURL) return app } return provisionedApps{ Default: create("app-a", "http://localhost1:8080/foo/bar"), NoPort: create("app-b", "http://localhost2"), Subdomain: create("app-z", "http://30.localhost:3000"), Extra: []codersdk.OAuth2ProviderApp{ create("app-x", "http://20.localhost:3000"), create("app-y", "http://10.localhost:3000"), }, } }