From 4ce585e5df5d9f1d55a96ef5e14dba7f33f43f57 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 14 Jul 2025 13:24:44 +0200 Subject: [PATCH] feat(oauth2): add frontend UI for client credentials applications - Add ClientCredentialsAppForm and ClientCredentialsAppRow components - Update API schemas to include created_at, grant_types, and user_id fields - Add dedicated pages for creating and managing client credentials apps - Update sidebar navigation and routing for OAuth2 client credentials - Enhance OAuth2AppPageView with user ownership information display Change-Id: I3271c7fb995d7225dd6cc830066fa2c8cb29720a Signed-off-by: Thomas Kosiewski --- .claude/docs/TESTING.md | 27 +- .claude/docs/WORKFLOWS.md | 2 + CLAUDE.md | 28 +- Makefile | 2 +- coderd/apidoc/docs.go | 38 +- coderd/apidoc/swagger.json | 36 +- coderd/database/db2sdk/db2sdk.go | 82 +++- coderd/database/dbauthz/dbauthz.go | 98 +++-- coderd/database/dbauthz/dbauthz_test.go | 206 ++++++++- coderd/database/dbgen/dbgen.go | 13 +- coderd/database/dbmetrics/querymetrics.go | 11 +- coderd/database/dbmock/dbmock.go | 23 +- coderd/database/dump.sql | 11 +- ...irect_uris_for_client_credentials.down.sql | 16 + ...edirect_uris_for_client_credentials.up.sql | 31 ++ coderd/database/modelmethods.go | 40 +- coderd/database/models.go | 4 +- coderd/database/querier.go | 5 +- coderd/database/queries.sql.go | 221 +++++++++- coderd/database/queries/oauth2.sql | 32 +- coderd/httpmw/oauth2.go | 40 +- coderd/oauth2.go | 5 +- coderd/oauth2provider/app_secrets.go | 11 +- coderd/oauth2provider/apps.go | 71 +++- coderd/oauth2provider/provider_test.go | 173 +++++++- coderd/oauth2provider/registration.go | 13 +- coderd/oauth2provider/tokens.go | 16 +- coderd/rbac/roles.go | 8 +- coderd/rbac/roles_test.go | 16 +- codersdk/oauth2.go | 21 +- docs/admin/security/audit-logs.md | 2 +- docs/reference/api/enterprise.md | 62 ++- docs/reference/api/schemas.md | 20 +- enterprise/audit/table.go | 15 +- site/src/api/api.ts | 11 +- site/src/api/queries/oauth2.ts | 6 +- site/src/api/typesGenerated.ts | 7 + .../OAuth2/ClientCredentialsAppForm.tsx | 129 ++++++ .../OAuth2/ClientCredentialsAppRow.tsx | 71 ++++ site/src/components/Sidebar/Sidebar.tsx | 4 +- .../EditOAuth2AppPageView.tsx | 138 ++++-- .../CreateClientCredentialsAppPage.tsx | 49 +++ .../ManageClientCredentialsAppPage.tsx | 397 ++++++++++++++++++ .../OAuth2ProviderPage/OAuth2ProviderPage.tsx | 73 +++- .../OAuth2ProviderPageView.stories.tsx | 5 +- .../OAuth2ProviderPageView.tsx | 139 ++++-- site/src/pages/UserSettingsPage/Sidebar.tsx | 4 +- site/src/router.tsx | 24 +- site/src/testHelpers/entities.ts | 7 +- 49 files changed, 2163 insertions(+), 300 deletions(-) create mode 100644 coderd/database/migrations/000359_allow_empty_redirect_uris_for_client_credentials.down.sql create mode 100644 coderd/database/migrations/000359_allow_empty_redirect_uris_for_client_credentials.up.sql create mode 100644 site/src/components/OAuth2/ClientCredentialsAppForm.tsx create mode 100644 site/src/components/OAuth2/ClientCredentialsAppRow.tsx create mode 100644 site/src/pages/UserSettingsPage/OAuth2ProviderPage/CreateClientCredentialsAppPage.tsx create mode 100644 site/src/pages/UserSettingsPage/OAuth2ProviderPage/ManageClientCredentialsAppPage.tsx diff --git a/.claude/docs/TESTING.md b/.claude/docs/TESTING.md index eff655b0acadc..1354c496c2785 100644 --- a/.claude/docs/TESTING.md +++ b/.claude/docs/TESTING.md @@ -62,21 +62,23 @@ coderd/ ### Running Tests -| Command | Purpose | -|---------|---------| -| `make test` | Run all Go tests | -| `make test RUN=TestFunctionName` | Run specific test | -| `go test -v ./path/to/package -run TestFunctionName` | Run test with verbose output | -| `make test-postgres` | Run tests with Postgres database | -| `make test-race` | Run tests with Go race detector | -| `make test-e2e` | Run end-to-end tests | +| Command | Purpose | +|------------------------------------------------------|----------------------------------| +| `make test` | Run all Go tests | +| `make test PACKAGE=./pkg/...` | Run tests for specific package | +| `make test RUN=TestFunctionName` | Run specific test | +| `make test PACKAGE=./pkg/... RUN=TestFunctionName` | Run specific test in package | +| `go test -v ./path/to/package -run TestFunctionName` | Run test with verbose output | +| `make test-postgres` | Run tests with Postgres database | +| `make test-race` | Run tests with Go race detector | +| `make test-e2e` | Run end-to-end tests | ### Frontend Testing -| Command | Purpose | -|---------|---------| -| `pnpm test` | Run frontend tests | -| `pnpm check` | Run code checks | +| Command | Purpose | +|--------------|--------------------| +| `pnpm test` | Run frontend tests | +| `pnpm check` | Run code checks | ## Common Testing Issues @@ -207,6 +209,7 @@ func BenchmarkFunction(b *testing.B) { ``` Run benchmarks with: + ```bash go test -bench=. -benchmem ./package/path ``` diff --git a/.claude/docs/WORKFLOWS.md b/.claude/docs/WORKFLOWS.md index 8fc43002bba7d..de1a2e67f0d7c 100644 --- a/.claude/docs/WORKFLOWS.md +++ b/.claude/docs/WORKFLOWS.md @@ -104,7 +104,9 @@ ### Test Execution - Run full test suite: `make test` +- Run specific package: `make test PACKAGE=./coderd/oauth2/...` - Run specific test: `make test RUN=TestFunctionName` +- Run specific test in package: `make test PACKAGE=./coderd/oauth2/... RUN=TestFunctionName` - Run with Postgres: `make test-postgres` - Run with race detector: `make test-race` - Run end-to-end tests: `make test-e2e` diff --git a/CLAUDE.md b/CLAUDE.md index 3de33a5466054..01c688f81f5ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,19 +7,21 @@ ## ๐Ÿš€ Essential Commands -| Task | Command | Notes | -|-------------------|--------------------------|----------------------------------| -| **Development** | `./scripts/develop.sh` | โš ๏ธ Don't use manual build | -| **Build** | `make build` | Fat binaries (includes server) | -| **Build Slim** | `make build-slim` | Slim binaries | -| **Test** | `make test` | Full test suite | -| **Test Single** | `make test RUN=TestName` | Faster than full suite | -| **Test Postgres** | `make test-postgres` | Run tests with Postgres database | -| **Test Race** | `make test-race` | Run tests with Go race detector | -| **Lint** | `make lint` | Always run after changes | -| **Generate** | `make gen` | After database changes | -| **Format** | `make fmt` | Auto-format code | -| **Clean** | `make clean` | Clean build artifacts | +| Task | Command | Notes | +|-------------------|--------------------------------------------|----------------------------------| +| **Development** | `./scripts/develop.sh` | โš ๏ธ Don't use manual build | +| **Build** | `make build` | Fat binaries (includes server) | +| **Build Slim** | `make build-slim` | Slim binaries | +| **Test** | `make test` | Full test suite | +| **Test Package** | `make test PACKAGE=./pkg/...` | Test specific package | +| **Test Single** | `make test RUN=TestName` | Faster than full suite | +| **Test Combined** | `make test PACKAGE=./pkg/... RUN=TestName` | Test specific test in package | +| **Test Postgres** | `make test-postgres` | Run tests with Postgres database | +| **Test Race** | `make test-race` | Run tests with Go race detector | +| **Lint** | `make lint` | Always run after changes | +| **Generate** | `make gen` | After database changes | +| **Format** | `make fmt` | Auto-format code | +| **Clean** | `make clean` | Clean build artifacts | ### Frontend Commands (site directory) diff --git a/Makefile b/Makefile index bd3f04a4874cd..ded2a67cd9a99 100644 --- a/Makefile +++ b/Makefile @@ -936,7 +936,7 @@ GOTESTSUM_RETRY_FLAGS := endif test: - $(GIT_FLAGS) gotestsum --format standard-quiet $(GOTESTSUM_RETRY_FLAGS) --packages="./..." -- -v -short -count=1 $(if $(RUN),-run $(RUN)) + $(GIT_FLAGS) gotestsum --format standard-quiet $(GOTESTSUM_RETRY_FLAGS) --packages="$(if $(PACKAGE),$(PACKAGE),./...)" -- -v -short -count=1 $(if $(RUN),-run $(RUN)) .PHONY: test test-cli: diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3d677306696b5..e82c6ca0e28bc 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1995,6 +1995,12 @@ const docTemplate = `{ "description": "Filter by applications authorized for a user", "name": "user_id", "in": "query" + }, + { + "type": "string", + "description": "Filter by applications owned by a user", + "name": "owner_id", + "in": "query" } ], "responses": { @@ -14435,6 +14441,13 @@ const docTemplate = `{ "codersdk.OAuth2ProviderApp": { "type": "object", "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string" + }, "endpoints": { "description": "Endpoints are included in the app response for easier discovery. The OAuth2\nspec does not have a defined place to find these (for comparison, OIDC has\na '/.well-known/openid-configuration' endpoint).", "allOf": [ @@ -14443,6 +14456,12 @@ const docTemplate = `{ } ] }, + "grant_types": { + "type": "array", + "items": { + "type": "string" + } + }, "icon": { "type": "string" }, @@ -14458,6 +14477,13 @@ const docTemplate = `{ "items": { "type": "string" } + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string" } } }, @@ -14467,6 +14493,10 @@ const docTemplate = `{ "client_secret_truncated": { "type": "string" }, + "created_at": { + "type": "string", + "format": "date-time" + }, "id": { "type": "string", "format": "uuid" @@ -15075,8 +15105,7 @@ const docTemplate = `{ "codersdk.PostOAuth2ProviderAppRequest": { "type": "object", "required": [ - "name", - "redirect_uris" + "name" ], "properties": { "grant_types": { @@ -15093,7 +15122,6 @@ const docTemplate = `{ }, "redirect_uris": { "type": "array", - "minItems": 1, "items": { "type": "string" } @@ -15825,8 +15853,7 @@ const docTemplate = `{ "codersdk.PutOAuth2ProviderAppRequest": { "type": "object", "required": [ - "name", - "redirect_uris" + "name" ], "properties": { "grant_types": { @@ -15843,7 +15870,6 @@ const docTemplate = `{ }, "redirect_uris": { "type": "array", - "minItems": 1, "items": { "type": "string" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index fdede9169b308..7c1cbbb2732ce 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1737,6 +1737,12 @@ "description": "Filter by applications authorized for a user", "name": "user_id", "in": "query" + }, + { + "type": "string", + "description": "Filter by applications owned by a user", + "name": "owner_id", + "in": "query" } ], "responses": { @@ -13025,6 +13031,13 @@ "codersdk.OAuth2ProviderApp": { "type": "object", "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string" + }, "endpoints": { "description": "Endpoints are included in the app response for easier discovery. The OAuth2\nspec does not have a defined place to find these (for comparison, OIDC has\na '/.well-known/openid-configuration' endpoint).", "allOf": [ @@ -13033,6 +13046,12 @@ } ] }, + "grant_types": { + "type": "array", + "items": { + "type": "string" + } + }, "icon": { "type": "string" }, @@ -13048,6 +13067,13 @@ "items": { "type": "string" } + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string" } } }, @@ -13057,6 +13083,10 @@ "client_secret_truncated": { "type": "string" }, + "created_at": { + "type": "string", + "format": "date-time" + }, "id": { "type": "string", "format": "uuid" @@ -13645,7 +13675,7 @@ }, "codersdk.PostOAuth2ProviderAppRequest": { "type": "object", - "required": ["name", "redirect_uris"], + "required": ["name"], "properties": { "grant_types": { "type": "array", @@ -13661,7 +13691,6 @@ }, "redirect_uris": { "type": "array", - "minItems": 1, "items": { "type": "string" } @@ -14361,7 +14390,7 @@ }, "codersdk.PutOAuth2ProviderAppRequest": { "type": "object", - "required": ["name", "redirect_uris"], + "required": ["name"], "properties": { "grant_types": { "type": "array", @@ -14377,7 +14406,6 @@ }, "redirect_uris": { "type": "array", - "minItems": 1, "items": { "type": "string" } diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index c8fc95dcec215..f5fbd2bbd6459 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -354,23 +354,34 @@ func TemplateVersionParameterOptionFromPreview(option *previewtypes.ParameterOpt } } +// oauth2AppEndpoints generates the OAuth2 endpoints for an app +func oauth2AppEndpoints(accessURL *url.URL) codersdk.OAuth2AppEndpoints { + return codersdk.OAuth2AppEndpoints{ + Authorization: accessURL.ResolveReference(&url.URL{ + Path: "/oauth2/authorize", + }).String(), + Token: accessURL.ResolveReference(&url.URL{ + Path: "/oauth2/token", + }).String(), + DeviceAuth: accessURL.ResolveReference(&url.URL{ + Path: "/oauth2/device", + }).String(), + Revocation: accessURL.ResolveReference(&url.URL{ + Path: "/oauth2/revoke", + }).String(), + } +} + func OAuth2ProviderApp(accessURL *url.URL, dbApp database.OAuth2ProviderApp) codersdk.OAuth2ProviderApp { return codersdk.OAuth2ProviderApp{ ID: dbApp.ID, Name: dbApp.Name, RedirectURIs: dbApp.RedirectUris, Icon: dbApp.Icon, - Endpoints: codersdk.OAuth2AppEndpoints{ - Authorization: accessURL.ResolveReference(&url.URL{ - Path: "/oauth2/authorize", - }).String(), - Token: accessURL.ResolveReference(&url.URL{ - Path: "/oauth2/token", - }).String(), - DeviceAuth: accessURL.ResolveReference(&url.URL{ - Path: "/oauth2/device/authorize", - }).String(), - }, + CreatedAt: dbApp.CreatedAt, + GrantTypes: dbApp.GrantTypes, + UserID: dbApp.UserID.UUID, + Endpoints: oauth2AppEndpoints(accessURL), } } @@ -380,6 +391,55 @@ func OAuth2ProviderApps(accessURL *url.URL, dbApps []database.OAuth2ProviderApp) }) } +func OAuth2ProviderAppRow(accessURL *url.URL, dbApp database.GetOAuth2ProviderAppByIDRow) codersdk.OAuth2ProviderApp { + return codersdk.OAuth2ProviderApp{ + ID: dbApp.ID, + Name: dbApp.Name, + RedirectURIs: dbApp.RedirectUris, + Icon: dbApp.Icon, + CreatedAt: dbApp.CreatedAt, + GrantTypes: dbApp.GrantTypes, + UserID: dbApp.UserID.UUID, + Username: dbApp.Username.String, + Email: dbApp.Email.String, + Endpoints: oauth2AppEndpoints(accessURL), + } +} + +func OAuth2ProviderAppsRows(accessURL *url.URL, dbApps []database.GetOAuth2ProviderAppsRow) []codersdk.OAuth2ProviderApp { + return List(dbApps, func(dbApp database.GetOAuth2ProviderAppsRow) codersdk.OAuth2ProviderApp { + return codersdk.OAuth2ProviderApp{ + ID: dbApp.ID, + Name: dbApp.Name, + RedirectURIs: dbApp.RedirectUris, + Icon: dbApp.Icon, + CreatedAt: dbApp.CreatedAt, + GrantTypes: dbApp.GrantTypes, + UserID: dbApp.UserID.UUID, + Username: dbApp.Username.String, + Email: dbApp.Email.String, + Endpoints: oauth2AppEndpoints(accessURL), + } + }) +} + +func OAuth2ProviderAppsByOwnerIDRows(accessURL *url.URL, dbApps []database.GetOAuth2ProviderAppsByOwnerIDRow) []codersdk.OAuth2ProviderApp { + return List(dbApps, func(dbApp database.GetOAuth2ProviderAppsByOwnerIDRow) codersdk.OAuth2ProviderApp { + return codersdk.OAuth2ProviderApp{ + ID: dbApp.ID, + Name: dbApp.Name, + RedirectURIs: dbApp.RedirectUris, + Icon: dbApp.Icon, + CreatedAt: dbApp.CreatedAt, + GrantTypes: dbApp.GrantTypes, + UserID: dbApp.UserID.UUID, + Username: dbApp.Username.String, + Email: dbApp.Email.String, + Endpoints: oauth2AppEndpoints(accessURL), + } + }) +} + func convertDisplayApps(apps []database.DisplayApp) []codersdk.DisplayApp { dapps := make([]codersdk.DisplayApp, 0, len(apps)) for _, app := range apps { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 4ada84f552374..8f0dfd24b2016 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1587,7 +1587,14 @@ func (q *querier) DeleteOAuth2ProviderAppByClientID(ctx context.Context, id uuid } func (q *querier) DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error { - if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceOauth2App); err != nil { + // Get the app to check ownership for proper authorization + app, err := q.db.GetOAuth2ProviderAppByID(ctx, id) + if err != nil { + return err + } + + // Check if user can delete this specific app + if err := q.authorizeContext(ctx, policy.ActionDelete, app.RBACObject()); err != nil { return err } return q.db.DeleteOAuth2ProviderAppByID(ctx, id) @@ -1613,10 +1620,7 @@ func (q *querier) DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx context.Context } func (q *querier) DeleteOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) error { - if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceOauth2AppSecret); err != nil { - return err - } - return q.db.DeleteOAuth2ProviderAppSecretByID(ctx, id) + return fetchAndExec(q.log, q.auth, policy.ActionDelete, q.db.GetOAuth2ProviderAppSecretByID, q.db.DeleteOAuth2ProviderAppSecretByID)(ctx, id) } func (q *querier) DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx context.Context, arg database.DeleteOAuth2ProviderAppTokensByAppAndUserIDParams) error { @@ -2344,11 +2348,8 @@ func (q *querier) GetOAuth2ProviderAppByClientID(ctx context.Context, id uuid.UU return q.db.GetOAuth2ProviderAppByClientID(ctx, id) } -func (q *querier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOauth2App); err != nil { - return database.OAuth2ProviderApp{}, err - } - return q.db.GetOAuth2ProviderAppByID(ctx, id) +func (q *querier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.GetOAuth2ProviderAppByIDRow, error) { + return fetch(q.log, q.auth, q.db.GetOAuth2ProviderAppByID)(ctx, id) } func (q *querier) GetOAuth2ProviderAppByRegistrationToken(ctx context.Context, registrationAccessToken sql.NullString) (database.OAuth2ProviderApp, error) { @@ -2367,10 +2368,7 @@ func (q *querier) GetOAuth2ProviderAppCodeByPrefix(ctx context.Context, secretPr } func (q *querier) GetOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderAppSecret, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOauth2AppSecret); err != nil { - return database.OAuth2ProviderAppSecret{}, err - } - return q.db.GetOAuth2ProviderAppSecretByID(ctx, id) + return fetch(q.log, q.auth, q.db.GetOAuth2ProviderAppSecretByID)(ctx, id) } func (q *querier) GetOAuth2ProviderAppSecretByPrefix(ctx context.Context, secretPrefix []byte) (database.OAuth2ProviderAppSecret, error) { @@ -2378,10 +2376,7 @@ func (q *querier) GetOAuth2ProviderAppSecretByPrefix(ctx context.Context, secret } func (q *querier) GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, appID uuid.UUID) ([]database.OAuth2ProviderAppSecret, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOauth2AppSecret); err != nil { - return []database.OAuth2ProviderAppSecret{}, err - } - return q.db.GetOAuth2ProviderAppSecretsByAppID(ctx, appID) + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetOAuth2ProviderAppSecretsByAppID)(ctx, appID) } func (q *querier) GetOAuth2ProviderAppTokenByAPIKeyID(ctx context.Context, apiKeyID string) (database.OAuth2ProviderAppToken, error) { @@ -2410,11 +2405,32 @@ func (q *querier) GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, hashPre return token, nil } -func (q *querier) GetOAuth2ProviderApps(ctx context.Context) ([]database.OAuth2ProviderApp, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOauth2App); err != nil { - return []database.OAuth2ProviderApp{}, err +func (q *querier) GetOAuth2ProviderApps(ctx context.Context) ([]database.GetOAuth2ProviderAppsRow, error) { + // Ensure actor is present before attempting authorization + if _, ok := ActorFromContext(ctx); !ok { + return nil, ErrNoActor } - return q.db.GetOAuth2ProviderApps(ctx) + + // Get all apps from the database + apps, err := q.db.GetOAuth2ProviderApps(ctx) + if err != nil { + return nil, err + } + + // Filter apps based on authorization + var filteredApps []database.GetOAuth2ProviderAppsRow + for _, app := range apps { + // Check authorization for each app using proper ownership model + if err := q.authorizeContext(ctx, policy.ActionRead, app.RBACObject()); err == nil { + filteredApps = append(filteredApps, app) + } + } + + return filteredApps, nil +} + +func (q *querier) GetOAuth2ProviderAppsByOwnerID(ctx context.Context, userID uuid.NullUUID) ([]database.GetOAuth2ProviderAppsByOwnerIDRow, error) { + return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetOAuth2ProviderAppsByOwnerID)(ctx, userID) } func (q *querier) GetOAuth2ProviderAppsByUserID(ctx context.Context, userID uuid.UUID) ([]database.GetOAuth2ProviderAppsByUserIDRow, error) { @@ -3881,7 +3897,7 @@ func (q *querier) InsertMissingGroups(ctx context.Context, arg database.InsertMi } func (q *querier) InsertOAuth2ProviderApp(ctx context.Context, arg database.InsertOAuth2ProviderAppParams) (database.OAuth2ProviderApp, error) { - if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceOauth2App); err != nil { + if err := q.authorizeContext(ctx, policy.ActionCreate, arg.RBACObject()); err != nil { return database.OAuth2ProviderApp{}, err } return q.db.InsertOAuth2ProviderApp(ctx, arg) @@ -3896,9 +3912,28 @@ func (q *querier) InsertOAuth2ProviderAppCode(ctx context.Context, arg database. } func (q *querier) InsertOAuth2ProviderAppSecret(ctx context.Context, arg database.InsertOAuth2ProviderAppSecretParams) (database.OAuth2ProviderAppSecret, error) { - if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceOauth2AppSecret); err != nil { + // Fetch the app to get ownership information + app, err := q.db.GetOAuth2ProviderAppByID(ctx, arg.AppID) + if err != nil { + return database.OAuth2ProviderAppSecret{}, xerrors.Errorf("fetch parent app: %w", err) + } + + // Populate denormalized field if not already set + if !arg.AppOwnerUserID.Valid { + arg.AppOwnerUserID = app.UserID + } + + // Create a secret with the denormalized ownership for authorization + secret := database.OAuth2ProviderAppSecret{ + AppID: arg.AppID, + ID: arg.ID, + AppOwnerUserID: arg.AppOwnerUserID, + } + + if err := q.authorizeContext(ctx, policy.ActionCreate, secret.RBACObject()); err != nil { return database.OAuth2ProviderAppSecret{}, err } + return q.db.InsertOAuth2ProviderAppSecret(ctx, arg) } @@ -4585,17 +4620,24 @@ func (q *querier) UpdateOAuth2ProviderAppByClientID(ctx context.Context, arg dat } func (q *querier) UpdateOAuth2ProviderAppByID(ctx context.Context, arg database.UpdateOAuth2ProviderAppByIDParams) (database.OAuth2ProviderApp, error) { - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceOauth2App); err != nil { + // Get the app to check ownership for proper authorization + app, err := q.db.GetOAuth2ProviderAppByID(ctx, arg.ID) + if err != nil { + return database.OAuth2ProviderApp{}, err + } + + // Check if user can update this specific app + if err := q.authorizeContext(ctx, policy.ActionUpdate, app.RBACObject()); err != nil { return database.OAuth2ProviderApp{}, err } return q.db.UpdateOAuth2ProviderAppByID(ctx, arg) } func (q *querier) UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg database.UpdateOAuth2ProviderAppSecretByIDParams) (database.OAuth2ProviderAppSecret, error) { - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceOauth2AppSecret); err != nil { - return database.OAuth2ProviderAppSecret{}, err + fetch := func(ctx context.Context, arg database.UpdateOAuth2ProviderAppSecretByIDParams) (database.OAuth2ProviderAppSecret, error) { + return q.db.GetOAuth2ProviderAppSecretByID(ctx, arg.ID) } - return q.db.UpdateOAuth2ProviderAppSecretByID(ctx, arg) + return fetchAndQuery(q.log, q.auth, policy.ActionUpdate, fetch, q.db.UpdateOAuth2ProviderAppSecretByID)(ctx, arg) } func (q *querier) UpdateOAuth2ProviderDeviceCodeAuthorization(ctx context.Context, arg database.UpdateOAuth2ProviderDeviceCodeAuthorizationParams) (database.OAuth2ProviderDeviceCode, error) { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index db7e4815e6d9a..3b8391bd0d00b 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -5259,15 +5259,110 @@ func (s *MethodTestSuite) TestPrebuilds() { func (s *MethodTestSuite) TestOAuth2ProviderApps() { s.Run("GetOAuth2ProviderApps", s.Subtest(func(db database.Store, check *expects) { - apps := []database.OAuth2ProviderApp{ - dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{Name: "first"}), - dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{Name: "last"}), + app1 := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{Name: "first"}) + app2 := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{Name: "last"}) + apps := []database.GetOAuth2ProviderAppsRow{ + { + ID: app1.ID, + CreatedAt: app1.CreatedAt, + UpdatedAt: app1.UpdatedAt, + Name: app1.Name, + Icon: app1.Icon, + RedirectUris: app1.RedirectUris, + ClientType: app1.ClientType, + DynamicallyRegistered: app1.DynamicallyRegistered, + ClientIDIssuedAt: app1.ClientIDIssuedAt, + ClientSecretExpiresAt: app1.ClientSecretExpiresAt, + GrantTypes: app1.GrantTypes, + ResponseTypes: app1.ResponseTypes, + TokenEndpointAuthMethod: app1.TokenEndpointAuthMethod, + Scope: app1.Scope, + Contacts: app1.Contacts, + ClientUri: app1.ClientUri, + LogoUri: app1.LogoUri, + TosUri: app1.TosUri, + PolicyUri: app1.PolicyUri, + JwksUri: app1.JwksUri, + Jwks: app1.Jwks, + SoftwareID: app1.SoftwareID, + SoftwareVersion: app1.SoftwareVersion, + RegistrationAccessToken: app1.RegistrationAccessToken, + RegistrationClientUri: app1.RegistrationClientUri, + UserID: app1.UserID, + Username: sql.NullString{}, + Email: sql.NullString{}, + }, + { + ID: app2.ID, + CreatedAt: app2.CreatedAt, + UpdatedAt: app2.UpdatedAt, + Name: app2.Name, + Icon: app2.Icon, + RedirectUris: app2.RedirectUris, + ClientType: app2.ClientType, + DynamicallyRegistered: app2.DynamicallyRegistered, + ClientIDIssuedAt: app2.ClientIDIssuedAt, + ClientSecretExpiresAt: app2.ClientSecretExpiresAt, + GrantTypes: app2.GrantTypes, + ResponseTypes: app2.ResponseTypes, + TokenEndpointAuthMethod: app2.TokenEndpointAuthMethod, + Scope: app2.Scope, + Contacts: app2.Contacts, + ClientUri: app2.ClientUri, + LogoUri: app2.LogoUri, + TosUri: app2.TosUri, + PolicyUri: app2.PolicyUri, + JwksUri: app2.JwksUri, + Jwks: app2.Jwks, + SoftwareID: app2.SoftwareID, + SoftwareVersion: app2.SoftwareVersion, + RegistrationAccessToken: app2.RegistrationAccessToken, + RegistrationClientUri: app2.RegistrationClientUri, + UserID: app2.UserID, + Username: sql.NullString{}, + Email: sql.NullString{}, + }, } - check.Args().Asserts(rbac.ResourceOauth2App, policy.ActionRead).Returns(apps) + // fetchWithPostFilter calls RBACObject() on each app automatically + // Pass the actual app objects and let the framework call their RBACObject() method + check.Args(). + Asserts(app1, policy.ActionRead, app2, policy.ActionRead). + OutOfOrder(). + Returns(apps) })) s.Run("GetOAuth2ProviderAppByID", s.Subtest(func(db database.Store, check *expects) { app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{}) - check.Args(app.ID).Asserts(rbac.ResourceOauth2App, policy.ActionRead).Returns(app) + appRow := database.GetOAuth2ProviderAppByIDRow{ + ID: app.ID, + CreatedAt: app.CreatedAt, + UpdatedAt: app.UpdatedAt, + Name: app.Name, + Icon: app.Icon, + RedirectUris: app.RedirectUris, + ClientType: app.ClientType, + DynamicallyRegistered: app.DynamicallyRegistered, + ClientIDIssuedAt: app.ClientIDIssuedAt, + ClientSecretExpiresAt: app.ClientSecretExpiresAt, + GrantTypes: app.GrantTypes, + ResponseTypes: app.ResponseTypes, + TokenEndpointAuthMethod: app.TokenEndpointAuthMethod, + Scope: app.Scope, + Contacts: app.Contacts, + ClientUri: app.ClientUri, + LogoUri: app.LogoUri, + TosUri: app.TosUri, + PolicyUri: app.PolicyUri, + JwksUri: app.JwksUri, + Jwks: app.Jwks, + SoftwareID: app.SoftwareID, + SoftwareVersion: app.SoftwareVersion, + RegistrationAccessToken: app.RegistrationAccessToken, + RegistrationClientUri: app.RegistrationClientUri, + UserID: app.UserID, + Username: sql.NullString{}, + Email: sql.NullString{}, + } + check.Args(app.ID).Asserts(rbac.ResourceOauth2App, policy.ActionRead).Returns(appRow) })) s.Run("GetOAuth2ProviderAppsByUserID", s.Subtest(func(db database.Store, check *expects) { dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) @@ -5353,6 +5448,91 @@ func (s *MethodTestSuite) TestOAuth2ProviderApps() { app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{}) check.Args(app.ID).Asserts(rbac.ResourceOauth2App, policy.ActionDelete) })) + s.Run("GetOAuth2ProviderAppsByOwnerID", s.Subtest(func(db database.Store, check *expects) { + owner := dbgen.User(s.T(), db, database.User{}) + // Create multiple apps with the same owner + app1 := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{ + UserID: uuid.NullUUID{UUID: owner.ID, Valid: true}, + }) + app2 := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{ + UserID: uuid.NullUUID{UUID: owner.ID, Valid: true}, + }) + // Create an app with a different owner to ensure filtering works + differentOwner := dbgen.User(s.T(), db, database.User{}) + _ = dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{ + UserID: uuid.NullUUID{UUID: differentOwner.ID, Valid: true}, + }) + + apps := []database.GetOAuth2ProviderAppsByOwnerIDRow{ + { + ID: app1.ID, + CreatedAt: app1.CreatedAt, + UpdatedAt: app1.UpdatedAt, + Name: app1.Name, + Icon: app1.Icon, + RedirectUris: app1.RedirectUris, + ClientType: app1.ClientType, + DynamicallyRegistered: app1.DynamicallyRegistered, + ClientIDIssuedAt: app1.ClientIDIssuedAt, + ClientSecretExpiresAt: app1.ClientSecretExpiresAt, + GrantTypes: app1.GrantTypes, + ResponseTypes: app1.ResponseTypes, + TokenEndpointAuthMethod: app1.TokenEndpointAuthMethod, + Scope: app1.Scope, + Contacts: app1.Contacts, + ClientUri: app1.ClientUri, + LogoUri: app1.LogoUri, + TosUri: app1.TosUri, + PolicyUri: app1.PolicyUri, + JwksUri: app1.JwksUri, + Jwks: app1.Jwks, + SoftwareID: app1.SoftwareID, + SoftwareVersion: app1.SoftwareVersion, + RegistrationAccessToken: app1.RegistrationAccessToken, + RegistrationClientUri: app1.RegistrationClientUri, + UserID: app1.UserID, + Username: sql.NullString{String: owner.Username, Valid: true}, + Email: sql.NullString{String: owner.Email, Valid: true}, + }, + { + ID: app2.ID, + CreatedAt: app2.CreatedAt, + UpdatedAt: app2.UpdatedAt, + Name: app2.Name, + Icon: app2.Icon, + RedirectUris: app2.RedirectUris, + ClientType: app2.ClientType, + DynamicallyRegistered: app2.DynamicallyRegistered, + ClientIDIssuedAt: app2.ClientIDIssuedAt, + ClientSecretExpiresAt: app2.ClientSecretExpiresAt, + GrantTypes: app2.GrantTypes, + ResponseTypes: app2.ResponseTypes, + TokenEndpointAuthMethod: app2.TokenEndpointAuthMethod, + Scope: app2.Scope, + Contacts: app2.Contacts, + ClientUri: app2.ClientUri, + LogoUri: app2.LogoUri, + TosUri: app2.TosUri, + PolicyUri: app2.PolicyUri, + JwksUri: app2.JwksUri, + Jwks: app2.Jwks, + SoftwareID: app2.SoftwareID, + SoftwareVersion: app2.SoftwareVersion, + RegistrationAccessToken: app2.RegistrationAccessToken, + RegistrationClientUri: app2.RegistrationClientUri, + UserID: app2.UserID, + Username: sql.NullString{String: owner.Username, Valid: true}, + Email: sql.NullString{String: owner.Email, Valid: true}, + }, + } + + // fetchWithPostFilter calls RBACObject() on each app automatically + // Pass the actual app objects and let the framework call their RBACObject() method + check.Args(uuid.NullUUID{UUID: owner.ID, Valid: true}). + Asserts(app1, policy.ActionRead, app2, policy.ActionRead). + OutOfOrder(). + Returns(apps) + })) s.Run("GetOAuth2ProviderAppByClientID", s.Subtest(func(db database.Store, check *expects) { app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{}) check.Args(app.ID).Asserts(rbac.ResourceOauth2App, policy.ActionRead).Returns(app) @@ -5417,27 +5597,31 @@ func (s *MethodTestSuite) TestOAuth2ProviderAppSecrets() { AppID: app2.ID, SecretPrefix: []byte("3"), }) - check.Args(app1.ID).Asserts(rbac.ResourceOauth2AppSecret, policy.ActionRead).Returns(secrets) + // fetchWithPostFilter calls RBACObject() on each secret automatically + // Pass the actual secret objects and let the framework call their RBACObject() method + check.Args(app1.ID). + Asserts(secrets[0], policy.ActionRead, secrets[1], policy.ActionRead). + Returns(secrets) })) s.Run("GetOAuth2ProviderAppSecretByID", s.Subtest(func(db database.Store, check *expects) { app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{}) secret := dbgen.OAuth2ProviderAppSecret(s.T(), db, database.OAuth2ProviderAppSecret{ AppID: app.ID, }) - check.Args(secret.ID).Asserts(rbac.ResourceOauth2AppSecret, policy.ActionRead).Returns(secret) + check.Args(secret.ID).Asserts(rbac.ResourceOauth2AppSecret.WithID(secret.ID), policy.ActionRead).Returns(secret) })) s.Run("GetOAuth2ProviderAppSecretByPrefix", s.Subtest(func(db database.Store, check *expects) { app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{}) secret := dbgen.OAuth2ProviderAppSecret(s.T(), db, database.OAuth2ProviderAppSecret{ AppID: app.ID, }) - check.Args(secret.SecretPrefix).Asserts(rbac.ResourceOauth2AppSecret, policy.ActionRead).Returns(secret) + check.Args(secret.SecretPrefix).Asserts(rbac.ResourceOauth2AppSecret.WithID(secret.ID), policy.ActionRead).Returns(secret) })) s.Run("InsertOAuth2ProviderAppSecret", s.Subtest(func(db database.Store, check *expects) { app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{}) check.Args(database.InsertOAuth2ProviderAppSecretParams{ AppID: app.ID, - }).Asserts(rbac.ResourceOauth2AppSecret, policy.ActionCreate) + }).Asserts(rbac.ResourceOauth2AppSecret.WithID(uuid.Nil), policy.ActionCreate) })) s.Run("UpdateOAuth2ProviderAppSecretByID", s.Subtest(func(db database.Store, check *expects) { dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) @@ -5449,14 +5633,14 @@ func (s *MethodTestSuite) TestOAuth2ProviderAppSecrets() { check.Args(database.UpdateOAuth2ProviderAppSecretByIDParams{ ID: secret.ID, LastUsedAt: secret.LastUsedAt, - }).Asserts(rbac.ResourceOauth2AppSecret, policy.ActionUpdate).Returns(secret) + }).Asserts(rbac.ResourceOauth2AppSecret.WithID(secret.ID), policy.ActionUpdate).Returns(secret) })) s.Run("DeleteOAuth2ProviderAppSecretByID", s.Subtest(func(db database.Store, check *expects) { app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{}) secret := dbgen.OAuth2ProviderAppSecret(s.T(), db, database.OAuth2ProviderAppSecret{ AppID: app.ID, }) - check.Args(secret.ID).Asserts(rbac.ResourceOauth2AppSecret, policy.ActionDelete) + check.Args(secret.ID).Asserts(rbac.ResourceOauth2AppSecret.WithID(secret.ID), policy.ActionDelete) })) } diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index a22de6b174681..06d3d2946161f 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -1231,12 +1231,13 @@ func OAuth2ProviderApp(t testing.TB, db database.Store, seed database.OAuth2Prov func OAuth2ProviderAppSecret(t testing.TB, db database.Store, seed database.OAuth2ProviderAppSecret) database.OAuth2ProviderAppSecret { app, err := db.InsertOAuth2ProviderAppSecret(genCtx, database.InsertOAuth2ProviderAppSecretParams{ - ID: takeFirst(seed.ID, uuid.New()), - CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()), - SecretPrefix: takeFirstSlice(seed.SecretPrefix, []byte("prefix")), - HashedSecret: takeFirstSlice(seed.HashedSecret, []byte("hashed-secret")), - DisplaySecret: takeFirst(seed.DisplaySecret, "secret"), - AppID: takeFirst(seed.AppID, uuid.New()), + ID: takeFirst(seed.ID, uuid.New()), + CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()), + SecretPrefix: takeFirstSlice(seed.SecretPrefix, []byte("prefix")), + HashedSecret: takeFirstSlice(seed.HashedSecret, []byte("hashed-secret")), + DisplaySecret: takeFirst(seed.DisplaySecret, "secret"), + AppID: takeFirst(seed.AppID, uuid.New()), + AppOwnerUserID: takeFirst(seed.AppOwnerUserID, uuid.NullUUID{}), }) require.NoError(t, err, "insert oauth2 app secret") return app diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 3cd55b9ef3c87..502f6d53c7ae9 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1069,7 +1069,7 @@ func (m queryMetricsStore) GetOAuth2ProviderAppByClientID(ctx context.Context, i return r0, r1 } -func (m queryMetricsStore) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) { +func (m queryMetricsStore) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.GetOAuth2ProviderAppByIDRow, error) { start := time.Now() r0, r1 := m.s.GetOAuth2ProviderAppByID(ctx, id) m.queryLatencies.WithLabelValues("GetOAuth2ProviderAppByID").Observe(time.Since(start).Seconds()) @@ -1132,13 +1132,20 @@ func (m queryMetricsStore) GetOAuth2ProviderAppTokenByPrefix(ctx context.Context return r0, r1 } -func (m queryMetricsStore) GetOAuth2ProviderApps(ctx context.Context) ([]database.OAuth2ProviderApp, error) { +func (m queryMetricsStore) GetOAuth2ProviderApps(ctx context.Context) ([]database.GetOAuth2ProviderAppsRow, error) { start := time.Now() r0, r1 := m.s.GetOAuth2ProviderApps(ctx) m.queryLatencies.WithLabelValues("GetOAuth2ProviderApps").Observe(time.Since(start).Seconds()) return r0, r1 } +func (m queryMetricsStore) GetOAuth2ProviderAppsByOwnerID(ctx context.Context, userID uuid.NullUUID) ([]database.GetOAuth2ProviderAppsByOwnerIDRow, error) { + start := time.Now() + r0, r1 := m.s.GetOAuth2ProviderAppsByOwnerID(ctx, userID) + m.queryLatencies.WithLabelValues("GetOAuth2ProviderAppsByOwnerID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetOAuth2ProviderAppsByUserID(ctx context.Context, userID uuid.UUID) ([]database.GetOAuth2ProviderAppsByUserIDRow, error) { start := time.Now() r0, r1 := m.s.GetOAuth2ProviderAppsByUserID(ctx, userID) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 0d36854089e5a..e6a65ea68a689 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2233,10 +2233,10 @@ func (mr *MockStoreMockRecorder) GetOAuth2ProviderAppByClientID(ctx, id any) *go } // GetOAuth2ProviderAppByID mocks base method. -func (m *MockStore) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) { +func (m *MockStore) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.GetOAuth2ProviderAppByIDRow, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetOAuth2ProviderAppByID", ctx, id) - ret0, _ := ret[0].(database.OAuth2ProviderApp) + ret0, _ := ret[0].(database.GetOAuth2ProviderAppByIDRow) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2368,10 +2368,10 @@ func (mr *MockStoreMockRecorder) GetOAuth2ProviderAppTokenByPrefix(ctx, hashPref } // GetOAuth2ProviderApps mocks base method. -func (m *MockStore) GetOAuth2ProviderApps(ctx context.Context) ([]database.OAuth2ProviderApp, error) { +func (m *MockStore) GetOAuth2ProviderApps(ctx context.Context) ([]database.GetOAuth2ProviderAppsRow, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetOAuth2ProviderApps", ctx) - ret0, _ := ret[0].([]database.OAuth2ProviderApp) + ret0, _ := ret[0].([]database.GetOAuth2ProviderAppsRow) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2382,6 +2382,21 @@ func (mr *MockStoreMockRecorder) GetOAuth2ProviderApps(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuth2ProviderApps", reflect.TypeOf((*MockStore)(nil).GetOAuth2ProviderApps), ctx) } +// GetOAuth2ProviderAppsByOwnerID mocks base method. +func (m *MockStore) GetOAuth2ProviderAppsByOwnerID(ctx context.Context, userID uuid.NullUUID) ([]database.GetOAuth2ProviderAppsByOwnerIDRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOAuth2ProviderAppsByOwnerID", ctx, userID) + ret0, _ := ret[0].([]database.GetOAuth2ProviderAppsByOwnerIDRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetOAuth2ProviderAppsByOwnerID indicates an expected call of GetOAuth2ProviderAppsByOwnerID. +func (mr *MockStoreMockRecorder) GetOAuth2ProviderAppsByOwnerID(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuth2ProviderAppsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetOAuth2ProviderAppsByOwnerID), ctx, userID) +} + // GetOAuth2ProviderAppsByUserID mocks base method. func (m *MockStore) GetOAuth2ProviderAppsByUserID(ctx context.Context, userID uuid.UUID) ([]database.GetOAuth2ProviderAppsByUserIDRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index f15a86b97ea52..49e1dc77c70ea 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1192,11 +1192,14 @@ CREATE TABLE oauth2_provider_app_secrets ( hashed_secret bytea NOT NULL, display_secret text NOT NULL, app_id uuid NOT NULL, - secret_prefix bytea NOT NULL + secret_prefix bytea NOT NULL, + app_owner_user_id uuid ); COMMENT ON COLUMN oauth2_provider_app_secrets.display_secret IS 'The tail end of the original secret so secrets can be differentiated.'; +COMMENT ON COLUMN oauth2_provider_app_secrets.app_owner_user_id IS 'Denormalized owner user ID from parent app for efficient authorization without N+1 queries'; + CREATE TABLE oauth2_provider_app_tokens ( id uuid NOT NULL, created_at timestamp with time zone NOT NULL, @@ -1242,12 +1245,12 @@ CREATE TABLE oauth2_provider_apps ( registration_access_token text, registration_client_uri text, user_id uuid, - CONSTRAINT redirect_uris_not_empty CHECK ((cardinality(redirect_uris) > 0)) + CONSTRAINT redirect_uris_not_empty_unless_client_credentials CHECK ((((grant_types = ARRAY['client_credentials'::text]) AND (cardinality(redirect_uris) >= 0)) OR ((grant_types <> ARRAY['client_credentials'::text]) AND (cardinality(redirect_uris) > 0)))) ); COMMENT ON TABLE oauth2_provider_apps IS 'A table used to configure apps that can use Coder as an OAuth2 provider, the reverse of what we are calling external authentication.'; -COMMENT ON COLUMN oauth2_provider_apps.redirect_uris IS 'RFC 6749 compliant list of valid redirect URIs for the application'; +COMMENT ON COLUMN oauth2_provider_apps.redirect_uris IS 'RFC 6749 compliant list of valid redirect URIs for the application. May be empty for client credentials applications.'; COMMENT ON COLUMN oauth2_provider_apps.client_type IS 'OAuth2 client type: confidential or public'; @@ -2872,6 +2875,8 @@ CREATE INDEX idx_inbox_notifications_user_id_template_id_targets ON inbox_notifi CREATE INDEX idx_notification_messages_status ON notification_messages USING btree (status); +CREATE INDEX idx_oauth2_provider_app_secrets_app_owner_user_id ON oauth2_provider_app_secrets USING btree (app_owner_user_id); + CREATE INDEX idx_oauth2_provider_device_codes_cleanup ON oauth2_provider_device_codes USING btree (expires_at); CREATE INDEX idx_oauth2_provider_device_codes_client_id ON oauth2_provider_device_codes USING btree (client_id); diff --git a/coderd/database/migrations/000359_allow_empty_redirect_uris_for_client_credentials.down.sql b/coderd/database/migrations/000359_allow_empty_redirect_uris_for_client_credentials.down.sql new file mode 100644 index 0000000000000..8deac0a0524b8 --- /dev/null +++ b/coderd/database/migrations/000359_allow_empty_redirect_uris_for_client_credentials.down.sql @@ -0,0 +1,16 @@ +-- Restore the original constraint that requires at least one redirect URI +-- This will fail if there are existing client credentials applications with empty redirect URIs +ALTER TABLE oauth2_provider_apps + DROP CONSTRAINT IF EXISTS redirect_uris_not_empty_unless_client_credentials; + +ALTER TABLE oauth2_provider_apps + ADD CONSTRAINT redirect_uris_not_empty CHECK (cardinality(redirect_uris) > 0); + +COMMENT ON COLUMN oauth2_provider_apps.redirect_uris IS 'RFC 6749 compliant list of valid redirect URIs for the application'; + +-- Remove the denormalized app_owner_user_id column +DROP INDEX IF EXISTS idx_oauth2_provider_app_secrets_app_owner_user_id; + +ALTER TABLE oauth2_provider_app_secrets + DROP COLUMN IF EXISTS app_owner_user_id; + diff --git a/coderd/database/migrations/000359_allow_empty_redirect_uris_for_client_credentials.up.sql b/coderd/database/migrations/000359_allow_empty_redirect_uris_for_client_credentials.up.sql new file mode 100644 index 0000000000000..69c71f31a4272 --- /dev/null +++ b/coderd/database/migrations/000359_allow_empty_redirect_uris_for_client_credentials.up.sql @@ -0,0 +1,31 @@ +-- Allow empty redirect URIs for client credentials applications +-- Client credentials flow doesn't require redirect URIs (RFC 6749 Section 4.4) +-- This replaces the simple constraint with a more sophisticated one +ALTER TABLE oauth2_provider_apps + DROP CONSTRAINT IF EXISTS redirect_uris_not_empty; + +-- Add a more sophisticated constraint that allows empty redirect URIs only for client credentials applications +-- For client credentials applications (grant_types = ['client_credentials']), redirect URIs can be empty +-- For all other grant types, at least one redirect URI is required +ALTER TABLE oauth2_provider_apps + ADD CONSTRAINT redirect_uris_not_empty_unless_client_credentials CHECK ((grant_types = ARRAY['client_credentials'::text] AND cardinality(redirect_uris) >= 0) OR (grant_types != ARRAY['client_credentials'::text] AND cardinality(redirect_uris) > 0)); + +COMMENT ON COLUMN oauth2_provider_apps.redirect_uris IS 'RFC 6749 compliant list of valid redirect URIs for the application. May be empty for client credentials applications.'; + +-- Add app_owner_user_id column to oauth2_provider_app_secrets for denormalization +-- This avoids N+1 queries when authorizing secret operations +ALTER TABLE oauth2_provider_app_secrets + ADD COLUMN app_owner_user_id UUID NULL; + +-- Populate the new column with existing data from parent apps +UPDATE oauth2_provider_app_secrets +SET app_owner_user_id = oauth2_provider_apps.user_id +FROM oauth2_provider_apps +WHERE oauth2_provider_app_secrets.app_id = oauth2_provider_apps.id; + +-- Add index for efficient authorization queries +CREATE INDEX IF NOT EXISTS idx_oauth2_provider_app_secrets_app_owner_user_id + ON oauth2_provider_app_secrets(app_owner_user_id); + +COMMENT ON COLUMN oauth2_provider_app_secrets.app_owner_user_id IS 'Denormalized owner user ID from parent app for efficient authorization without N+1 queries'; + diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index cc87fe1cd2f7c..4002a0339e64c 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -404,11 +404,17 @@ func (t OAuth2ProviderAppToken) RBACObject() rbac.Object { return rbac.ResourceOauth2AppCodeToken.WithOwner(t.UserID.String()).WithID(t.ID) } -func (OAuth2ProviderAppSecret) RBACObject() rbac.Object { - return rbac.ResourceOauth2AppSecret +func (s OAuth2ProviderAppSecret) RBACObject() rbac.Object { + if s.AppOwnerUserID.Valid { + return rbac.ResourceOauth2AppSecret.WithID(s.ID).WithOwner(s.AppOwnerUserID.UUID.String()) + } + return rbac.ResourceOauth2AppSecret.WithID(s.ID) } -func (OAuth2ProviderApp) RBACObject() rbac.Object { +func (app OAuth2ProviderApp) RBACObject() rbac.Object { + if app.UserID.Valid { + return rbac.ResourceOauth2App.WithOwner(app.UserID.UUID.String()) + } return rbac.ResourceOauth2App } @@ -416,6 +422,34 @@ func (a GetOAuth2ProviderAppsByUserIDRow) RBACObject() rbac.Object { return a.OAuth2ProviderApp.RBACObject() } +func (a GetOAuth2ProviderAppByIDRow) RBACObject() rbac.Object { + if a.UserID.Valid { + return rbac.ResourceOauth2App.WithOwner(a.UserID.UUID.String()) + } + return rbac.ResourceOauth2App +} + +func (a GetOAuth2ProviderAppsRow) RBACObject() rbac.Object { + if a.UserID.Valid { + return rbac.ResourceOauth2App.WithOwner(a.UserID.UUID.String()) + } + return rbac.ResourceOauth2App +} + +func (params InsertOAuth2ProviderAppParams) RBACObject() rbac.Object { + if params.UserID.Valid { + return rbac.ResourceOauth2App.WithOwner(params.UserID.UUID.String()) + } + return rbac.ResourceOauth2App +} + +func (a GetOAuth2ProviderAppsByOwnerIDRow) RBACObject() rbac.Object { + if a.UserID.Valid { + return rbac.ResourceOauth2App.WithOwner(a.UserID.UUID.String()) + } + return rbac.ResourceOauth2App +} + func (d OAuth2ProviderDeviceCode) RBACObject() rbac.Object { // Device codes are similar to OAuth2 app code tokens if d.UserID.Valid { diff --git a/coderd/database/models.go b/coderd/database/models.go index 900fa3aaa89c9..031863a3007ee 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3274,7 +3274,7 @@ type OAuth2ProviderApp struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` Name string `db:"name" json:"name"` Icon string `db:"icon" json:"icon"` - // RFC 6749 compliant list of valid redirect URIs for the application + // RFC 6749 compliant list of valid redirect URIs for the application. May be empty for client credentials applications. RedirectUris []string `db:"redirect_uris" json:"redirect_uris"` // OAuth2 client type: confidential or public ClientType sql.NullString `db:"client_type" json:"client_type"` @@ -3343,6 +3343,8 @@ type OAuth2ProviderAppSecret struct { DisplaySecret string `db:"display_secret" json:"display_secret"` AppID uuid.UUID `db:"app_id" json:"app_id"` SecretPrefix []byte `db:"secret_prefix" json:"secret_prefix"` + // Denormalized owner user ID from parent app for efficient authorization without N+1 queries + AppOwnerUserID uuid.NullUUID `db:"app_owner_user_id" json:"app_owner_user_id"` } type OAuth2ProviderAppToken struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 33d0d73f42b05..d587dc9798afa 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -234,7 +234,7 @@ type sqlcQuerier interface { GetOAuth2GithubDefaultEligible(ctx context.Context) (bool, error) // RFC 7591/7592 Dynamic Client Registration queries GetOAuth2ProviderAppByClientID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) - GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) + GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (GetOAuth2ProviderAppByIDRow, error) GetOAuth2ProviderAppByRegistrationToken(ctx context.Context, registrationAccessToken sql.NullString) (OAuth2ProviderApp, error) GetOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppCode, error) GetOAuth2ProviderAppCodeByPrefix(ctx context.Context, secretPrefix []byte) (OAuth2ProviderAppCode, error) @@ -243,7 +243,8 @@ type sqlcQuerier interface { GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, appID uuid.UUID) ([]OAuth2ProviderAppSecret, error) GetOAuth2ProviderAppTokenByAPIKeyID(ctx context.Context, apiKeyID string) (OAuth2ProviderAppToken, error) GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, hashPrefix []byte) (OAuth2ProviderAppToken, error) - GetOAuth2ProviderApps(ctx context.Context) ([]OAuth2ProviderApp, error) + GetOAuth2ProviderApps(ctx context.Context) ([]GetOAuth2ProviderAppsRow, error) + GetOAuth2ProviderAppsByOwnerID(ctx context.Context, userID uuid.NullUUID) ([]GetOAuth2ProviderAppsByOwnerIDRow, error) GetOAuth2ProviderAppsByUserID(ctx context.Context, userID uuid.UUID) ([]GetOAuth2ProviderAppsByUserIDRow, error) GetOAuth2ProviderDeviceCodeByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderDeviceCode, error) GetOAuth2ProviderDeviceCodeByPrefix(ctx context.Context, deviceCodePrefix string) (OAuth2ProviderDeviceCode, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index d9249b84622b7..db103c811b986 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5565,12 +5565,49 @@ func (q *sqlQuerier) GetOAuth2ProviderAppByClientID(ctx context.Context, id uuid } const getOAuth2ProviderAppByID = `-- name: GetOAuth2ProviderAppByID :one -SELECT id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, policy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id FROM oauth2_provider_apps WHERE id = $1 +SELECT + oauth2_provider_apps.id, oauth2_provider_apps.created_at, oauth2_provider_apps.updated_at, oauth2_provider_apps.name, oauth2_provider_apps.icon, oauth2_provider_apps.redirect_uris, oauth2_provider_apps.client_type, oauth2_provider_apps.dynamically_registered, oauth2_provider_apps.client_id_issued_at, oauth2_provider_apps.client_secret_expires_at, oauth2_provider_apps.grant_types, oauth2_provider_apps.response_types, oauth2_provider_apps.token_endpoint_auth_method, oauth2_provider_apps.scope, oauth2_provider_apps.contacts, oauth2_provider_apps.client_uri, oauth2_provider_apps.logo_uri, oauth2_provider_apps.tos_uri, oauth2_provider_apps.policy_uri, oauth2_provider_apps.jwks_uri, oauth2_provider_apps.jwks, oauth2_provider_apps.software_id, oauth2_provider_apps.software_version, oauth2_provider_apps.registration_access_token, oauth2_provider_apps.registration_client_uri, oauth2_provider_apps.user_id, + users.username, + users.email +FROM oauth2_provider_apps +LEFT JOIN users ON oauth2_provider_apps.user_id = users.id +WHERE oauth2_provider_apps.id = $1 ` -func (q *sqlQuerier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) { +type GetOAuth2ProviderAppByIDRow struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Name string `db:"name" json:"name"` + Icon string `db:"icon" json:"icon"` + RedirectUris []string `db:"redirect_uris" json:"redirect_uris"` + ClientType sql.NullString `db:"client_type" json:"client_type"` + DynamicallyRegistered sql.NullBool `db:"dynamically_registered" json:"dynamically_registered"` + ClientIDIssuedAt sql.NullTime `db:"client_id_issued_at" json:"client_id_issued_at"` + ClientSecretExpiresAt sql.NullTime `db:"client_secret_expires_at" json:"client_secret_expires_at"` + GrantTypes []string `db:"grant_types" json:"grant_types"` + ResponseTypes []string `db:"response_types" json:"response_types"` + TokenEndpointAuthMethod sql.NullString `db:"token_endpoint_auth_method" json:"token_endpoint_auth_method"` + Scope sql.NullString `db:"scope" json:"scope"` + Contacts []string `db:"contacts" json:"contacts"` + ClientUri sql.NullString `db:"client_uri" json:"client_uri"` + LogoUri sql.NullString `db:"logo_uri" json:"logo_uri"` + TosUri sql.NullString `db:"tos_uri" json:"tos_uri"` + PolicyUri sql.NullString `db:"policy_uri" json:"policy_uri"` + JwksUri sql.NullString `db:"jwks_uri" json:"jwks_uri"` + Jwks pqtype.NullRawMessage `db:"jwks" json:"jwks"` + SoftwareID sql.NullString `db:"software_id" json:"software_id"` + SoftwareVersion sql.NullString `db:"software_version" json:"software_version"` + RegistrationAccessToken sql.NullString `db:"registration_access_token" json:"registration_access_token"` + RegistrationClientUri sql.NullString `db:"registration_client_uri" json:"registration_client_uri"` + UserID uuid.NullUUID `db:"user_id" json:"user_id"` + Username sql.NullString `db:"username" json:"username"` + Email sql.NullString `db:"email" json:"email"` +} + +func (q *sqlQuerier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (GetOAuth2ProviderAppByIDRow, error) { row := q.db.QueryRowContext(ctx, getOAuth2ProviderAppByID, id) - var i OAuth2ProviderApp + var i GetOAuth2ProviderAppByIDRow err := row.Scan( &i.ID, &i.CreatedAt, @@ -5598,6 +5635,8 @@ func (q *sqlQuerier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) &i.RegistrationAccessToken, &i.RegistrationClientUri, &i.UserID, + &i.Username, + &i.Email, ) return i, err } @@ -5685,7 +5724,7 @@ func (q *sqlQuerier) GetOAuth2ProviderAppCodeByPrefix(ctx context.Context, secre } const getOAuth2ProviderAppSecretByID = `-- name: GetOAuth2ProviderAppSecretByID :one -SELECT id, created_at, last_used_at, hashed_secret, display_secret, app_id, secret_prefix FROM oauth2_provider_app_secrets WHERE id = $1 +SELECT id, created_at, last_used_at, hashed_secret, display_secret, app_id, secret_prefix, app_owner_user_id FROM oauth2_provider_app_secrets WHERE id = $1 ` func (q *sqlQuerier) GetOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppSecret, error) { @@ -5699,12 +5738,13 @@ func (q *sqlQuerier) GetOAuth2ProviderAppSecretByID(ctx context.Context, id uuid &i.DisplaySecret, &i.AppID, &i.SecretPrefix, + &i.AppOwnerUserID, ) return i, err } const getOAuth2ProviderAppSecretByPrefix = `-- name: GetOAuth2ProviderAppSecretByPrefix :one -SELECT id, created_at, last_used_at, hashed_secret, display_secret, app_id, secret_prefix FROM oauth2_provider_app_secrets WHERE secret_prefix = $1 +SELECT id, created_at, last_used_at, hashed_secret, display_secret, app_id, secret_prefix, app_owner_user_id FROM oauth2_provider_app_secrets WHERE secret_prefix = $1 ` func (q *sqlQuerier) GetOAuth2ProviderAppSecretByPrefix(ctx context.Context, secretPrefix []byte) (OAuth2ProviderAppSecret, error) { @@ -5718,12 +5758,13 @@ func (q *sqlQuerier) GetOAuth2ProviderAppSecretByPrefix(ctx context.Context, sec &i.DisplaySecret, &i.AppID, &i.SecretPrefix, + &i.AppOwnerUserID, ) return i, err } const getOAuth2ProviderAppSecretsByAppID = `-- name: GetOAuth2ProviderAppSecretsByAppID :many -SELECT id, created_at, last_used_at, hashed_secret, display_secret, app_id, secret_prefix FROM oauth2_provider_app_secrets WHERE app_id = $1 ORDER BY (created_at, id) ASC +SELECT id, created_at, last_used_at, hashed_secret, display_secret, app_id, secret_prefix, app_owner_user_id FROM oauth2_provider_app_secrets WHERE app_id = $1 ORDER BY (created_at, id) ASC ` func (q *sqlQuerier) GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, appID uuid.UUID) ([]OAuth2ProviderAppSecret, error) { @@ -5743,6 +5784,7 @@ func (q *sqlQuerier) GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, app &i.DisplaySecret, &i.AppID, &i.SecretPrefix, + &i.AppOwnerUserID, ); err != nil { return nil, err } @@ -5800,18 +5842,149 @@ func (q *sqlQuerier) GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, hash } const getOAuth2ProviderApps = `-- name: GetOAuth2ProviderApps :many -SELECT id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, policy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id FROM oauth2_provider_apps ORDER BY (name, id) ASC +SELECT + oauth2_provider_apps.id, oauth2_provider_apps.created_at, oauth2_provider_apps.updated_at, oauth2_provider_apps.name, oauth2_provider_apps.icon, oauth2_provider_apps.redirect_uris, oauth2_provider_apps.client_type, oauth2_provider_apps.dynamically_registered, oauth2_provider_apps.client_id_issued_at, oauth2_provider_apps.client_secret_expires_at, oauth2_provider_apps.grant_types, oauth2_provider_apps.response_types, oauth2_provider_apps.token_endpoint_auth_method, oauth2_provider_apps.scope, oauth2_provider_apps.contacts, oauth2_provider_apps.client_uri, oauth2_provider_apps.logo_uri, oauth2_provider_apps.tos_uri, oauth2_provider_apps.policy_uri, oauth2_provider_apps.jwks_uri, oauth2_provider_apps.jwks, oauth2_provider_apps.software_id, oauth2_provider_apps.software_version, oauth2_provider_apps.registration_access_token, oauth2_provider_apps.registration_client_uri, oauth2_provider_apps.user_id, + users.username, + users.email +FROM oauth2_provider_apps +LEFT JOIN users ON oauth2_provider_apps.user_id = users.id +ORDER BY (oauth2_provider_apps.name, oauth2_provider_apps.id) ASC ` -func (q *sqlQuerier) GetOAuth2ProviderApps(ctx context.Context) ([]OAuth2ProviderApp, error) { +type GetOAuth2ProviderAppsRow struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Name string `db:"name" json:"name"` + Icon string `db:"icon" json:"icon"` + RedirectUris []string `db:"redirect_uris" json:"redirect_uris"` + ClientType sql.NullString `db:"client_type" json:"client_type"` + DynamicallyRegistered sql.NullBool `db:"dynamically_registered" json:"dynamically_registered"` + ClientIDIssuedAt sql.NullTime `db:"client_id_issued_at" json:"client_id_issued_at"` + ClientSecretExpiresAt sql.NullTime `db:"client_secret_expires_at" json:"client_secret_expires_at"` + GrantTypes []string `db:"grant_types" json:"grant_types"` + ResponseTypes []string `db:"response_types" json:"response_types"` + TokenEndpointAuthMethod sql.NullString `db:"token_endpoint_auth_method" json:"token_endpoint_auth_method"` + Scope sql.NullString `db:"scope" json:"scope"` + Contacts []string `db:"contacts" json:"contacts"` + ClientUri sql.NullString `db:"client_uri" json:"client_uri"` + LogoUri sql.NullString `db:"logo_uri" json:"logo_uri"` + TosUri sql.NullString `db:"tos_uri" json:"tos_uri"` + PolicyUri sql.NullString `db:"policy_uri" json:"policy_uri"` + JwksUri sql.NullString `db:"jwks_uri" json:"jwks_uri"` + Jwks pqtype.NullRawMessage `db:"jwks" json:"jwks"` + SoftwareID sql.NullString `db:"software_id" json:"software_id"` + SoftwareVersion sql.NullString `db:"software_version" json:"software_version"` + RegistrationAccessToken sql.NullString `db:"registration_access_token" json:"registration_access_token"` + RegistrationClientUri sql.NullString `db:"registration_client_uri" json:"registration_client_uri"` + UserID uuid.NullUUID `db:"user_id" json:"user_id"` + Username sql.NullString `db:"username" json:"username"` + Email sql.NullString `db:"email" json:"email"` +} + +func (q *sqlQuerier) GetOAuth2ProviderApps(ctx context.Context) ([]GetOAuth2ProviderAppsRow, error) { rows, err := q.db.QueryContext(ctx, getOAuth2ProviderApps) if err != nil { return nil, err } defer rows.Close() - var items []OAuth2ProviderApp + var items []GetOAuth2ProviderAppsRow + for rows.Next() { + var i GetOAuth2ProviderAppsRow + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Name, + &i.Icon, + pq.Array(&i.RedirectUris), + &i.ClientType, + &i.DynamicallyRegistered, + &i.ClientIDIssuedAt, + &i.ClientSecretExpiresAt, + pq.Array(&i.GrantTypes), + pq.Array(&i.ResponseTypes), + &i.TokenEndpointAuthMethod, + &i.Scope, + pq.Array(&i.Contacts), + &i.ClientUri, + &i.LogoUri, + &i.TosUri, + &i.PolicyUri, + &i.JwksUri, + &i.Jwks, + &i.SoftwareID, + &i.SoftwareVersion, + &i.RegistrationAccessToken, + &i.RegistrationClientUri, + &i.UserID, + &i.Username, + &i.Email, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getOAuth2ProviderAppsByOwnerID = `-- name: GetOAuth2ProviderAppsByOwnerID :many +SELECT + oauth2_provider_apps.id, oauth2_provider_apps.created_at, oauth2_provider_apps.updated_at, oauth2_provider_apps.name, oauth2_provider_apps.icon, oauth2_provider_apps.redirect_uris, oauth2_provider_apps.client_type, oauth2_provider_apps.dynamically_registered, oauth2_provider_apps.client_id_issued_at, oauth2_provider_apps.client_secret_expires_at, oauth2_provider_apps.grant_types, oauth2_provider_apps.response_types, oauth2_provider_apps.token_endpoint_auth_method, oauth2_provider_apps.scope, oauth2_provider_apps.contacts, oauth2_provider_apps.client_uri, oauth2_provider_apps.logo_uri, oauth2_provider_apps.tos_uri, oauth2_provider_apps.policy_uri, oauth2_provider_apps.jwks_uri, oauth2_provider_apps.jwks, oauth2_provider_apps.software_id, oauth2_provider_apps.software_version, oauth2_provider_apps.registration_access_token, oauth2_provider_apps.registration_client_uri, oauth2_provider_apps.user_id, + users.username, + users.email +FROM oauth2_provider_apps +LEFT JOIN users ON oauth2_provider_apps.user_id = users.id +WHERE oauth2_provider_apps.user_id = $1 +ORDER BY (oauth2_provider_apps.name, oauth2_provider_apps.id) ASC +` + +type GetOAuth2ProviderAppsByOwnerIDRow struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Name string `db:"name" json:"name"` + Icon string `db:"icon" json:"icon"` + RedirectUris []string `db:"redirect_uris" json:"redirect_uris"` + ClientType sql.NullString `db:"client_type" json:"client_type"` + DynamicallyRegistered sql.NullBool `db:"dynamically_registered" json:"dynamically_registered"` + ClientIDIssuedAt sql.NullTime `db:"client_id_issued_at" json:"client_id_issued_at"` + ClientSecretExpiresAt sql.NullTime `db:"client_secret_expires_at" json:"client_secret_expires_at"` + GrantTypes []string `db:"grant_types" json:"grant_types"` + ResponseTypes []string `db:"response_types" json:"response_types"` + TokenEndpointAuthMethod sql.NullString `db:"token_endpoint_auth_method" json:"token_endpoint_auth_method"` + Scope sql.NullString `db:"scope" json:"scope"` + Contacts []string `db:"contacts" json:"contacts"` + ClientUri sql.NullString `db:"client_uri" json:"client_uri"` + LogoUri sql.NullString `db:"logo_uri" json:"logo_uri"` + TosUri sql.NullString `db:"tos_uri" json:"tos_uri"` + PolicyUri sql.NullString `db:"policy_uri" json:"policy_uri"` + JwksUri sql.NullString `db:"jwks_uri" json:"jwks_uri"` + Jwks pqtype.NullRawMessage `db:"jwks" json:"jwks"` + SoftwareID sql.NullString `db:"software_id" json:"software_id"` + SoftwareVersion sql.NullString `db:"software_version" json:"software_version"` + RegistrationAccessToken sql.NullString `db:"registration_access_token" json:"registration_access_token"` + RegistrationClientUri sql.NullString `db:"registration_client_uri" json:"registration_client_uri"` + UserID uuid.NullUUID `db:"user_id" json:"user_id"` + Username sql.NullString `db:"username" json:"username"` + Email sql.NullString `db:"email" json:"email"` +} + +func (q *sqlQuerier) GetOAuth2ProviderAppsByOwnerID(ctx context.Context, userID uuid.NullUUID) ([]GetOAuth2ProviderAppsByOwnerIDRow, error) { + rows, err := q.db.QueryContext(ctx, getOAuth2ProviderAppsByOwnerID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetOAuth2ProviderAppsByOwnerIDRow for rows.Next() { - var i OAuth2ProviderApp + var i GetOAuth2ProviderAppsByOwnerIDRow if err := rows.Scan( &i.ID, &i.CreatedAt, @@ -5839,6 +6012,8 @@ func (q *sqlQuerier) GetOAuth2ProviderApps(ctx context.Context) ([]OAuth2Provide &i.RegistrationAccessToken, &i.RegistrationClientUri, &i.UserID, + &i.Username, + &i.Email, ); err != nil { return nil, err } @@ -6269,24 +6444,27 @@ INSERT INTO oauth2_provider_app_secrets ( secret_prefix, hashed_secret, display_secret, - app_id + app_id, + app_owner_user_id ) VALUES( $1, $2, $3, $4, $5, - $6 -) RETURNING id, created_at, last_used_at, hashed_secret, display_secret, app_id, secret_prefix + $6, + $7 +) RETURNING id, created_at, last_used_at, hashed_secret, display_secret, app_id, secret_prefix, app_owner_user_id ` type InsertOAuth2ProviderAppSecretParams struct { - ID uuid.UUID `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - SecretPrefix []byte `db:"secret_prefix" json:"secret_prefix"` - HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` - DisplaySecret string `db:"display_secret" json:"display_secret"` - AppID uuid.UUID `db:"app_id" json:"app_id"` + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + SecretPrefix []byte `db:"secret_prefix" json:"secret_prefix"` + HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` + DisplaySecret string `db:"display_secret" json:"display_secret"` + AppID uuid.UUID `db:"app_id" json:"app_id"` + AppOwnerUserID uuid.NullUUID `db:"app_owner_user_id" json:"app_owner_user_id"` } func (q *sqlQuerier) InsertOAuth2ProviderAppSecret(ctx context.Context, arg InsertOAuth2ProviderAppSecretParams) (OAuth2ProviderAppSecret, error) { @@ -6297,6 +6475,7 @@ func (q *sqlQuerier) InsertOAuth2ProviderAppSecret(ctx context.Context, arg Inse arg.HashedSecret, arg.DisplaySecret, arg.AppID, + arg.AppOwnerUserID, ) var i OAuth2ProviderAppSecret err := row.Scan( @@ -6307,6 +6486,7 @@ func (q *sqlQuerier) InsertOAuth2ProviderAppSecret(ctx context.Context, arg Inse &i.DisplaySecret, &i.AppID, &i.SecretPrefix, + &i.AppOwnerUserID, ) return i, err } @@ -6666,7 +6846,7 @@ func (q *sqlQuerier) UpdateOAuth2ProviderAppByID(ctx context.Context, arg Update const updateOAuth2ProviderAppSecretByID = `-- name: UpdateOAuth2ProviderAppSecretByID :one UPDATE oauth2_provider_app_secrets SET last_used_at = $2 -WHERE id = $1 RETURNING id, created_at, last_used_at, hashed_secret, display_secret, app_id, secret_prefix +WHERE id = $1 RETURNING id, created_at, last_used_at, hashed_secret, display_secret, app_id, secret_prefix, app_owner_user_id ` type UpdateOAuth2ProviderAppSecretByIDParams struct { @@ -6685,6 +6865,7 @@ func (q *sqlQuerier) UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg &i.DisplaySecret, &i.AppID, &i.SecretPrefix, + &i.AppOwnerUserID, ) return i, err } diff --git a/coderd/database/queries/oauth2.sql b/coderd/database/queries/oauth2.sql index b58b5f198b774..f262596c27108 100644 --- a/coderd/database/queries/oauth2.sql +++ b/coderd/database/queries/oauth2.sql @@ -1,8 +1,30 @@ -- name: GetOAuth2ProviderApps :many -SELECT * FROM oauth2_provider_apps ORDER BY (name, id) ASC; +SELECT + oauth2_provider_apps.*, + users.username, + users.email +FROM oauth2_provider_apps +LEFT JOIN users ON oauth2_provider_apps.user_id = users.id +ORDER BY (oauth2_provider_apps.name, oauth2_provider_apps.id) ASC; -- name: GetOAuth2ProviderAppByID :one -SELECT * FROM oauth2_provider_apps WHERE id = $1; +SELECT + oauth2_provider_apps.*, + users.username, + users.email +FROM oauth2_provider_apps +LEFT JOIN users ON oauth2_provider_apps.user_id = users.id +WHERE oauth2_provider_apps.id = $1; + +-- name: GetOAuth2ProviderAppsByOwnerID :many +SELECT + oauth2_provider_apps.*, + users.username, + users.email +FROM oauth2_provider_apps +LEFT JOIN users ON oauth2_provider_apps.user_id = users.id +WHERE oauth2_provider_apps.user_id = $1 +ORDER BY (oauth2_provider_apps.name, oauth2_provider_apps.id) ASC; -- name: InsertOAuth2ProviderApp :one INSERT INTO oauth2_provider_apps ( @@ -104,14 +126,16 @@ INSERT INTO oauth2_provider_app_secrets ( secret_prefix, hashed_secret, display_secret, - app_id + app_id, + app_owner_user_id ) VALUES( $1, $2, $3, $4, $5, - $6 + $6, + $7 ) RETURNING *; -- name: UpdateOAuth2ProviderAppSecretByID :one diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index d569a6ad0067a..7e1be737ae673 100644 --- a/coderd/httpmw/oauth2.go +++ b/coderd/httpmw/oauth2.go @@ -195,8 +195,45 @@ type ( ) // OAuth2ProviderApp returns the OAuth2 app from the ExtractOAuth2ProviderAppParam handler. +// This returns the base OAuth2ProviderApp type for backward compatibility with existing code. func OAuth2ProviderApp(r *http.Request) database.OAuth2ProviderApp { - app, ok := r.Context().Value(oauth2ProviderAppParamContextKey{}).(database.OAuth2ProviderApp) + appRow := OAuth2ProviderAppRow(r) + + // Convert to base type for backward compatibility + return database.OAuth2ProviderApp{ + ID: appRow.ID, + CreatedAt: appRow.CreatedAt, + UpdatedAt: appRow.UpdatedAt, + Name: appRow.Name, + Icon: appRow.Icon, + RedirectUris: appRow.RedirectUris, + ClientType: appRow.ClientType, + DynamicallyRegistered: appRow.DynamicallyRegistered, + ClientIDIssuedAt: appRow.ClientIDIssuedAt, + ClientSecretExpiresAt: appRow.ClientSecretExpiresAt, + GrantTypes: appRow.GrantTypes, + ResponseTypes: appRow.ResponseTypes, + TokenEndpointAuthMethod: appRow.TokenEndpointAuthMethod, + Scope: appRow.Scope, + Contacts: appRow.Contacts, + ClientUri: appRow.ClientUri, + LogoUri: appRow.LogoUri, + TosUri: appRow.TosUri, + PolicyUri: appRow.PolicyUri, + JwksUri: appRow.JwksUri, + Jwks: appRow.Jwks, + SoftwareID: appRow.SoftwareID, + SoftwareVersion: appRow.SoftwareVersion, + RegistrationAccessToken: appRow.RegistrationAccessToken, + RegistrationClientUri: appRow.RegistrationClientUri, + UserID: appRow.UserID, + } +} + +// OAuth2ProviderAppRow returns the full OAuth2 app row from the ExtractOAuth2ProviderAppParam handler. +// This includes username and email fields for API responses that need them. +func OAuth2ProviderAppRow(r *http.Request) database.GetOAuth2ProviderAppByIDRow { + app, ok := r.Context().Value(oauth2ProviderAppParamContextKey{}).(database.GetOAuth2ProviderAppByIDRow) if !ok { panic("developer error: oauth2 app param middleware not provided") } @@ -322,6 +359,7 @@ func extractOAuth2ProviderAppBase(db database.Store, errWriter errorWriter) func }) return } + ctx = context.WithValue(ctx, oauth2ProviderAppParamContextKey{}, app) next.ServeHTTP(rw, r.WithContext(ctx)) }) diff --git a/coderd/oauth2.go b/coderd/oauth2.go index 94e1224e01f75..6701b329187dc 100644 --- a/coderd/oauth2.go +++ b/coderd/oauth2.go @@ -12,10 +12,11 @@ import ( // @Produce json // @Tags Enterprise // @Param user_id query string false "Filter by applications authorized for a user" +// @Param owner_id query string false "Filter by applications owned by a user" // @Success 200 {array} codersdk.OAuth2ProviderApp // @Router /oauth2-provider/apps [get] func (api *API) oAuth2ProviderApps() http.HandlerFunc { - return oauth2provider.ListApps(api.Database, api.AccessURL) + return oauth2provider.ListApps(api.Database, api.AccessURL, api.Logger) } // @Summary Get OAuth2 application. @@ -77,7 +78,7 @@ func (api *API) deleteOAuth2ProviderApp() http.HandlerFunc { // @Success 200 {array} codersdk.OAuth2ProviderAppSecret // @Router /oauth2-provider/apps/{app}/secrets [get] func (api *API) oAuth2ProviderAppSecrets() http.HandlerFunc { - return oauth2provider.GetAppSecrets(api.Database) + return oauth2provider.GetAppSecrets(api.Database, api.Logger) } // @Summary Create OAuth2 application secret. diff --git a/coderd/oauth2provider/app_secrets.go b/coderd/oauth2provider/app_secrets.go index 5549ece4266f2..974025240737e 100644 --- a/coderd/oauth2provider/app_secrets.go +++ b/coderd/oauth2provider/app_secrets.go @@ -16,12 +16,13 @@ import ( ) // GetAppSecrets returns an http.HandlerFunc that handles GET /oauth2-provider/apps/{app}/secrets -func GetAppSecrets(db database.Store) http.HandlerFunc { +func GetAppSecrets(db database.Store, logger slog.Logger) http.HandlerFunc { return func(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() app := httpmw.OAuth2ProviderApp(r) dbSecrets, err := db.GetOAuth2ProviderAppSecretsByAppID(ctx, app.ID) if err != nil { + logger.Error(ctx, "failed to get OAuth2 client secrets", slog.Error(err), slog.F("app_id", app.ID)) httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error getting OAuth2 client secrets.", Detail: err.Error(), @@ -56,6 +57,7 @@ func CreateAppSecret(db database.Store, auditor *audit.Auditor, logger slog.Logg defer commitAudit() secret, err := GenerateSecret() if err != nil { + logger.Error(ctx, "failed to generate OAuth2 client secret", slog.Error(err), slog.F("app_id", app.ID)) httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to generate OAuth2 client secret.", Detail: err.Error(), @@ -70,10 +72,12 @@ func CreateAppSecret(db database.Store, auditor *audit.Auditor, logger slog.Logg // DisplaySecret is the last six characters of the original unhashed secret. // This is done so they can be differentiated and it matches how GitHub // displays their client secrets. - DisplaySecret: secret.Formatted[len(secret.Formatted)-6:], - AppID: app.ID, + DisplaySecret: secret.Formatted[len(secret.Formatted)-6:], + AppID: app.ID, + AppOwnerUserID: app.UserID, }) if err != nil { + logger.Error(ctx, "failed to create OAuth2 client secret", slog.Error(err), slog.F("app_id", app.ID)) httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error creating OAuth2 client secret.", Detail: err.Error(), @@ -105,6 +109,7 @@ func DeleteAppSecret(db database.Store, auditor *audit.Auditor, logger slog.Logg defer commitAudit() err := db.DeleteOAuth2ProviderAppSecretByID(ctx, secret.ID) if err != nil { + logger.Error(ctx, "failed to delete OAuth2 client secret", slog.Error(err), slog.F("secret_id", secret.ID), slog.F("app_id", secret.AppID)) httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error deleting OAuth2 client secret.", Detail: err.Error(), diff --git a/coderd/oauth2provider/apps.go b/coderd/oauth2provider/apps.go index 2533e33da77c1..8f3a3899370c5 100644 --- a/coderd/oauth2provider/apps.go +++ b/coderd/oauth2provider/apps.go @@ -18,37 +18,77 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" ) // ListApps returns an http.HandlerFunc that handles GET /oauth2-provider/apps -func ListApps(db database.Store, accessURL *url.URL) http.HandlerFunc { +func ListApps(db database.Store, accessURL *url.URL, logger slog.Logger) http.HandlerFunc { return func(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() rawUserID := r.URL.Query().Get("user_id") - if rawUserID == "" { + rawOwnerID := r.URL.Query().Get("owner_id") + + // If neither filter is provided, return all apps + if rawUserID == "" && rawOwnerID == "" { dbApps, err := db.GetOAuth2ProviderApps(ctx) if err != nil { - httpapi.InternalServerError(rw, err) + logger.Error(ctx, "failed to get OAuth2 provider apps", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error retrieving OAuth2 applications.", + }) return } - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApps(accessURL, dbApps)) + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderAppsRows(accessURL, dbApps)) return } + // Handle owner_id filter - apps created by the user + if rawOwnerID != "" { + ownerID, err := uuid.Parse(rawOwnerID) + if err != nil { + logger.Warn(ctx, "invalid owner UUID provided", slog.F("owner_id", rawOwnerID), slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid owner UUID", + Detail: fmt.Sprintf("queried owner_id=%q", rawOwnerID), + }) + return + } + + ownedApps, err := db.GetOAuth2ProviderAppsByOwnerID(ctx, uuid.NullUUID{ + UUID: ownerID, + Valid: true, + }) + if err != nil { + logger.Error(ctx, "failed to get OAuth2 provider apps by owner", slog.F("owner_id", ownerID), slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error retrieving OAuth2 applications.", + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderAppsByOwnerIDRows(accessURL, ownedApps)) + return + } + + // Handle user_id filter - apps the user has authorized (has tokens for) userID, err := uuid.Parse(rawUserID) if err != nil { + logger.Warn(ctx, "invalid user UUID provided", slog.F("user_id", rawUserID), slog.Error(err)) httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid user UUID", - Detail: fmt.Sprintf("queried user_id=%q", userID), + Detail: fmt.Sprintf("queried user_id=%q", rawUserID), }) return } userApps, err := db.GetOAuth2ProviderAppsByUserID(ctx, userID) if err != nil { - httpapi.InternalServerError(rw, err) + logger.Error(ctx, "failed to get OAuth2 provider apps by user", slog.F("user_id", userID), slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error retrieving OAuth2 applications.", + }) return } @@ -64,8 +104,8 @@ func ListApps(db database.Store, accessURL *url.URL) http.HandlerFunc { func GetApp(accessURL *url.URL) http.HandlerFunc { return func(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - app := httpmw.OAuth2ProviderApp(r) - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApp(accessURL, app)) + appRow := httpmw.OAuth2ProviderAppRow(r) + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderAppRow(accessURL, appRow)) } } @@ -89,6 +129,7 @@ func CreateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, lo // Validate grant types and redirect URI requirements if err := req.Validate(); err != nil { + logger.Warn(ctx, "invalid OAuth2 application request", slog.Error(err), slog.F("request", req)) httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid OAuth2 application request.", Detail: err.Error(), @@ -143,9 +184,16 @@ func CreateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, lo RegistrationClientUri: sql.NullString{}, }) if err != nil { + if rbac.IsUnauthorizedError(err) { + logger.Debug(ctx, "unauthorized to create OAuth2 application", slog.Error(err), slog.F("app_name", req.Name)) + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "You are not authorized to create this type of OAuth2 application.", + }) + return + } + logger.Error(ctx, "failed to create OAuth2 application", slog.Error(err), slog.F("app_name", req.Name)) httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error creating OAuth2 application.", - Detail: err.Error(), }) return } @@ -176,6 +224,7 @@ func UpdateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, lo // Validate the update request if err := req.Validate(); err != nil { + logger.Warn(ctx, "invalid OAuth2 application update request", slog.Error(err), slog.F("app_id", app.ID), slog.F("request", req)) httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid OAuth2 application update request.", Detail: err.Error(), @@ -213,9 +262,9 @@ func UpdateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, lo SoftwareVersion: app.SoftwareVersion, // Keep existing value }) if err != nil { + logger.Error(ctx, "failed to update OAuth2 application", slog.Error(err), slog.F("app_id", app.ID), slog.F("app_name", req.Name)) httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error updating OAuth2 application.", - Detail: err.Error(), }) return } @@ -241,9 +290,9 @@ func DeleteApp(db database.Store, auditor *audit.Auditor, logger slog.Logger) ht defer commitAudit() err := db.DeleteOAuth2ProviderAppByID(ctx, app.ID) if err != nil { + logger.Error(ctx, "failed to delete OAuth2 application", slog.Error(err), slog.F("app_id", app.ID), slog.F("app_name", app.Name)) httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error deleting OAuth2 application.", - Detail: err.Error(), }) return } diff --git a/coderd/oauth2provider/provider_test.go b/coderd/oauth2provider/provider_test.go index 33ce47ba6f2b8..143d26c5055e6 100644 --- a/coderd/oauth2provider/provider_test.go +++ b/coderd/oauth2provider/provider_test.go @@ -652,12 +652,12 @@ func TestOAuth2ProviderAppOperations(t *testing.T) { client := coderdtest.New(t, nil) owner := coderdtest.CreateFirstUser(t, client) - another, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + _, _ = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) ctx := testutil.Context(t, testutil.WaitLong) // No apps yet. - apps, err := another.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{}) + apps, err := client.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{}) require.NoError(t, err) require.Len(t, apps, 0) @@ -669,7 +669,7 @@ func TestOAuth2ProviderAppOperations(t *testing.T) { } // Should get all the apps now. - apps, err = another.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{}) + apps, err = client.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{}) require.NoError(t, err) require.Len(t, apps, 5) require.Equal(t, expectedOrder, apps) @@ -703,7 +703,7 @@ func TestOAuth2ProviderAppOperations(t *testing.T) { require.Equal(t, expectedApps.Default.ID, newApp.ID) // Should be able to get a single app. - got, err := another.OAuth2ProviderApp(ctx, expectedApps.Default.ID) + got, err := client.OAuth2ProviderApp(ctx, expectedApps.Default.ID) require.NoError(t, err) require.Equal(t, newApp, got) @@ -713,7 +713,7 @@ func TestOAuth2ProviderAppOperations(t *testing.T) { require.NoError(t, err) // Should show the new count. - newApps, err := another.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{}) + newApps, err := client.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{}) require.NoError(t, err) require.Len(t, newApps, 4) @@ -724,10 +724,10 @@ func TestOAuth2ProviderAppOperations(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) owner := coderdtest.CreateFirstUser(t, client) - another, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + _, 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{ + apps, err := client.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{ UserID: user.ID, }) require.NoError(t, err) @@ -770,3 +770,162 @@ func generateApps(ctx context.Context, t *testing.T, client *codersdk.Client, su }, } } + +// TestOAuth2ProviderAppNonAdminClientCredentials tests that non-admin users can create +// client credentials OAuth2 apps (regression test for authorization bug). +func TestOAuth2ProviderAppNonAdminClientCredentials(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + + ctx := testutil.Context(t, testutil.WaitLong) + + // Create a non-admin user (member role) + memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + // Test that member can create client credentials app + app, err := memberClient.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{ + Name: "member-client-credentials-app", + RedirectURIs: []string{}, // Empty for client credentials + GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials}, + }) + require.NoError(t, err) + require.Equal(t, "member-client-credentials-app", app.Name) + require.Equal(t, memberUser.ID, app.UserID) + require.Contains(t, app.GrantTypes, string(codersdk.OAuth2ProviderGrantTypeClientCredentials)) + + // Test that member can create a secret for their app + secret, err := memberClient.PostOAuth2ProviderAppSecret(ctx, app.ID) + require.NoError(t, err) + require.NotEmpty(t, secret.ClientSecretFull) + + // Test that member can list secrets for their own app + secrets, err := memberClient.OAuth2ProviderAppSecrets(ctx, app.ID) + require.NoError(t, err) + require.Len(t, secrets, 1) + require.Equal(t, secret.ID, secrets[0].ID) + + // Test that member can get their own app + retrievedApp, err := memberClient.OAuth2ProviderApp(ctx, app.ID) + require.NoError(t, err) + require.Equal(t, app.ID, retrievedApp.ID) + require.Equal(t, memberUser.ID, retrievedApp.UserID) + + // Test that member can use client credentials flow + conf := clientcredentials.Config{ + ClientID: retrievedApp.ID.String(), + ClientSecret: secret.ClientSecretFull, + TokenURL: memberClient.URL.String() + "/oauth2/token", + } + + token, err := conf.Token(ctx) + require.NoError(t, err) + require.NotEmpty(t, token.AccessToken) + require.Equal(t, "Bearer", token.TokenType) + + // Test that member cannot create system-level apps (authorization code flow) + _, err = memberClient.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{ + Name: "member-system-app", + RedirectURIs: []string{"http://localhost:3000"}, + GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeAuthorizationCode}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "You are not authorized to create this type of OAuth2 application") +} + +// TestOAuth2ProviderAppOwnershipAuthorization tests that users can only access their own OAuth2 apps +func TestOAuth2ProviderAppOwnershipAuthorization(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + // Create two non-admin users + user1Client, user1 := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + user2Client, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + // User1 creates a client credentials app + user1App, err := user1Client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{ + Name: "user1-app", + RedirectURIs: []string{}, + GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials}, + }) + require.NoError(t, err) + require.Equal(t, user1.ID, user1App.UserID) + + // User1 creates a secret for their app + user1Secret, err := user1Client.PostOAuth2ProviderAppSecret(ctx, user1App.ID) + require.NoError(t, err) + require.NotEmpty(t, user1Secret.ClientSecretFull) + + // Test that user1 can access their own app + retrievedApp, err := user1Client.OAuth2ProviderApp(ctx, user1App.ID) + require.NoError(t, err) + require.Equal(t, user1App.ID, retrievedApp.ID) + + // Test that user1 can list secrets for their own app + secrets, err := user1Client.OAuth2ProviderAppSecrets(ctx, user1App.ID) + require.NoError(t, err) + require.Len(t, secrets, 1) + require.Equal(t, user1Secret.ID, secrets[0].ID) + + // Test that user2 cannot access user1's app + _, err = user2Client.OAuth2ProviderApp(ctx, user1App.ID) + require.Error(t, err) + require.Contains(t, err.Error(), "not found") + + // Test that user2 cannot list secrets for user1's app + _, err = user2Client.OAuth2ProviderAppSecrets(ctx, user1App.ID) + require.Error(t, err) + require.Contains(t, err.Error(), "not found") + + // Test that user2 cannot create a secret for user1's app + _, err = user2Client.PostOAuth2ProviderAppSecret(ctx, user1App.ID) + require.Error(t, err) + require.Contains(t, err.Error(), "not found") + + // Test that user2 cannot delete user1's app + err = user2Client.DeleteOAuth2ProviderApp(ctx, user1App.ID) + require.Error(t, err) + require.Contains(t, err.Error(), "not found") + + // Test that user2 cannot update user1's app + _, err = user2Client.PutOAuth2ProviderApp(ctx, user1App.ID, codersdk.PutOAuth2ProviderAppRequest{ + Name: "hacked-app", + RedirectURIs: []string{"http://localhost:8080"}, + Icon: "evil", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "not found") + + // Test that user2 cannot delete user1's app secret + err = user2Client.DeleteOAuth2ProviderAppSecret(ctx, user1App.ID, user1Secret.ID) + require.Error(t, err) + require.Contains(t, err.Error(), "not found") + + // Test that user1 CAN delete their own app secret + err = user1Client.DeleteOAuth2ProviderAppSecret(ctx, user1App.ID, user1Secret.ID) + require.NoError(t, err) + + // Test that user1 CAN update their own app + updatedApp, err := user1Client.PutOAuth2ProviderApp(ctx, user1App.ID, codersdk.PutOAuth2ProviderAppRequest{ + Name: "updated-app", + RedirectURIs: []string{}, + Icon: "new-icon", + GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials}, + }) + require.NoError(t, err) + require.Equal(t, "updated-app", updatedApp.Name) + require.Equal(t, "new-icon", updatedApp.Icon) + + // Test that user1 CAN delete their own app + err = user1Client.DeleteOAuth2ProviderApp(ctx, user1App.ID) + require.NoError(t, err) + + // Verify app is gone + _, err = user1Client.OAuth2ProviderApp(ctx, user1App.ID) + require.Error(t, err) + require.Contains(t, err.Error(), "not found") +} diff --git a/coderd/oauth2provider/registration.go b/coderd/oauth2provider/registration.go index 93ae5ec2f2f7c..4d07f939bcc87 100644 --- a/coderd/oauth2provider/registration.go +++ b/coderd/oauth2provider/registration.go @@ -130,12 +130,13 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 dynamic client registration _, err = db.InsertOAuth2ProviderAppSecret(dbauthz.AsSystemOAuth2(ctx), database.InsertOAuth2ProviderAppSecretParams{ - ID: uuid.New(), - CreatedAt: now, - SecretPrefix: []byte(parsedSecret.prefix), - HashedSecret: []byte(hashedSecret), - DisplaySecret: createDisplaySecret(clientSecret), - AppID: clientID, + ID: uuid.New(), + CreatedAt: now, + SecretPrefix: []byte(parsedSecret.prefix), + HashedSecret: []byte(hashedSecret), + DisplaySecret: createDisplaySecret(clientSecret), + AppID: clientID, + AppOwnerUserID: uuid.NullUUID{Valid: false}, // System-level secret }) if err != nil { writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, diff --git a/coderd/oauth2provider/tokens.go b/coderd/oauth2provider/tokens.go index 5891f04cc2cb3..0bfc048e7c192 100644 --- a/coderd/oauth2provider/tokens.go +++ b/coderd/oauth2provider/tokens.go @@ -123,14 +123,6 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime) http.HandlerF ctx := r.Context() app := httpmw.OAuth2ProviderApp(r) - // Validate that app has registered redirect URIs - if len(app.RedirectUris) == 0 { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "OAuth2 app has no registered redirect URIs.", - }) - return - } - params, validationErrs, err := extractTokenParams(r, app.RedirectUris) if err != nil { // Check for specific validation errors in priority order @@ -156,6 +148,14 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime) http.HandlerF return } + // Validate redirect URIs only for grant types that require them + if params.grantType == codersdk.OAuth2ProviderGrantTypeAuthorizationCode && len(app.RedirectUris) == 0 { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "OAuth2 app has no registered redirect URIs.", + }) + return + } + var token oauth2.Token //nolint:gocritic,revive // More cases will be added later. switch params.grantType { diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 33635f34e5914..c3a6739790e5f 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -289,9 +289,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Identifier: RoleMember(), DisplayName: "Member", Site: Permissions(map[string][]policy.Action{ - ResourceAssignRole.Type: {policy.ActionRead}, - // All users can see OAuth2 provider applications. - ResourceOauth2App.Type: {policy.ActionRead}, + ResourceAssignRole.Type: {policy.ActionRead}, ResourceWorkspaceProxy.Type: {policy.ActionRead}, }), Org: map[string][]Permission{}, @@ -306,6 +304,10 @@ func ReloadBuiltinRoles(opts *RoleOptions) { ResourceOrganizationMember.Type: {policy.ActionRead}, // Users can create provisioner daemons scoped to themselves. ResourceProvisionerDaemon.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, + // Users can create OAuth2 apps scoped to themselves. + ResourceOauth2App.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + ResourceOauth2AppSecret.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, + ResourceOauth2AppCodeToken.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, })..., ), }.withCachedRegoValue() diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index c790d2cf140ca..fd32af8e30cfb 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -628,8 +628,20 @@ func TestRolePermissions(t *testing.T) { Actions: []policy.Action{policy.ActionRead}, Resource: rbac.ResourceOauth2App, AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {owner, setOrgNotMe, setOtherOrg, memberMe, orgMemberMe, templateAdmin, userAdmin}, - false: {}, + // Only owners can read system-scoped OAuth2 apps + // User-level permissions only apply to user-owned resources + true: {owner}, + false: {memberMe, orgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin, otherOrgAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, templateAdmin, userAdmin, orgMemberMe, otherOrgMember}, + }, + }, + { + Name: "Oauth2AppReadOwned", + Actions: []policy.Action{policy.ActionRead}, + Resource: rbac.ResourceOauth2App.WithOwner(currentUser.String()), + AuthorizeMap: map[bool][]hasAuthSubjects{ + // Users can read their own OAuth2 apps through ownership + true: {owner, memberMe, orgMemberMe}, + false: {orgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin, otherOrgAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, templateAdmin, userAdmin, otherOrgMember}, }, }, { diff --git a/codersdk/oauth2.go b/codersdk/oauth2.go index 0c0732a15cfca..c915cf71f46c3 100644 --- a/codersdk/oauth2.go +++ b/codersdk/oauth2.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/google/uuid" "golang.org/x/oauth2" @@ -25,6 +26,11 @@ type OAuth2ProviderApp struct { Name string `json:"name"` RedirectURIs []string `json:"redirect_uris"` Icon string `json:"icon"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + GrantTypes []string `json:"grant_types"` + UserID uuid.UUID `json:"user_id,omitempty" format:"uuid"` + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` // Endpoints are included in the app response for easier discovery. The OAuth2 // spec does not have a defined place to find these (for comparison, OIDC has @@ -41,7 +47,8 @@ type OAuth2AppEndpoints struct { } type OAuth2ProviderAppFilter struct { - UserID uuid.UUID `json:"user_id,omitempty" format:"uuid"` + UserID uuid.UUID `json:"user_id,omitempty" format:"uuid"` + OwnerID uuid.UUID `json:"owner_id,omitempty" format:"uuid"` } // OAuth2ProviderApps returns the applications configured to authenticate using @@ -49,11 +56,14 @@ type OAuth2ProviderAppFilter struct { func (c *Client) OAuth2ProviderApps(ctx context.Context, filter OAuth2ProviderAppFilter) ([]OAuth2ProviderApp, error) { res, err := c.Request(ctx, http.MethodGet, "/api/v2/oauth2-provider/apps", nil, func(r *http.Request) { + q := r.URL.Query() if filter.UserID != uuid.Nil { - q := r.URL.Query() q.Set("user_id", filter.UserID.String()) - r.URL.RawQuery = q.Encode() } + if filter.OwnerID != uuid.Nil { + q.Set("owner_id", filter.OwnerID.String()) + } + r.URL.RawQuery = q.Encode() }) if err != nil { return []OAuth2ProviderApp{}, err @@ -83,7 +93,7 @@ func (c *Client) OAuth2ProviderApp(ctx context.Context, id uuid.UUID) (OAuth2Pro type PostOAuth2ProviderAppRequest struct { Name string `json:"name" validate:"required,oauth2_app_display_name"` - RedirectURIs []string `json:"redirect_uris" validate:"required,min=1,dive,http_url"` + RedirectURIs []string `json:"redirect_uris" validate:"dive,http_url"` Icon string `json:"icon" validate:"omitempty"` GrantTypes []OAuth2ProviderGrantType `json:"grant_types,omitempty" validate:"dive,oneof=authorization_code refresh_token client_credentials urn:ietf:params:oauth:grant-type:device_code"` } @@ -140,7 +150,7 @@ func (c *Client) PostOAuth2ProviderApp(ctx context.Context, app PostOAuth2Provid type PutOAuth2ProviderAppRequest struct { Name string `json:"name" validate:"required,oauth2_app_display_name"` - RedirectURIs []string `json:"redirect_uris" validate:"required,min=1,dive,http_url"` + RedirectURIs []string `json:"redirect_uris" validate:"dive,http_url"` Icon string `json:"icon" validate:"omitempty"` GrantTypes []OAuth2ProviderGrantType `json:"grant_types,omitempty" validate:"dive,oneof=authorization_code refresh_token client_credentials urn:ietf:params:oauth:grant-type:device_code"` } @@ -186,6 +196,7 @@ type OAuth2ProviderAppSecretFull struct { type OAuth2ProviderAppSecret struct { ID uuid.UUID `json:"id" format:"uuid"` + CreatedAt time.Time `json:"created_at" format:"date-time"` LastUsedAt NullTime `json:"last_used_at"` ClientSecretTruncated string `json:"client_secret_truncated"` } diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 3671318a4769c..6ed1ed015cb5d 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -27,7 +27,7 @@ We track the following resources: | NotificationTemplate
| |
FieldTracked
actionstrue
body_templatetrue
enabled_by_defaulttrue
grouptrue
idfalse
kindtrue
methodtrue
nametrue
title_templatetrue
| | NotificationsSettings
| |
FieldTracked
idfalse
notifier_pausedtrue
| | OAuth2ProviderApp
| |
FieldTracked
client_id_issued_atfalse
client_secret_expires_attrue
client_typetrue
client_uritrue
contactstrue
created_atfalse
dynamically_registeredtrue
grant_typestrue
icontrue
idfalse
jwkstrue
jwks_uritrue
logo_uritrue
nametrue
policy_uritrue
redirect_uristrue
registration_access_tokentrue
registration_client_uritrue
response_typestrue
scopetrue
software_idtrue
software_versiontrue
token_endpoint_auth_methodtrue
tos_uritrue
updated_atfalse
user_idtrue
| -| OAuth2ProviderAppSecret
| |
FieldTracked
app_idfalse
created_atfalse
display_secretfalse
hashed_secretfalse
idfalse
last_used_atfalse
secret_prefixfalse
| +| OAuth2ProviderAppSecret
| |
FieldTracked
app_idfalse
app_owner_user_idfalse
created_atfalse
display_secretfalse
hashed_secretfalse
idfalse
last_used_atfalse
secret_prefixfalse
| | OAuth2ProviderDeviceCode
create, write, delete | |
FieldTracked
client_idtrue
created_atfalse
device_code_prefixtrue
expires_atfalse
idfalse
polling_intervalfalse
resource_uritrue
scopetrue
statustrue
user_codetrue
user_idtrue
verification_uritrue
verification_uri_completetrue
| | Organization
| |
FieldTracked
created_atfalse
deletedtrue
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
updated_attrue
| | OrganizationSyncSettings
| |
FieldTracked
assign_defaulttrue
fieldtrue
mappingtrue
| diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 2ca9e493af0fc..213449c776eed 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -794,9 +794,10 @@ curl -X GET http://coder-server:8080/api/v2/oauth2-provider/apps \ ### Parameters -| Name | In | Type | Required | Description | -|-----------|-------|--------|----------|----------------------------------------------| -| `user_id` | query | string | false | Filter by applications authorized for a user | +| Name | In | Type | Required | Description | +|------------|-------|--------|----------|----------------------------------------------| +| `user_id` | query | string | false | Filter by applications authorized for a user | +| `owner_id` | query | string | false | Filter by applications owned by a user | ### Example responses @@ -805,18 +806,25 @@ curl -X GET http://coder-server:8080/api/v2/oauth2-provider/apps \ ```json [ { + "created_at": "2019-08-24T14:15:22Z", + "email": "string", "endpoints": { "authorization": "string", "device_authorization": "string", "revocation": "string", "token": "string" }, + "grant_types": [ + "string" + ], "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string", "redirect_uris": [ "string" - ] + ], + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" } ] ``` @@ -834,15 +842,20 @@ Status Code **200** | Name | Type | Required | Restrictions | Description | |---------------------------|----------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `[array item]` | array | false | | | +| `ยป created_at` | string(date-time) | false | | | +| `ยป email` | string | false | | | | `ยป endpoints` | [codersdk.OAuth2AppEndpoints](schemas.md#codersdkoauth2appendpoints) | false | | Endpoints are included in the app response for easier discovery. The OAuth2 spec does not have a defined place to find these (for comparison, OIDC has a '/.well-known/openid-configuration' endpoint). | | `ยปยป authorization` | string | false | | | | `ยปยป device_authorization` | string | false | | Device authorization is the device authorization endpoint for RFC 8628. | | `ยปยป revocation` | string | false | | | | `ยปยป token` | string | false | | | +| `ยป grant_types` | array | false | | | | `ยป icon` | string | false | | | | `ยป id` | string(uuid) | false | | | | `ยป name` | string | false | | | | `ยป redirect_uris` | array | false | | | +| `ยป user_id` | string(uuid) | false | | | +| `ยป username` | string | false | | | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -887,18 +900,25 @@ curl -X POST http://coder-server:8080/api/v2/oauth2-provider/apps \ ```json { + "created_at": "2019-08-24T14:15:22Z", + "email": "string", "endpoints": { "authorization": "string", "device_authorization": "string", "revocation": "string", "token": "string" }, + "grant_types": [ + "string" + ], "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string", "redirect_uris": [ "string" - ] + ], + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" } ``` @@ -935,18 +955,25 @@ curl -X GET http://coder-server:8080/api/v2/oauth2-provider/apps/{app} \ ```json { + "created_at": "2019-08-24T14:15:22Z", + "email": "string", "endpoints": { "authorization": "string", "device_authorization": "string", "revocation": "string", "token": "string" }, + "grant_types": [ + "string" + ], "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string", "redirect_uris": [ "string" - ] + ], + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" } ``` @@ -1000,18 +1027,25 @@ curl -X PUT http://coder-server:8080/api/v2/oauth2-provider/apps/{app} \ ```json { + "created_at": "2019-08-24T14:15:22Z", + "email": "string", "endpoints": { "authorization": "string", "device_authorization": "string", "revocation": "string", "token": "string" }, + "grant_types": [ + "string" + ], "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string", "redirect_uris": [ "string" - ] + ], + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" } ``` @@ -1076,6 +1110,7 @@ curl -X GET http://coder-server:8080/api/v2/oauth2-provider/apps/{app}/secrets \ [ { "client_secret_truncated": "string", + "created_at": "2019-08-24T14:15:22Z", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_used_at": "string" } @@ -1092,12 +1127,13 @@ curl -X GET http://coder-server:8080/api/v2/oauth2-provider/apps/{app}/secrets \ Status Code **200** -| Name | Type | Required | Restrictions | Description | -|-----------------------------|--------------|----------|--------------|-------------| -| `[array item]` | array | false | | | -| `ยป client_secret_truncated` | string | false | | | -| `ยป id` | string(uuid) | false | | | -| `ยป last_used_at` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|-----------------------------|-------------------|----------|--------------|-------------| +| `[array item]` | array | false | | | +| `ยป client_secret_truncated` | string | false | | | +| `ยป created_at` | string(date-time) | false | | | +| `ยป id` | string(uuid) | false | | | +| `ยป last_used_at` | string | false | | | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index af845002fb02a..1ffddaa480dbf 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -4869,18 +4869,25 @@ Only certain features set these fields: - FeatureManagedAgentLimit| ```json { + "created_at": "2019-08-24T14:15:22Z", + "email": "string", "endpoints": { "authorization": "string", "device_authorization": "string", "revocation": "string", "token": "string" }, + "grant_types": [ + "string" + ], "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string", "redirect_uris": [ "string" - ] + ], + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" } ``` @@ -4888,17 +4895,23 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | Name | Type | Required | Restrictions | Description | |-----------------|------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `created_at` | string | false | | | +| `email` | string | false | | | | `endpoints` | [codersdk.OAuth2AppEndpoints](#codersdkoauth2appendpoints) | false | | Endpoints are included in the app response for easier discovery. The OAuth2 spec does not have a defined place to find these (for comparison, OIDC has a '/.well-known/openid-configuration' endpoint). | +| `grant_types` | array of string | false | | | | `icon` | string | false | | | | `id` | string | false | | | | `name` | string | false | | | | `redirect_uris` | array of string | false | | | +| `user_id` | string | false | | | +| `username` | string | false | | | ## codersdk.OAuth2ProviderAppSecret ```json { "client_secret_truncated": "string", + "created_at": "2019-08-24T14:15:22Z", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_used_at": "string" } @@ -4909,6 +4922,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | Name | Type | Required | Restrictions | Description | |---------------------------|--------|----------|--------------|-------------| | `client_secret_truncated` | string | false | | | +| `created_at` | string | false | | | | `id` | string | false | | | | `last_used_at` | string | false | | | @@ -5541,7 +5555,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `grant_types` | array of [codersdk.OAuth2ProviderGrantType](#codersdkoauth2providergranttype) | false | | | | `icon` | string | false | | | | `name` | string | true | | | -| `redirect_uris` | array of string | true | | | +| `redirect_uris` | array of string | false | | | ## codersdk.PostWorkspaceUsageRequest @@ -6396,7 +6410,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `grant_types` | array of [codersdk.OAuth2ProviderGrantType](#codersdkoauth2providergranttype) | false | | | | `icon` | string | false | | | | `name` | string | true | | | -| `redirect_uris` | array of string | true | | | +| `redirect_uris` | array of string | false | | | ## codersdk.RBACAction diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index aac683c12dee3..02b260454ac26 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -298,13 +298,14 @@ var auditableResourcesTypes = map[any]map[string]Action{ "registration_client_uri": ActionTrack, // Management endpoint URI }, &database.OAuth2ProviderAppSecret{}: { - "id": ActionIgnore, - "created_at": ActionIgnore, - "last_used_at": ActionIgnore, - "hashed_secret": ActionIgnore, - "display_secret": ActionIgnore, - "app_id": ActionIgnore, - "secret_prefix": ActionIgnore, + "id": ActionIgnore, + "created_at": ActionIgnore, + "last_used_at": ActionIgnore, + "hashed_secret": ActionIgnore, + "display_secret": ActionIgnore, + "app_id": ActionIgnore, + "secret_prefix": ActionIgnore, + "app_owner_user_id": ActionIgnore, // Denormalized field for performance }, &database.OAuth2ProviderDeviceCode{}: { "id": ActionIgnore, diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b9d5f06924519..0bae02cae8951 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1749,13 +1749,12 @@ class ApiMethods { }; getOAuth2ProviderApps = async ( - filter?: TypesGen.OAuth2ProviderAppFilter, + filter: TypesGen.OAuth2ProviderAppFilter = {}, ): Promise => { - const params = filter?.user_id - ? new URLSearchParams({ user_id: filter.user_id }).toString() - : ""; - - const resp = await this.axios.get(`/api/v2/oauth2-provider/apps?${params}`); + const params = new URLSearchParams(filter as Record); + const resp = await this.axios.get("/api/v2/oauth2-provider/apps", { + params, + }); return resp.data; }; diff --git a/site/src/api/queries/oauth2.ts b/site/src/api/queries/oauth2.ts index a124dbd032480..3619cbc9b811d 100644 --- a/site/src/api/queries/oauth2.ts +++ b/site/src/api/queries/oauth2.ts @@ -21,10 +21,10 @@ export const getGitHubDeviceFlowCallback = (code: string, state: string) => { }; }; -export const getApps = (userId?: string) => { +export const getApps = (filter: TypesGen.OAuth2ProviderAppFilter = {}) => { return { - queryKey: userId ? appsKey.concat(userId) : appsKey, - queryFn: () => API.getOAuth2ProviderApps({ user_id: userId }), + queryKey: [...appsKey, filter], + queryFn: () => API.getOAuth2ProviderApps(filter), }; }; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index efc096b3fb157..62519579fd218 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1728,17 +1728,24 @@ export interface OAuth2ProviderApp { readonly name: string; readonly redirect_uris: readonly string[]; readonly icon: string; + readonly created_at: string; + readonly grant_types: readonly string[]; + readonly user_id?: string; + readonly username?: string; + readonly email?: string; readonly endpoints: OAuth2AppEndpoints; } // From codersdk/oauth2.go export interface OAuth2ProviderAppFilter { readonly user_id?: string; + readonly owner_id?: string; } // From codersdk/oauth2.go export interface OAuth2ProviderAppSecret { readonly id: string; + readonly created_at: string; readonly last_used_at: string | null; readonly client_secret_truncated: string; } diff --git a/site/src/components/OAuth2/ClientCredentialsAppForm.tsx b/site/src/components/OAuth2/ClientCredentialsAppForm.tsx new file mode 100644 index 0000000000000..5d2f5d7060757 --- /dev/null +++ b/site/src/components/OAuth2/ClientCredentialsAppForm.tsx @@ -0,0 +1,129 @@ +import TextField from "@mui/material/TextField"; +import type * as TypesGen from "api/typesGenerated"; +import { Alert } from "components/Alert/Alert"; +import { Button } from "components/Button/Button"; +import { Spinner } from "components/Spinner/Spinner"; +import { Stack } from "components/Stack/Stack"; +import { useFormik } from "formik"; +import { ChevronLeftIcon } from "lucide-react"; +import type { FC } from "react"; +import { Link as RouterLink } from "react-router-dom"; +import { getFormHelpers } from "utils/formUtils"; +import * as Yup from "yup"; +import { Section } from "../../pages/UserSettingsPage/Section"; + +const validationSchema = Yup.object({ + name: Yup.string().required("Name is required"), + icon: Yup.string(), +}); + +type ClientCredentialsAppFormProps = { + app?: TypesGen.OAuth2ProviderApp; + onSubmit: (data: { + name: string; + icon: string; + grant_types: TypesGen.OAuth2ProviderGrantType[]; + redirect_uris: string[]; + }) => void; + error?: unknown; + isUpdating: boolean; +}; + +export const ClientCredentialsAppForm: FC = ({ + app, + onSubmit, + error, + isUpdating, +}) => { + const form = useFormik({ + initialValues: { + name: app?.name || "", + icon: app?.icon || "", + }, + validationSchema, + onSubmit: (values) => { + onSubmit({ + name: values.name, + icon: values.icon, + grant_types: ["client_credentials"], + redirect_uris: [], // Client credentials don't need redirect URIs + }); + }, + }); + + const getFieldHelpers = getFormHelpers(form, error); + + const title = app + ? "Edit Client Credentials Application" + : "Create Client Credentials Application"; + const description = app + ? "Update your client credentials application details" + : "Create a new client credentials application for server-to-server authentication"; + + return ( + <> + +
+ + + + +
+ + + + + + {/* Info box explaining client credentials - only show when creating */} + {!app && ( + +
+
+ About Client Credentials Applications +
+
+ Client credentials applications are designed for + server-to-server communication and API access. They + authenticate using a client ID and secret, and receive tokens + with your user permissions. This is the recommended way to + obtain Coder API keys for automation, providing better + security than long-lived tokens. +
+
+
+ )} + + + + +
+
+ + ); +}; diff --git a/site/src/components/OAuth2/ClientCredentialsAppRow.tsx b/site/src/components/OAuth2/ClientCredentialsAppRow.tsx new file mode 100644 index 0000000000000..6605f9a4103ca --- /dev/null +++ b/site/src/components/OAuth2/ClientCredentialsAppRow.tsx @@ -0,0 +1,71 @@ +import Button from "@mui/material/Button"; +import TableCell from "@mui/material/TableCell"; +import TableRow from "@mui/material/TableRow"; +import type * as TypesGen from "api/typesGenerated"; +import { Avatar } from "components/Avatar/Avatar"; +import { Stack } from "components/Stack/Stack"; +import dayjs from "dayjs"; +import { useClickableTableRow } from "hooks/useClickableTableRow"; +import type { FC } from "react"; + +type ClientCredentialsAppRowProps = { + app: TypesGen.OAuth2ProviderApp; + onManage: (app: TypesGen.OAuth2ProviderApp) => void; + onDelete: (app: TypesGen.OAuth2ProviderApp) => void; +}; + +export const ClientCredentialsAppRow: FC = ({ + app, + onManage, + onDelete, +}) => { + const clickableProps = useClickableTableRow({ + onClick: () => onManage(app), + }); + + return ( + + + + +
+ {app.name} +
+ Created {dayjs(app.created_at).format("MMM D, YYYY")} +
+
+
+
+ + + + Client Credentials + + + + { + e.stopPropagation(); // Prevent row click + }} + > + + + + + +
+ ); +}; diff --git a/site/src/components/Sidebar/Sidebar.tsx b/site/src/components/Sidebar/Sidebar.tsx index 813835baeb277..d9d76ce015fcf 100644 --- a/site/src/components/Sidebar/Sidebar.tsx +++ b/site/src/components/Sidebar/Sidebar.tsx @@ -81,19 +81,21 @@ interface SidebarNavItemProps { children?: ReactNode; icon: ElementType; href: string; + end?: boolean; } export const SidebarNavItem: FC = ({ children, href, icon: Icon, + end = true, }) => { const link = useClassName(classNames.link, []); const activeLink = useClassName(classNames.activeLink, []); return ( cx([link, isActive && activeLink])} > diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPageView.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPageView.tsx index 36aebfb57a5bd..445978bd8cd71 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPageView.tsx @@ -1,4 +1,5 @@ -import { type Interpolation, type Theme, useTheme } from "@emotion/react"; +import Collapse from "@mui/material/Collapse"; +import DialogActions from "@mui/material/DialogActions"; import Divider from "@mui/material/Divider"; import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; @@ -14,6 +15,8 @@ import { CodeExample } from "components/CodeExample/CodeExample"; import { CopyableValue } from "components/CopyableValue/CopyableValue"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; +import { Dialog, DialogActionButtons } from "components/Dialogs/Dialog"; +import { DropdownArrow } from "components/DropdownArrow/DropdownArrow"; import { Loader } from "components/Loader/Loader"; import { SettingsHeader, @@ -23,6 +26,7 @@ import { import { Spinner } from "components/Spinner/Spinner"; import { Stack } from "components/Stack/Stack"; import { TableLoader } from "components/TableLoader/TableLoader"; +import dayjs from "dayjs"; import { CopyIcon } from "lucide-react"; import { ChevronLeftIcon } from "lucide-react"; import { type FC, useState } from "react"; @@ -68,9 +72,15 @@ export const EditOAuth2AppPageView: FC = ({ ackFullNewSecret, error, }) => { - const theme = useTheme(); const [searchParams] = useSearchParams(); const [showDelete, setShowDelete] = useState(false); + const [showCodeExample, setShowCodeExample] = useState(false); + + // Owner information is now included in the app response + const owner = + app?.user_id && app.user_id !== "00000000-0000-0000-0000-000000000000" + ? { id: app.user_id, username: app.username, email: app.email } + : null; return ( <> @@ -94,32 +104,74 @@ export const EditOAuth2AppPageView: FC = ({ - {fullNewSecret && ( - -

+ onClose={() => { + ackFullNewSecret(); + setShowCodeExample(false); + }} + data-testid="dialog" + > +

+

+ OAuth2 client secret +

+
+

Your new client secret is displayed below. Make sure to copy it now; you will not be able to see it again.

- - } - /> + {app.grant_types?.includes("client_credentials") && ( +
+ + +
+

+ Use this curl command to exchange your client + credentials for an access token: +

+ +
+
+
+ )} +
+
+ + + { + ackFullNewSecret(); + setShowCodeExample(false); + }} + /> + + )} @@ -146,29 +198,42 @@ export const EditOAuth2AppPageView: FC = ({ onCancel={() => setShowDelete(false)} /> -
-
Client ID
+
+
Client ID
{app.id}
-
Authorization URL
+
Authorization URL
{app.endpoints.authorization}{" "}
-
Token URL
+
Token URL
{app.endpoints.token}
+
Grant Types
+
{app.grant_types?.join(", ") || "None"}
+
Created
+
{dayjs(app.created_at).format("MMM D, YYYY [at] h:mm A")}
+ {app.user_id && + app.user_id !== "00000000-0000-0000-0000-000000000000" && ( + <> +
Owner
+
+ {owner ? owner.username || owner.email : app.user_id} +
+ + )}
- + = ({ } /> - + = ({ direction="row" justifyContent="space-between" > -

Client secrets

+

Client Secrets

@@ -248,7 +313,7 @@ const OAuth2AppSecretsTable: FC = ({ {!isLoadingSecrets && (!secrets || secrets.length === 0) && ( -
+
No client secrets have been generated.
@@ -314,16 +379,3 @@ const OAuth2SecretRow: FC = ({ ); }; - -const styles = { - dataList: { - display: "grid", - gridTemplateColumns: "max-content auto", - "& > dt": { - fontWeight: "bold", - }, - "& > dd": { - marginLeft: 10, - }, - }, -} satisfies Record>; diff --git a/site/src/pages/UserSettingsPage/OAuth2ProviderPage/CreateClientCredentialsAppPage.tsx b/site/src/pages/UserSettingsPage/OAuth2ProviderPage/CreateClientCredentialsAppPage.tsx new file mode 100644 index 0000000000000..66beaf17dc12c --- /dev/null +++ b/site/src/pages/UserSettingsPage/OAuth2ProviderPage/CreateClientCredentialsAppPage.tsx @@ -0,0 +1,49 @@ +import { getErrorMessage } from "api/errors"; +import { postApp } from "api/queries/oauth2"; +import type * as TypesGen from "api/typesGenerated"; +import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; +import { ClientCredentialsAppForm } from "components/OAuth2/ClientCredentialsAppForm"; +import type { FC } from "react"; +import { useMutation, useQueryClient } from "react-query"; +import { useNavigate } from "react-router-dom"; + +type ClientCredentialsFormData = { + name: string; + icon: string; + grant_types: TypesGen.OAuth2ProviderGrantType[]; + redirect_uris: string[]; +}; + +const CreateClientCredentialsAppPage: FC = () => { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const createAppMutation = useMutation(postApp(queryClient)); + + const handleSubmit = async (data: ClientCredentialsFormData) => { + try { + const app = await createAppMutation.mutateAsync({ + name: data.name, + icon: data.icon, + grant_types: data.grant_types, + redirect_uris: data.redirect_uris, + }); + + displaySuccess(`OAuth2 application "${app.name}" created successfully!`); + navigate(`/settings/oauth2-provider/${app.id}`); + } catch (error) { + displayError( + getErrorMessage(error, "Failed to create OAuth2 application."), + ); + } + }; + + return ( + + ); +}; + +export default CreateClientCredentialsAppPage; diff --git a/site/src/pages/UserSettingsPage/OAuth2ProviderPage/ManageClientCredentialsAppPage.tsx b/site/src/pages/UserSettingsPage/OAuth2ProviderPage/ManageClientCredentialsAppPage.tsx new file mode 100644 index 0000000000000..545c8546dfb49 --- /dev/null +++ b/site/src/pages/UserSettingsPage/OAuth2ProviderPage/ManageClientCredentialsAppPage.tsx @@ -0,0 +1,397 @@ +import Button from "@mui/material/Button"; +import Collapse from "@mui/material/Collapse"; +import DialogActions from "@mui/material/DialogActions"; +import Link from "@mui/material/Link"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import { getErrorMessage } from "api/errors"; +import { + deleteApp, + deleteAppSecret, + getApp, + getAppSecrets, + postAppSecret, + putApp, +} from "api/queries/oauth2"; +import type * as TypesGen from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Button as CustomButton } from "components/Button/Button"; +import { CodeExample } from "components/CodeExample/CodeExample"; +import { CopyableValue } from "components/CopyableValue/CopyableValue"; +import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; +import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; +import { Dialog, DialogActionButtons } from "components/Dialogs/Dialog"; +import { DropdownArrow } from "components/DropdownArrow/DropdownArrow"; +import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; +import { ClientCredentialsAppForm } from "components/OAuth2/ClientCredentialsAppForm"; +import { Spinner } from "components/Spinner/Spinner"; +import { Stack } from "components/Stack/Stack"; +import { TableLoader } from "components/TableLoader/TableLoader"; +import dayjs from "dayjs"; +import { ChevronLeftIcon, CopyIcon, PlusIcon, TrashIcon } from "lucide-react"; +import { type FC, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { Link as RouterLink, useNavigate, useParams } from "react-router-dom"; +import { Section } from "../Section"; + +const ManageClientCredentialsAppPage: FC = () => { + const { appId } = useParams<{ appId: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + if (!appId) { + navigate("/settings/oauth2-provider"); + return null; + } + + const [editMode, setEditMode] = useState(false); + const [secretToDelete, setSecretToDelete] = useState(); + const [newSecretFull, setNewSecretFull] = + useState(null); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showCodeExample, setShowCodeExample] = useState(false); + + const appQuery = useQuery(getApp(appId)); + const secretsQuery = useQuery(getAppSecrets(appId)); + const updateAppMutation = useMutation(putApp(queryClient)); + const createSecretMutation = useMutation(postAppSecret(queryClient)); + const deleteSecretMutation = useMutation(deleteAppSecret(queryClient)); + const deleteAppMutation = useMutation(deleteApp(queryClient)); + + const app = appQuery.data; + const secrets = secretsQuery.data || []; + const secretToDeleteData = secrets.find((s) => s.id === secretToDelete); + + const handleUpdateApp = async (data: { + name: string; + icon: string; + grant_types: TypesGen.OAuth2ProviderGrantType[]; + redirect_uris: string[]; + }) => { + if (!app) return; + + try { + await updateAppMutation.mutateAsync({ + id: app.id, + req: { + name: data.name, + icon: data.icon, + grant_types: data.grant_types, + redirect_uris: data.redirect_uris, + }, + }); + displaySuccess("Application updated successfully!"); + setEditMode(false); + } catch (error) { + displayError(getErrorMessage(error, "Failed to update application.")); + } + }; + + const handleCreateSecret = async () => { + try { + const newSecret = await createSecretMutation.mutateAsync(appId); + setNewSecretFull(newSecret); + displaySuccess("Client secret created successfully!"); + } catch (error) { + displayError(getErrorMessage(error, "Failed to create client secret.")); + } + }; + + const handleDeleteApp = async () => { + try { + await deleteAppMutation.mutateAsync(appId); + displaySuccess("Application deleted successfully!"); + navigate("/settings/oauth2-provider"); + } catch (error) { + displayError(getErrorMessage(error, "Failed to delete application.")); + } + }; + + if (appQuery.isLoading) { + return ( +
+ +
+ ); + } + + if (appQuery.error || !app) { + return ( +
+ +
+ ); + } + + return ( + + {editMode ? ( + + ) : ( + <> + +
+ + + setEditMode(true)}> + Edit + + setShowDeleteDialog(true)} + > + Delete + + + + + All OAuth2 Applications + + + + + + {/* Application details using admin pattern */} +
+
Client ID
+
+ + {app.id} + +
+
Token URL
+
+ + {app.endpoints.token} + +
+
Grant Types
+
{app.grant_types?.join(", ") || "None"}
+
Created
+
{dayjs(app.created_at).format("MMM D, YYYY [at] h:mm A")}
+
+ + )} + + {!editMode && ( +
+ +
+

+ Client secrets are used to authenticate your application with + the Coder API. +

+ +
+ + {secretsQuery.error && } + + + + + + Secret + Last Used + Actions + + + + {secretsQuery.isLoading && } + {secrets.map((secret) => ( + + + + *****{secret.client_secret_truncated} + + + + {secret.last_used_at + ? dayjs(secret.last_used_at).format("MMM D, YYYY") + : "Never"} + + + + + + ))} + {secrets.length === 0 && !secretsQuery.isLoading && ( + + +
+

+ No client secrets created yet. +

+

+ Create a secret to start using this application. +

+
+
+
+ )} +
+
+
+
+
+ )} + + {secretToDeleteData && ( + { + try { + await deleteSecretMutation.mutateAsync({ + appId, + secretId: secretToDeleteData.id, + }); + displaySuccess("Client secret deleted successfully!"); + setSecretToDelete(undefined); + } catch (error) { + displayError( + getErrorMessage(error, "Failed to delete client secret."), + ); + } + }} + onClose={() => setSecretToDelete(undefined)} + title="Delete client secret" + confirmLoading={deleteSecretMutation.isPending} + confirmText="Delete" + description={ + <> + Deleting{" "} + *****{secretToDeleteData.client_secret_truncated}{" "} + is irreversible and will revoke all the tokens generated by it. + Are you sure you want to proceed? + + } + /> + )} + + {/* New Secret Dialog - Show full secret once */} + {newSecretFull && app && ( + { + setNewSecretFull(null); + setShowCodeExample(false); + }} + data-testid="dialog" + > +
+

+ Client secret created +

+
+

+ Your new client secret is displayed below. Make sure to copy it + now; you will not be able to see it again. +

+ + {app.grant_types?.includes("client_credentials") && ( +
+ setShowCodeExample(!showCodeExample)} + className="cursor-pointer text-content-secondary flex items-center text-sm" + > + Code Example + + + +
+

+ Use this curl command to exchange your client + credentials for an access token: +

+ +
+
+
+ )} +
+
+ + + { + setNewSecretFull(null); + setShowCodeExample(false); + }} + /> + +
+ )} + + {/* Delete Application Dialog */} + {showDeleteDialog && ( + setShowDeleteDialog(false)} + onConfirm={handleDeleteApp} + /> + )} + + ); +}; + +export default ManageClientCredentialsAppPage; diff --git a/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPage.tsx b/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPage.tsx index 77ee124777e3e..3b79be595668c 100644 --- a/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPage.tsx +++ b/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPage.tsx @@ -1,33 +1,62 @@ import { getErrorMessage } from "api/errors"; -import { getApps, revokeApp } from "api/queries/oauth2"; +import { deleteApp, getApps, revokeApp } from "api/queries/oauth2"; +import type * as TypesGen from "api/typesGenerated"; import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { useAuthenticated } from "hooks"; import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useNavigate } from "react-router-dom"; import { Section } from "../Section"; import OAuth2ProviderPageView from "./OAuth2ProviderPageView"; const OAuth2ProviderPage: FC = () => { const { user: me } = useAuthenticated(); const queryClient = useQueryClient(); - const userOAuth2AppsQuery = useQuery(getApps(me.id)); + const navigate = useNavigate(); + + // Get authorized apps (apps that user has granted access to their account) + const authorizedAppsQuery = useQuery(getApps({ user_id: me.id })); + + // Get owned apps (apps that the user created) + const ownedAppsQuery = useQuery(getApps({ owner_id: me.id })); + const revokeAppMutation = useMutation(revokeApp(queryClient, me.id)); + const deleteAppMutation = useMutation(deleteApp(queryClient)); + const [appIdToRevoke, setAppIdToRevoke] = useState(); - const appToRevoke = userOAuth2AppsQuery.data?.find( - (app) => app.id === appIdToRevoke, - ); + const [appIdToDelete, setAppIdToDelete] = useState(); + + // Now both lists come directly from the server without client-side filtering + const authorizedApps = authorizedAppsQuery.data || []; + const ownedApps = ownedAppsQuery.data || []; + + const appToRevoke = authorizedApps.find((app) => app.id === appIdToRevoke); + const appToDelete = ownedApps.find((app) => app.id === appIdToDelete); + + const handleManageOwnedApp = (app: TypesGen.OAuth2ProviderApp) => { + navigate(`${app.id}`); + }; + + const handleDeleteOwnedApp = (app: TypesGen.OAuth2ProviderApp) => { + setAppIdToDelete(app.id); + }; return (
{ setAppIdToRevoke(app.id); }} + onManageOwnedApp={handleManageOwnedApp} + onDeleteOwnedApp={handleDeleteOwnedApp} /> + + {/* Revoke authorized app dialog */} {appToRevoke !== undefined && ( { }} /> )} + + {/* Delete owned app dialog */} + {appToDelete !== undefined && ( + setAppIdToDelete(undefined)} + onConfirm={async () => { + try { + await deleteAppMutation.mutateAsync(appToDelete.id); + displaySuccess( + `You have successfully deleted the OAuth2 application "${appToDelete.name}"`, + ); + setAppIdToDelete(undefined); + } catch (error) { + displayError( + getErrorMessage(error, "Failed to delete application."), + ); + } + }} + /> + )}
); }; diff --git a/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPageView.stories.tsx b/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPageView.stories.tsx index 406bf58ab6239..035cee546ca0f 100644 --- a/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPageView.stories.tsx +++ b/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPageView.stories.tsx @@ -30,7 +30,10 @@ export const Apps: Story = { args: { isLoading: false, error: undefined, - apps: MockOAuth2ProviderApps, + authorizedApps: MockOAuth2ProviderApps, + ownedApps: MockOAuth2ProviderApps, revoke: () => undefined, + onManageOwnedApp: () => undefined, + onDeleteOwnedApp: () => undefined, }, }; diff --git a/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPageView.tsx b/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPageView.tsx index f5bfd0b015a30..7ecd912b12b72 100644 --- a/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPageView.tsx +++ b/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPageView.tsx @@ -8,53 +8,136 @@ import TableRow from "@mui/material/TableRow"; import type * as TypesGen from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; +import { ClientCredentialsAppRow } from "components/OAuth2/ClientCredentialsAppRow"; import { Stack } from "components/Stack/Stack"; import { TableLoader } from "components/TableLoader/TableLoader"; +import { PlusIcon } from "lucide-react"; import type { FC } from "react"; +import { Link as RouterLink } from "react-router-dom"; type OAuth2ProviderPageViewProps = { isLoading: boolean; error: unknown; - apps?: TypesGen.OAuth2ProviderApp[]; + authorizedApps?: TypesGen.OAuth2ProviderApp[]; + ownedApps?: TypesGen.OAuth2ProviderApp[]; revoke: (app: TypesGen.OAuth2ProviderApp) => void; + onManageOwnedApp: (app: TypesGen.OAuth2ProviderApp) => void; + onDeleteOwnedApp: (app: TypesGen.OAuth2ProviderApp) => void; }; const OAuth2ProviderPageView: FC = ({ isLoading, error, - apps, + authorizedApps, + ownedApps, revoke, + onManageOwnedApp, + onDeleteOwnedApp, }) => { return ( - <> - {error && } + + {error ? : null} - - - - - Name - - - - - {isLoading && } - {apps?.map((app) => ( - - ))} - {apps?.length === 0 && ( + {/* My Applications Section */} +
+
+
+

+ My Applications +

+

+ OAuth2 applications you've created for API access +

+
+ +
+ + +
+ + + Application + Type + Actions + + + + {isLoading && } + {ownedApps?.map((app) => ( + + ))} + {ownedApps?.length === 0 && !isLoading && ( + + +
+

+ No applications created yet. +

+

+ Create your first OAuth2 application to start using the + Coder API. +

+
+
+
+ )} +
+
+
+
+ + {/* Authorized Applications Section */} +
+
+

+ Authorized Applications +

+

+ OAuth2 applications you've granted access to your account +

+
+ + + + - -
- No OAuth2 applications have been authorized. -
-
+ Name +
- )} - -
-
- + + + {isLoading && } + {authorizedApps?.map((app) => ( + + ))} + {authorizedApps?.length === 0 && !isLoading && ( + + +
+

+ No OAuth2 applications have been authorized. +

+
+
+
+ )} +
+ + +
+ ); }; diff --git a/site/src/pages/UserSettingsPage/Sidebar.tsx b/site/src/pages/UserSettingsPage/Sidebar.tsx index fd9b342227813..00b97583e94b9 100644 --- a/site/src/pages/UserSettingsPage/Sidebar.tsx +++ b/site/src/pages/UserSettingsPage/Sidebar.tsx @@ -46,7 +46,7 @@ export const Sidebar: FC = ({ user }) => { External Authentication {(experiments.includes("oauth2") || isDevBuild(buildInfo)) && ( - + OAuth2 Applications )} @@ -61,7 +61,7 @@ export const Sidebar: FC = ({ user }) => { SSH Keys - + Tokens diff --git a/site/src/router.tsx b/site/src/router.tsx index 76925ba7162aa..11be908561f00 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -173,6 +173,18 @@ const UserOAuth2ProviderSettingsPage = lazy( () => import("./pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPage"), ); +const CreateClientCredentialsAppPage = lazy( + () => + import( + "./pages/UserSettingsPage/OAuth2ProviderPage/CreateClientCredentialsAppPage" + ), +); +const ManageClientCredentialsAppPage = lazy( + () => + import( + "./pages/UserSettingsPage/OAuth2ProviderPage/ManageClientCredentialsAppPage" + ), +); const TemplateVersionPage = lazy( () => import("./pages/TemplateVersionPage/TemplateVersionPage"), ); @@ -527,10 +539,14 @@ export const router = createBrowserRouter( path="external-auth" element={} /> - } - /> + + } /> + } /> + } + /> + } /> } /> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 86dacd4d19b3d..3775f6ddd20e2 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -4429,6 +4429,9 @@ export const MockOAuth2ProviderApps: TypesGen.OAuth2ProviderApp[] = [ name: "foo", redirect_uris: ["http://localhost:3001", "http://localhost:3002"], icon: "/icon/github.svg", + created_at: "2022-12-16T20:10:45.637452Z", + grant_types: ["authorization_code", "refresh_token"], + user_id: "test-user-id", endpoints: { authorization: "http://localhost:3001/oauth2/authorize", token: "http://localhost:3001/oauth2/token", @@ -4442,11 +4445,13 @@ export const MockOAuth2ProviderAppSecrets: TypesGen.OAuth2ProviderAppSecret[] = [ { id: "1", + created_at: "2022-12-16T20:10:45.637452Z", client_secret_truncated: "foo", last_used_at: null, }, { - id: "1", + id: "2", + created_at: "2022-12-16T20:10:45.637452Z", last_used_at: "2022-12-16T20:10:45.637452Z", client_secret_truncated: "foo", }, pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy