From 5cfa34b31e8dd9dd0f64a0bdbb8a1af89307c841 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 21 Dec 2023 12:38:42 -0900 Subject: [PATCH] feat: add OAuth2 applications (#11197) * Add database tables for OAuth2 applications These are applications that will be able to use OAuth2 to get an API key from Coder. * Add endpoints for managing OAuth2 applications These let you add, update, and remove OAuth2 applications. * Add frontend for managing OAuth2 applications --- coderd/apidoc/docs.go | 357 +++++++++++++++++ coderd/apidoc/swagger.json | 319 +++++++++++++++ coderd/database/db2sdk/db2sdk.go | 17 + coderd/database/dbauthz/dbauthz.go | 70 ++++ coderd/database/dbauthz/dbauthz_test.go | 83 ++++ coderd/database/dbgen/dbgen.go | 25 ++ coderd/database/dbmem/dbmem.go | 214 ++++++++++ coderd/database/dbmetrics/dbmetrics.go | 70 ++++ coderd/database/dbmock/dbmock.go | 148 +++++++ coderd/database/dump.sql | 37 ++ coderd/database/foreign_key_constraint.go | 1 + .../000182_oauth2_provider.down.sql | 2 + .../migrations/000182_oauth2_provider.up.sql | 25 ++ .../fixtures/000182_oauth2_provider.up.sql | 21 + coderd/database/models.go | 20 + coderd/database/querier.go | 10 + coderd/database/queries.sql.go | 276 +++++++++++++ coderd/database/queries/oauth2.sql | 62 +++ coderd/database/sqlc.yaml | 3 + coderd/database/unique_constraint.go | 4 + coderd/httpapi/httpapi.go | 2 +- coderd/httpmw/oauth2.go | 94 +++++ coderd/rbac/object.go | 16 + coderd/rbac/object_gen.go | 2 + codersdk/deployment.go | 4 + codersdk/oauth2.go | 158 ++++++++ docs/api/enterprise.md | 346 +++++++++++++++++ docs/api/schemas.md | 90 +++++ enterprise/coderd/coderd.go | 28 ++ enterprise/coderd/oauth2.go | 255 ++++++++++++ enterprise/coderd/oauth2_test.go | 367 ++++++++++++++++++ site/src/AppRouter.tsx | 28 ++ site/src/api/api.ts | 55 +++ site/src/api/queries/oauth2.ts | 93 +++++ site/src/api/typesGenerated.ts | 37 ++ .../DeploySettingsLayout/Sidebar.tsx | 5 + .../CreateOAuth2AppPage.tsx | 40 ++ .../CreateOAuth2AppPageView.stories.tsx | 45 +++ .../CreateOAuth2AppPageView.tsx | 52 +++ .../EditOAuth2AppPage.tsx | 105 +++++ .../EditOAuth2AppPageView.stories.tsx | 83 ++++ .../EditOAuth2AppPageView.tsx | 305 +++++++++++++++ .../OAuth2AppsSettingsPage/OAuth2AppForm.tsx | 87 +++++ .../OAuth2AppsSettingsPage.tsx | 29 ++ .../OAuth2AppsSettingsPageView.stories.tsx | 38 ++ .../OAuth2AppsSettingsPageView.tsx | 132 +++++++ site/src/testHelpers/entities.ts | 22 ++ 47 files changed, 4281 insertions(+), 1 deletion(-) create mode 100644 coderd/database/migrations/000182_oauth2_provider.down.sql create mode 100644 coderd/database/migrations/000182_oauth2_provider.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000182_oauth2_provider.up.sql create mode 100644 coderd/database/queries/oauth2.sql create mode 100644 codersdk/oauth2.go create mode 100644 enterprise/coderd/oauth2.go create mode 100644 enterprise/coderd/oauth2_test.go create mode 100644 site/src/api/queries/oauth2.ts create mode 100644 site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPage.tsx create mode 100644 site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPageView.stories.tsx create mode 100644 site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPageView.tsx create mode 100644 site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPage.tsx create mode 100644 site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPageView.stories.tsx create mode 100644 site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPageView.tsx create mode 100644 site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/OAuth2AppForm.tsx create mode 100644 site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx create mode 100644 site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx create mode 100644 site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 6fa579c05d..5edcdc113f 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1304,6 +1304,282 @@ const docTemplate = `{ } } }, + "/oauth2-provider/apps": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get OAuth2 applications.", + "operationId": "get-oauth2-applications", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.OAuth2ProviderApp" + } + } + } + } + }, + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Create OAuth2 application.", + "operationId": "create-oauth2-application", + "parameters": [ + { + "description": "The OAuth2 application to create.", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.PostOAuth2ProviderAppRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.OAuth2ProviderApp" + } + } + } + } + }, + "/oauth2-provider/apps/{app}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get OAuth2 application.", + "operationId": "get-oauth2-application", + "parameters": [ + { + "type": "string", + "description": "App ID", + "name": "app", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.OAuth2ProviderApp" + } + } + } + }, + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Update OAuth2 application.", + "operationId": "update-oauth2-application", + "parameters": [ + { + "type": "string", + "description": "App ID", + "name": "app", + "in": "path", + "required": true + }, + { + "description": "Update an OAuth2 application.", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.PutOAuth2ProviderAppRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.OAuth2ProviderApp" + } + } + } + }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": [ + "Enterprise" + ], + "summary": "Delete OAuth2 application.", + "operationId": "delete-oauth2-application", + "parameters": [ + { + "type": "string", + "description": "App ID", + "name": "app", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/oauth2-provider/apps/{app}/secrets": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get OAuth2 application secrets.", + "operationId": "get-oauth2-application-secrets", + "parameters": [ + { + "type": "string", + "description": "App ID", + "name": "app", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.OAuth2ProviderAppSecret" + } + } + } + } + }, + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Create OAuth2 application secret.", + "operationId": "create-oauth2-application-secret", + "parameters": [ + { + "type": "string", + "description": "App ID", + "name": "app", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.OAuth2ProviderAppSecretFull" + } + } + } + } + } + }, + "/oauth2-provider/apps/{app}/secrets/{secretID}": { + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": [ + "Enterprise" + ], + "summary": "Delete OAuth2 application secret.", + "operationId": "delete-oauth2-application-secret", + "parameters": [ + { + "type": "string", + "description": "App ID", + "name": "app", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Secret ID", + "name": "secretID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/organizations": { "post": { "security": [ @@ -9413,6 +9689,51 @@ const docTemplate = `{ } } }, + "codersdk.OAuth2ProviderApp": { + "type": "object", + "properties": { + "callback_url": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + } + }, + "codersdk.OAuth2ProviderAppSecret": { + "type": "object", + "properties": { + "client_secret_truncated": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "last_used_at": { + "type": "string" + } + } + }, + "codersdk.OAuth2ProviderAppSecretFull": { + "type": "object", + "properties": { + "client_secret_full": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + } + } + }, "codersdk.OAuthConversionResponse": { "type": "object", "properties": { @@ -9653,6 +9974,24 @@ const docTemplate = `{ } } }, + "codersdk.PostOAuth2ProviderAppRequest": { + "type": "object", + "required": [ + "callback_url", + "name" + ], + "properties": { + "callback_url": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "codersdk.PprofConfig": { "type": "object", "properties": { @@ -9932,6 +10271,24 @@ const docTemplate = `{ } } }, + "codersdk.PutOAuth2ProviderAppRequest": { + "type": "object", + "required": [ + "callback_url", + "name" + ], + "properties": { + "callback_url": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "codersdk.RBACResource": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 478797d1f1..1d86bcbf02 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1122,6 +1122,250 @@ } } }, + "/oauth2-provider/apps": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get OAuth2 applications.", + "operationId": "get-oauth2-applications", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.OAuth2ProviderApp" + } + } + } + } + }, + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Create OAuth2 application.", + "operationId": "create-oauth2-application", + "parameters": [ + { + "description": "The OAuth2 application to create.", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.PostOAuth2ProviderAppRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.OAuth2ProviderApp" + } + } + } + } + }, + "/oauth2-provider/apps/{app}": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get OAuth2 application.", + "operationId": "get-oauth2-application", + "parameters": [ + { + "type": "string", + "description": "App ID", + "name": "app", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.OAuth2ProviderApp" + } + } + } + }, + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Update OAuth2 application.", + "operationId": "update-oauth2-application", + "parameters": [ + { + "type": "string", + "description": "App ID", + "name": "app", + "in": "path", + "required": true + }, + { + "description": "Update an OAuth2 application.", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.PutOAuth2ProviderAppRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.OAuth2ProviderApp" + } + } + } + }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": ["Enterprise"], + "summary": "Delete OAuth2 application.", + "operationId": "delete-oauth2-application", + "parameters": [ + { + "type": "string", + "description": "App ID", + "name": "app", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/oauth2-provider/apps/{app}/secrets": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get OAuth2 application secrets.", + "operationId": "get-oauth2-application-secrets", + "parameters": [ + { + "type": "string", + "description": "App ID", + "name": "app", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.OAuth2ProviderAppSecret" + } + } + } + } + }, + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Create OAuth2 application secret.", + "operationId": "create-oauth2-application-secret", + "parameters": [ + { + "type": "string", + "description": "App ID", + "name": "app", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.OAuth2ProviderAppSecretFull" + } + } + } + } + } + }, + "/oauth2-provider/apps/{app}/secrets/{secretID}": { + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": ["Enterprise"], + "summary": "Delete OAuth2 application secret.", + "operationId": "delete-oauth2-application-secret", + "parameters": [ + { + "type": "string", + "description": "App ID", + "name": "app", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Secret ID", + "name": "secretID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/organizations": { "post": { "security": [ @@ -8442,6 +8686,51 @@ } } }, + "codersdk.OAuth2ProviderApp": { + "type": "object", + "properties": { + "callback_url": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + } + }, + "codersdk.OAuth2ProviderAppSecret": { + "type": "object", + "properties": { + "client_secret_truncated": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "last_used_at": { + "type": "string" + } + } + }, + "codersdk.OAuth2ProviderAppSecretFull": { + "type": "object", + "properties": { + "client_secret_full": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + } + } + }, "codersdk.OAuthConversionResponse": { "type": "object", "properties": { @@ -8672,6 +8961,21 @@ } } }, + "codersdk.PostOAuth2ProviderAppRequest": { + "type": "object", + "required": ["callback_url", "name"], + "properties": { + "callback_url": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "codersdk.PprofConfig": { "type": "object", "properties": { @@ -8928,6 +9232,21 @@ } } }, + "codersdk.PutOAuth2ProviderAppRequest": { + "type": "object", + "required": ["callback_url", "name"], + "properties": { + "callback_url": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "codersdk.RBACResource": { "type": "string", "enum": [ diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index ccf67ea98d..329f593ba9 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -225,6 +225,23 @@ func templateVersionParameterOptions(rawOptions json.RawMessage) ([]codersdk.Tem return options, nil } +func OAuth2ProviderApp(dbApp database.OAuth2ProviderApp) codersdk.OAuth2ProviderApp { + return codersdk.OAuth2ProviderApp{ + ID: dbApp.ID, + Name: dbApp.Name, + CallbackURL: dbApp.CallbackURL, + Icon: dbApp.Icon, + } +} + +func OAuth2ProviderApps(dbApps []database.OAuth2ProviderApp) []codersdk.OAuth2ProviderApp { + apps := []codersdk.OAuth2ProviderApp{} + for _, dbApp := range dbApps { + apps = append(apps, OAuth2ProviderApp(dbApp)) + } + return apps +} + 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 0a1858f559..6e236e3442 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -805,6 +805,20 @@ func (q *querier) DeleteLicense(ctx context.Context, id int32) (int32, error) { return id, nil } +func (q *querier) DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error { + if err := q.authorizeContext(ctx, rbac.ActionDelete, rbac.ResourceOAuth2ProviderApp); err != nil { + return err + } + return q.db.DeleteOAuth2ProviderAppByID(ctx, id) +} + +func (q *querier) DeleteOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) error { + if err := q.authorizeContext(ctx, rbac.ActionDelete, rbac.ResourceOAuth2ProviderAppSecret); err != nil { + return err + } + return q.db.DeleteOAuth2ProviderAppSecretByID(ctx, id) +} + func (q *querier) DeleteOldProvisionerDaemons(ctx context.Context) error { if err := q.authorizeContext(ctx, rbac.ActionDelete, rbac.ResourceSystem); err != nil { return err @@ -1131,6 +1145,34 @@ func (q *querier) GetLogoURL(ctx context.Context) (string, error) { return q.db.GetLogoURL(ctx) } +func (q *querier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) { + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceOAuth2ProviderApp); err != nil { + return database.OAuth2ProviderApp{}, err + } + return q.db.GetOAuth2ProviderAppByID(ctx, id) +} + +func (q *querier) GetOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderAppSecret, error) { + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceOAuth2ProviderAppSecret); err != nil { + return database.OAuth2ProviderAppSecret{}, err + } + return q.db.GetOAuth2ProviderAppSecretByID(ctx, id) +} + +func (q *querier) GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, appID uuid.UUID) ([]database.OAuth2ProviderAppSecret, error) { + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceOAuth2ProviderAppSecret); err != nil { + return []database.OAuth2ProviderAppSecret{}, err + } + return q.db.GetOAuth2ProviderAppSecretsByAppID(ctx, appID) +} + +func (q *querier) GetOAuth2ProviderApps(ctx context.Context) ([]database.OAuth2ProviderApp, error) { + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceOAuth2ProviderApp); err != nil { + return []database.OAuth2ProviderApp{}, err + } + return q.db.GetOAuth2ProviderApps(ctx) +} + func (q *querier) GetOAuthSigningKey(ctx context.Context) (string, error) { if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceSystem); err != nil { return "", err @@ -2145,6 +2187,20 @@ func (q *querier) InsertMissingGroups(ctx context.Context, arg database.InsertMi return q.db.InsertMissingGroups(ctx, arg) } +func (q *querier) InsertOAuth2ProviderApp(ctx context.Context, arg database.InsertOAuth2ProviderAppParams) (database.OAuth2ProviderApp, error) { + if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceOAuth2ProviderApp); err != nil { + return database.OAuth2ProviderApp{}, err + } + return q.db.InsertOAuth2ProviderApp(ctx, arg) +} + +func (q *querier) InsertOAuth2ProviderAppSecret(ctx context.Context, arg database.InsertOAuth2ProviderAppSecretParams) (database.OAuth2ProviderAppSecret, error) { + if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceOAuth2ProviderAppSecret); err != nil { + return database.OAuth2ProviderAppSecret{}, err + } + return q.db.InsertOAuth2ProviderAppSecret(ctx, arg) +} + func (q *querier) InsertOrganization(ctx context.Context, arg database.InsertOrganizationParams) (database.Organization, error) { return insert(q.log, q.auth, rbac.ResourceOrganization, q.db.InsertOrganization)(ctx, arg) } @@ -2500,6 +2556,20 @@ func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemb return q.db.UpdateMemberRoles(ctx, arg) } +func (q *querier) UpdateOAuth2ProviderAppByID(ctx context.Context, arg database.UpdateOAuth2ProviderAppByIDParams) (database.OAuth2ProviderApp, error) { + if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceOAuth2ProviderApp); 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, rbac.ActionUpdate, rbac.ResourceOAuth2ProviderAppSecret); err != nil { + return database.OAuth2ProviderAppSecret{}, err + } + return q.db.UpdateOAuth2ProviderAppSecretByID(ctx, arg) +} + func (q *querier) UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg database.UpdateProvisionerDaemonLastSeenAtParams) error { if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceProvisionerDaemon); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 41aecb48fa..0d23f33c9c 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2200,3 +2200,86 @@ func (s *MethodTestSuite) TestSystemFunctions() { check.Args(uuid.New()).Asserts(rbac.ResourceSystem, rbac.ActionRead) })) } + +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"}), + } + check.Args().Asserts(rbac.ResourceOAuth2ProviderApp, rbac.ActionRead).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.ResourceOAuth2ProviderApp, rbac.ActionRead).Returns(app) + })) + s.Run("InsertOAuth2ProviderApp", s.Subtest(func(db database.Store, check *expects) { + check.Args(database.InsertOAuth2ProviderAppParams{}).Asserts(rbac.ResourceOAuth2ProviderApp, rbac.ActionCreate) + })) + s.Run("UpdateOAuth2ProviderAppByID", s.Subtest(func(db database.Store, check *expects) { + app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{}) + app.Name = "my-new-name" + app.UpdatedAt = time.Now() + check.Args(database.UpdateOAuth2ProviderAppByIDParams{ + ID: app.ID, + Name: app.Name, + CallbackURL: app.CallbackURL, + UpdatedAt: app.UpdatedAt, + }).Asserts(rbac.ResourceOAuth2ProviderApp, rbac.ActionUpdate).Returns(app) + })) + s.Run("DeleteOAuth2ProviderAppByID", s.Subtest(func(db database.Store, check *expects) { + app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{}) + check.Args(app.ID).Asserts(rbac.ResourceOAuth2ProviderApp, rbac.ActionDelete) + })) +} + +func (s *MethodTestSuite) TestOAuth2ProviderAppSecrets() { + s.Run("GetOAuth2ProviderAppSecretsByAppID", s.Subtest(func(db database.Store, check *expects) { + app1 := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{}) + app2 := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{}) + secrets := []database.OAuth2ProviderAppSecret{ + dbgen.OAuth2ProviderAppSecret(s.T(), db, database.OAuth2ProviderAppSecret{ + AppID: app1.ID, + CreatedAt: time.Now().Add(-time.Hour), // For ordering. + }), + dbgen.OAuth2ProviderAppSecret(s.T(), db, database.OAuth2ProviderAppSecret{ + AppID: app1.ID, + }), + } + _ = dbgen.OAuth2ProviderAppSecret(s.T(), db, database.OAuth2ProviderAppSecret{ + AppID: app2.ID, + }) + check.Args(app1.ID).Asserts(rbac.ResourceOAuth2ProviderAppSecret, rbac.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.ResourceOAuth2ProviderAppSecret, rbac.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.ResourceOAuth2ProviderAppSecret, rbac.ActionCreate) + })) + s.Run("UpdateOAuth2ProviderAppSecretByID", 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, + }) + secret.LastUsedAt = sql.NullTime{Time: time.Now(), Valid: true} + check.Args(database.UpdateOAuth2ProviderAppSecretByIDParams{ + ID: secret.ID, + LastUsedAt: secret.LastUsedAt, + }).Asserts(rbac.ResourceOAuth2ProviderAppSecret, rbac.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.ResourceOAuth2ProviderAppSecret, rbac.ActionDelete) + })) +} diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 1848dc6615..6df7befb0e 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -676,6 +676,31 @@ func WorkspaceAgentStat(t testing.TB, db database.Store, orig database.Workspace return scheme } +func OAuth2ProviderApp(t testing.TB, db database.Store, seed database.OAuth2ProviderApp) database.OAuth2ProviderApp { + app, err := db.InsertOAuth2ProviderApp(genCtx, database.InsertOAuth2ProviderAppParams{ + ID: takeFirst(seed.ID, uuid.New()), + Name: takeFirst(seed.Name, namesgenerator.GetRandomName(1)), + CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()), + UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()), + Icon: takeFirst(seed.Icon, ""), + CallbackURL: takeFirst(seed.CallbackURL, "http://localhost"), + }) + require.NoError(t, err, "insert oauth2 app") + return app +} + +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()), + HashedSecret: takeFirstSlice(seed.HashedSecret, []byte("hashed-secret")), + DisplaySecret: takeFirst(seed.DisplaySecret, "secret"), + AppID: takeFirst(seed.AppID, uuid.New()), + }) + require.NoError(t, err, "insert oauth2 app secret") + return app +} + func must[V any](v V, err error) V { if err != nil { panic(err) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 66f369b1a4..e9fdd47987 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -130,6 +130,8 @@ type data struct { groupMembers []database.GroupMember groups []database.Group licenses []database.License + oauth2ProviderApps []database.OAuth2ProviderApp + oauth2ProviderAppSecrets []database.OAuth2ProviderAppSecret parameterSchemas []database.ParameterSchema provisionerDaemons []database.ProvisionerDaemon provisionerJobLogs []database.ProvisionerJobLog @@ -1144,6 +1146,43 @@ func (q *FakeQuerier) DeleteLicense(_ context.Context, id int32) (int32, error) return 0, sql.ErrNoRows } +func (q *FakeQuerier) DeleteOAuth2ProviderAppByID(_ context.Context, id uuid.UUID) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, app := range q.oauth2ProviderApps { + if app.ID == id { + q.oauth2ProviderApps[index] = q.oauth2ProviderApps[len(q.oauth2ProviderApps)-1] + q.oauth2ProviderApps = q.oauth2ProviderApps[:len(q.oauth2ProviderApps)-1] + + secrets := []database.OAuth2ProviderAppSecret{} + for _, secret := range q.oauth2ProviderAppSecrets { + if secret.AppID != id { + secrets = append(secrets, secret) + } + } + q.oauth2ProviderAppSecrets = secrets + + return nil + } + } + return sql.ErrNoRows +} + +func (q *FakeQuerier) DeleteOAuth2ProviderAppSecretByID(_ context.Context, id uuid.UUID) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, secret := range q.oauth2ProviderAppSecrets { + if secret.ID == id { + q.oauth2ProviderAppSecrets[index] = q.oauth2ProviderAppSecrets[len(q.oauth2ProviderAppSecrets)-1] + q.oauth2ProviderAppSecrets = q.oauth2ProviderAppSecrets[:len(q.oauth2ProviderAppSecrets)-1] + return nil + } + } + return sql.ErrNoRows +} + func (q *FakeQuerier) DeleteOldProvisionerDaemons(_ context.Context) error { q.mutex.Lock() defer q.mutex.Unlock() @@ -2004,6 +2043,68 @@ func (q *FakeQuerier) GetLogoURL(_ context.Context) (string, error) { return q.logoURL, nil } +func (q *FakeQuerier) GetOAuth2ProviderAppByID(_ context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + for _, app := range q.oauth2ProviderApps { + if app.ID == id { + return app, nil + } + } + return database.OAuth2ProviderApp{}, sql.ErrNoRows +} + +func (q *FakeQuerier) GetOAuth2ProviderAppSecretByID(_ context.Context, id uuid.UUID) (database.OAuth2ProviderAppSecret, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + for _, secret := range q.oauth2ProviderAppSecrets { + if secret.ID == id { + return secret, nil + } + } + return database.OAuth2ProviderAppSecret{}, sql.ErrNoRows +} + +func (q *FakeQuerier) GetOAuth2ProviderAppSecretsByAppID(_ context.Context, appID uuid.UUID) ([]database.OAuth2ProviderAppSecret, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + for _, app := range q.oauth2ProviderApps { + if app.ID == appID { + secrets := []database.OAuth2ProviderAppSecret{} + for _, secret := range q.oauth2ProviderAppSecrets { + if secret.AppID == appID { + secrets = append(secrets, secret) + } + } + + slices.SortFunc(secrets, func(a, b database.OAuth2ProviderAppSecret) int { + if a.CreatedAt.Before(b.CreatedAt) { + return -1 + } else if a.CreatedAt.Equal(b.CreatedAt) { + return 0 + } + return 1 + }) + return secrets, nil + } + } + + return []database.OAuth2ProviderAppSecret{}, sql.ErrNoRows +} + +func (q *FakeQuerier) GetOAuth2ProviderApps(_ context.Context) ([]database.OAuth2ProviderApp, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + slices.SortFunc(q.oauth2ProviderApps, func(a, b database.OAuth2ProviderApp) int { + return slice.Ascending(a.Name, b.Name) + }) + return q.oauth2ProviderApps, nil +} + func (q *FakeQuerier) GetOAuthSigningKey(_ context.Context) (string, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -4946,6 +5047,61 @@ func (q *FakeQuerier) InsertMissingGroups(_ context.Context, arg database.Insert return newGroups, nil } +func (q *FakeQuerier) InsertOAuth2ProviderApp(_ context.Context, arg database.InsertOAuth2ProviderAppParams) (database.OAuth2ProviderApp, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.OAuth2ProviderApp{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for _, app := range q.oauth2ProviderApps { + if app.Name == arg.Name { + return database.OAuth2ProviderApp{}, errDuplicateKey + } + } + + //nolint:gosimple // Go wants database.OAuth2ProviderApp(arg), but we cannot be sure the structs will remain identical. + app := database.OAuth2ProviderApp{ + ID: arg.ID, + CreatedAt: arg.CreatedAt, + UpdatedAt: arg.UpdatedAt, + Name: arg.Name, + Icon: arg.Icon, + CallbackURL: arg.CallbackURL, + } + q.oauth2ProviderApps = append(q.oauth2ProviderApps, app) + + return app, nil +} + +func (q *FakeQuerier) InsertOAuth2ProviderAppSecret(_ context.Context, arg database.InsertOAuth2ProviderAppSecretParams) (database.OAuth2ProviderAppSecret, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.OAuth2ProviderAppSecret{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for _, app := range q.oauth2ProviderApps { + if app.ID == arg.AppID { + secret := database.OAuth2ProviderAppSecret{ + ID: arg.ID, + CreatedAt: arg.CreatedAt, + HashedSecret: arg.HashedSecret, + DisplaySecret: arg.DisplaySecret, + AppID: arg.AppID, + } + q.oauth2ProviderAppSecrets = append(q.oauth2ProviderAppSecrets, secret) + return secret, nil + } + } + + return database.OAuth2ProviderAppSecret{}, sql.ErrNoRows +} + func (q *FakeQuerier) InsertOrganization(_ context.Context, arg database.InsertOrganizationParams) (database.Organization, error) { if err := validateDatabaseType(arg); err != nil { return database.Organization{}, err @@ -5947,6 +6103,64 @@ func (q *FakeQuerier) UpdateMemberRoles(_ context.Context, arg database.UpdateMe return database.OrganizationMember{}, sql.ErrNoRows } +func (q *FakeQuerier) UpdateOAuth2ProviderAppByID(_ context.Context, arg database.UpdateOAuth2ProviderAppByIDParams) (database.OAuth2ProviderApp, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.OAuth2ProviderApp{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for _, app := range q.oauth2ProviderApps { + if app.Name == arg.Name && app.ID != arg.ID { + return database.OAuth2ProviderApp{}, errDuplicateKey + } + } + + for index, app := range q.oauth2ProviderApps { + if app.ID == arg.ID { + newApp := database.OAuth2ProviderApp{ + ID: arg.ID, + CreatedAt: app.CreatedAt, + UpdatedAt: arg.UpdatedAt, + Name: arg.Name, + Icon: arg.Icon, + CallbackURL: arg.CallbackURL, + } + q.oauth2ProviderApps[index] = newApp + return newApp, nil + } + } + return database.OAuth2ProviderApp{}, sql.ErrNoRows +} + +func (q *FakeQuerier) UpdateOAuth2ProviderAppSecretByID(_ context.Context, arg database.UpdateOAuth2ProviderAppSecretByIDParams) (database.OAuth2ProviderAppSecret, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.OAuth2ProviderAppSecret{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, secret := range q.oauth2ProviderAppSecrets { + if secret.ID == arg.ID { + newSecret := database.OAuth2ProviderAppSecret{ + ID: arg.ID, + CreatedAt: secret.CreatedAt, + HashedSecret: secret.HashedSecret, + DisplaySecret: secret.DisplaySecret, + AppID: secret.AppID, + LastUsedAt: arg.LastUsedAt, + } + q.oauth2ProviderAppSecrets[index] = newSecret + return newSecret, nil + } + } + return database.OAuth2ProviderAppSecret{}, sql.ErrNoRows +} + func (q *FakeQuerier) UpdateProvisionerDaemonLastSeenAt(_ context.Context, arg database.UpdateProvisionerDaemonLastSeenAtParams) error { err := validateDatabaseType(arg) if err != nil { diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 1b1aa1e631..d11b376b37 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -218,6 +218,20 @@ func (m metricsStore) DeleteLicense(ctx context.Context, id int32) (int32, error return licenseID, err } +func (m metricsStore) DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error { + start := time.Now() + r0 := m.s.DeleteOAuth2ProviderAppByID(ctx, id) + m.queryLatencies.WithLabelValues("DeleteOAuth2ProviderAppByID").Observe(time.Since(start).Seconds()) + return r0 +} + +func (m metricsStore) DeleteOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) error { + start := time.Now() + r0 := m.s.DeleteOAuth2ProviderAppSecretByID(ctx, id) + m.queryLatencies.WithLabelValues("DeleteOAuth2ProviderAppSecretByID").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) DeleteOldProvisionerDaemons(ctx context.Context) error { start := time.Now() r0 := m.s.DeleteOldProvisionerDaemons(ctx) @@ -566,6 +580,34 @@ func (m metricsStore) GetLogoURL(ctx context.Context) (string, error) { return url, err } +func (m metricsStore) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) { + start := time.Now() + r0, r1 := m.s.GetOAuth2ProviderAppByID(ctx, id) + m.queryLatencies.WithLabelValues("GetOAuth2ProviderAppByID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m metricsStore) GetOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderAppSecret, error) { + start := time.Now() + r0, r1 := m.s.GetOAuth2ProviderAppSecretByID(ctx, id) + m.queryLatencies.WithLabelValues("GetOAuth2ProviderAppSecretByID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m metricsStore) GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, appID uuid.UUID) ([]database.OAuth2ProviderAppSecret, error) { + start := time.Now() + r0, r1 := m.s.GetOAuth2ProviderAppSecretsByAppID(ctx, appID) + m.queryLatencies.WithLabelValues("GetOAuth2ProviderAppSecretsByAppID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m metricsStore) GetOAuth2ProviderApps(ctx context.Context) ([]database.OAuth2ProviderApp, error) { + start := time.Now() + r0, r1 := m.s.GetOAuth2ProviderApps(ctx) + m.queryLatencies.WithLabelValues("GetOAuth2ProviderApps").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetOAuthSigningKey(ctx context.Context) (string, error) { start := time.Now() r0, r1 := m.s.GetOAuthSigningKey(ctx) @@ -1334,6 +1376,20 @@ func (m metricsStore) InsertMissingGroups(ctx context.Context, arg database.Inse return r0, r1 } +func (m metricsStore) InsertOAuth2ProviderApp(ctx context.Context, arg database.InsertOAuth2ProviderAppParams) (database.OAuth2ProviderApp, error) { + start := time.Now() + r0, r1 := m.s.InsertOAuth2ProviderApp(ctx, arg) + m.queryLatencies.WithLabelValues("InsertOAuth2ProviderApp").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m metricsStore) InsertOAuth2ProviderAppSecret(ctx context.Context, arg database.InsertOAuth2ProviderAppSecretParams) (database.OAuth2ProviderAppSecret, error) { + start := time.Now() + r0, r1 := m.s.InsertOAuth2ProviderAppSecret(ctx, arg) + m.queryLatencies.WithLabelValues("InsertOAuth2ProviderAppSecret").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) InsertOrganization(ctx context.Context, arg database.InsertOrganizationParams) (database.Organization, error) { start := time.Now() organization, err := m.s.InsertOrganization(ctx, arg) @@ -1593,6 +1649,20 @@ func (m metricsStore) UpdateMemberRoles(ctx context.Context, arg database.Update return member, err } +func (m metricsStore) UpdateOAuth2ProviderAppByID(ctx context.Context, arg database.UpdateOAuth2ProviderAppByIDParams) (database.OAuth2ProviderApp, error) { + start := time.Now() + r0, r1 := m.s.UpdateOAuth2ProviderAppByID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateOAuth2ProviderAppByID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m metricsStore) UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg database.UpdateOAuth2ProviderAppSecretByIDParams) (database.OAuth2ProviderAppSecret, error) { + start := time.Now() + r0, r1 := m.s.UpdateOAuth2ProviderAppSecretByID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateOAuth2ProviderAppSecretByID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg database.UpdateProvisionerDaemonLastSeenAtParams) error { start := time.Now() r0 := m.s.UpdateProvisionerDaemonLastSeenAt(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index d8fa998ee6..64c4e73ef1 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -323,6 +323,34 @@ func (mr *MockStoreMockRecorder) DeleteLicense(arg0, arg1 interface{}) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteLicense", reflect.TypeOf((*MockStore)(nil).DeleteLicense), arg0, arg1) } +// DeleteOAuth2ProviderAppByID mocks base method. +func (m *MockStore) DeleteOAuth2ProviderAppByID(arg0 context.Context, arg1 uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteOAuth2ProviderAppByID", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteOAuth2ProviderAppByID indicates an expected call of DeleteOAuth2ProviderAppByID. +func (mr *MockStoreMockRecorder) DeleteOAuth2ProviderAppByID(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOAuth2ProviderAppByID", reflect.TypeOf((*MockStore)(nil).DeleteOAuth2ProviderAppByID), arg0, arg1) +} + +// DeleteOAuth2ProviderAppSecretByID mocks base method. +func (m *MockStore) DeleteOAuth2ProviderAppSecretByID(arg0 context.Context, arg1 uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteOAuth2ProviderAppSecretByID", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteOAuth2ProviderAppSecretByID indicates an expected call of DeleteOAuth2ProviderAppSecretByID. +func (mr *MockStoreMockRecorder) DeleteOAuth2ProviderAppSecretByID(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOAuth2ProviderAppSecretByID", reflect.TypeOf((*MockStore)(nil).DeleteOAuth2ProviderAppSecretByID), arg0, arg1) +} + // DeleteOldProvisionerDaemons mocks base method. func (m *MockStore) DeleteOldProvisionerDaemons(arg0 context.Context) error { m.ctrl.T.Helper() @@ -1113,6 +1141,66 @@ func (mr *MockStoreMockRecorder) GetLogoURL(arg0 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLogoURL", reflect.TypeOf((*MockStore)(nil).GetLogoURL), arg0) } +// GetOAuth2ProviderAppByID mocks base method. +func (m *MockStore) GetOAuth2ProviderAppByID(arg0 context.Context, arg1 uuid.UUID) (database.OAuth2ProviderApp, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOAuth2ProviderAppByID", arg0, arg1) + ret0, _ := ret[0].(database.OAuth2ProviderApp) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetOAuth2ProviderAppByID indicates an expected call of GetOAuth2ProviderAppByID. +func (mr *MockStoreMockRecorder) GetOAuth2ProviderAppByID(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuth2ProviderAppByID", reflect.TypeOf((*MockStore)(nil).GetOAuth2ProviderAppByID), arg0, arg1) +} + +// GetOAuth2ProviderAppSecretByID mocks base method. +func (m *MockStore) GetOAuth2ProviderAppSecretByID(arg0 context.Context, arg1 uuid.UUID) (database.OAuth2ProviderAppSecret, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOAuth2ProviderAppSecretByID", arg0, arg1) + ret0, _ := ret[0].(database.OAuth2ProviderAppSecret) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetOAuth2ProviderAppSecretByID indicates an expected call of GetOAuth2ProviderAppSecretByID. +func (mr *MockStoreMockRecorder) GetOAuth2ProviderAppSecretByID(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuth2ProviderAppSecretByID", reflect.TypeOf((*MockStore)(nil).GetOAuth2ProviderAppSecretByID), arg0, arg1) +} + +// GetOAuth2ProviderAppSecretsByAppID mocks base method. +func (m *MockStore) GetOAuth2ProviderAppSecretsByAppID(arg0 context.Context, arg1 uuid.UUID) ([]database.OAuth2ProviderAppSecret, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOAuth2ProviderAppSecretsByAppID", arg0, arg1) + ret0, _ := ret[0].([]database.OAuth2ProviderAppSecret) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetOAuth2ProviderAppSecretsByAppID indicates an expected call of GetOAuth2ProviderAppSecretsByAppID. +func (mr *MockStoreMockRecorder) GetOAuth2ProviderAppSecretsByAppID(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuth2ProviderAppSecretsByAppID", reflect.TypeOf((*MockStore)(nil).GetOAuth2ProviderAppSecretsByAppID), arg0, arg1) +} + +// GetOAuth2ProviderApps mocks base method. +func (m *MockStore) GetOAuth2ProviderApps(arg0 context.Context) ([]database.OAuth2ProviderApp, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOAuth2ProviderApps", arg0) + ret0, _ := ret[0].([]database.OAuth2ProviderApp) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetOAuth2ProviderApps indicates an expected call of GetOAuth2ProviderApps. +func (mr *MockStoreMockRecorder) GetOAuth2ProviderApps(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuth2ProviderApps", reflect.TypeOf((*MockStore)(nil).GetOAuth2ProviderApps), arg0) +} + // GetOAuthSigningKey mocks base method. func (m *MockStore) GetOAuthSigningKey(arg0 context.Context) (string, error) { m.ctrl.T.Helper() @@ -2803,6 +2891,36 @@ func (mr *MockStoreMockRecorder) InsertMissingGroups(arg0, arg1 interface{}) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertMissingGroups", reflect.TypeOf((*MockStore)(nil).InsertMissingGroups), arg0, arg1) } +// InsertOAuth2ProviderApp mocks base method. +func (m *MockStore) InsertOAuth2ProviderApp(arg0 context.Context, arg1 database.InsertOAuth2ProviderAppParams) (database.OAuth2ProviderApp, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertOAuth2ProviderApp", arg0, arg1) + ret0, _ := ret[0].(database.OAuth2ProviderApp) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertOAuth2ProviderApp indicates an expected call of InsertOAuth2ProviderApp. +func (mr *MockStoreMockRecorder) InsertOAuth2ProviderApp(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertOAuth2ProviderApp", reflect.TypeOf((*MockStore)(nil).InsertOAuth2ProviderApp), arg0, arg1) +} + +// InsertOAuth2ProviderAppSecret mocks base method. +func (m *MockStore) InsertOAuth2ProviderAppSecret(arg0 context.Context, arg1 database.InsertOAuth2ProviderAppSecretParams) (database.OAuth2ProviderAppSecret, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertOAuth2ProviderAppSecret", arg0, arg1) + ret0, _ := ret[0].(database.OAuth2ProviderAppSecret) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertOAuth2ProviderAppSecret indicates an expected call of InsertOAuth2ProviderAppSecret. +func (mr *MockStoreMockRecorder) InsertOAuth2ProviderAppSecret(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertOAuth2ProviderAppSecret", reflect.TypeOf((*MockStore)(nil).InsertOAuth2ProviderAppSecret), arg0, arg1) +} + // InsertOrganization mocks base method. func (m *MockStore) InsertOrganization(arg0 context.Context, arg1 database.InsertOrganizationParams) (database.Organization, error) { m.ctrl.T.Helper() @@ -3362,6 +3480,36 @@ func (mr *MockStoreMockRecorder) UpdateMemberRoles(arg0, arg1 interface{}) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateMemberRoles", reflect.TypeOf((*MockStore)(nil).UpdateMemberRoles), arg0, arg1) } +// UpdateOAuth2ProviderAppByID mocks base method. +func (m *MockStore) UpdateOAuth2ProviderAppByID(arg0 context.Context, arg1 database.UpdateOAuth2ProviderAppByIDParams) (database.OAuth2ProviderApp, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateOAuth2ProviderAppByID", arg0, arg1) + ret0, _ := ret[0].(database.OAuth2ProviderApp) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateOAuth2ProviderAppByID indicates an expected call of UpdateOAuth2ProviderAppByID. +func (mr *MockStoreMockRecorder) UpdateOAuth2ProviderAppByID(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOAuth2ProviderAppByID", reflect.TypeOf((*MockStore)(nil).UpdateOAuth2ProviderAppByID), arg0, arg1) +} + +// UpdateOAuth2ProviderAppSecretByID mocks base method. +func (m *MockStore) UpdateOAuth2ProviderAppSecretByID(arg0 context.Context, arg1 database.UpdateOAuth2ProviderAppSecretByIDParams) (database.OAuth2ProviderAppSecret, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateOAuth2ProviderAppSecretByID", arg0, arg1) + ret0, _ := ret[0].(database.OAuth2ProviderAppSecret) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateOAuth2ProviderAppSecretByID indicates an expected call of UpdateOAuth2ProviderAppSecretByID. +func (mr *MockStoreMockRecorder) UpdateOAuth2ProviderAppSecretByID(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOAuth2ProviderAppSecretByID", reflect.TypeOf((*MockStore)(nil).UpdateOAuth2ProviderAppSecretByID), arg0, arg1) +} + // UpdateProvisionerDaemonLastSeenAt mocks base method. func (m *MockStore) UpdateProvisionerDaemonLastSeenAt(arg0 context.Context, arg1 database.UpdateProvisionerDaemonLastSeenAtParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 0dd504ce2f..ee0d9f92f4 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -458,6 +458,28 @@ CREATE SEQUENCE licenses_id_seq ALTER SEQUENCE licenses_id_seq OWNED BY licenses.id; +CREATE TABLE oauth2_provider_app_secrets ( + id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + last_used_at timestamp with time zone, + hashed_secret bytea NOT NULL, + display_secret text NOT NULL, + app_id uuid NOT NULL +); + +COMMENT ON COLUMN oauth2_provider_app_secrets.display_secret IS 'The tail end of the original secret so secrets can be differentiated.'; + +CREATE TABLE oauth2_provider_apps ( + id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + name character varying(64) NOT NULL, + icon character varying(256) NOT NULL, + callback_url text NOT NULL +); + +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.'; + CREATE TABLE organization_members ( user_id uuid NOT NULL, organization_id uuid NOT NULL, @@ -1270,6 +1292,18 @@ ALTER TABLE ONLY licenses ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_pkey PRIMARY KEY (id); +ALTER TABLE ONLY oauth2_provider_app_secrets + ADD CONSTRAINT oauth2_provider_app_secrets_app_id_hashed_secret_key UNIQUE (app_id, hashed_secret); + +ALTER TABLE ONLY oauth2_provider_app_secrets + ADD CONSTRAINT oauth2_provider_app_secrets_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY oauth2_provider_apps + ADD CONSTRAINT oauth2_provider_apps_name_key UNIQUE (name); + +ALTER TABLE ONLY oauth2_provider_apps + ADD CONSTRAINT oauth2_provider_apps_pkey PRIMARY KEY (id); + ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_pkey PRIMARY KEY (organization_id, user_id); @@ -1496,6 +1530,9 @@ ALTER TABLE ONLY group_members ALTER TABLE ONLY groups ADD CONSTRAINT groups_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; +ALTER TABLE ONLY oauth2_provider_app_secrets + ADD CONSTRAINT oauth2_provider_app_secrets_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; + ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_organization_id_uuid_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 85d5fa9faa..5dc75af93f 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -13,6 +13,7 @@ const ( ForeignKeyGroupMembersGroupID ForeignKeyConstraint = "group_members_group_id_fkey" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE; ForeignKeyGroupMembersUserID ForeignKeyConstraint = "group_members_user_id_fkey" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyGroupsOrganizationID ForeignKeyConstraint = "groups_organization_id_fkey" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyOauth2ProviderAppSecretsAppID ForeignKeyConstraint = "oauth2_provider_app_secrets_app_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; ForeignKeyOrganizationMembersOrganizationIDUUID ForeignKeyConstraint = "organization_members_organization_id_uuid_fkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_organization_id_uuid_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; ForeignKeyOrganizationMembersUserIDUUID ForeignKeyConstraint = "organization_members_user_id_uuid_fkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyParameterSchemasJobID ForeignKeyConstraint = "parameter_schemas_job_id_fkey" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000182_oauth2_provider.down.sql b/coderd/database/migrations/000182_oauth2_provider.down.sql new file mode 100644 index 0000000000..7628cdecc5 --- /dev/null +++ b/coderd/database/migrations/000182_oauth2_provider.down.sql @@ -0,0 +1,2 @@ +DROP TABLE oauth2_provider_app_secrets; +DROP TABLE oauth2_provider_apps; diff --git a/coderd/database/migrations/000182_oauth2_provider.up.sql b/coderd/database/migrations/000182_oauth2_provider.up.sql new file mode 100644 index 0000000000..609f9da34e --- /dev/null +++ b/coderd/database/migrations/000182_oauth2_provider.up.sql @@ -0,0 +1,25 @@ +CREATE TABLE oauth2_provider_apps ( + id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + name varchar(64) NOT NULL, + icon varchar(256) NOT NULL, + callback_url text NOT NULL, + PRIMARY KEY (id), + UNIQUE(name) +); + +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.'; + +CREATE TABLE oauth2_provider_app_secrets ( + id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + last_used_at timestamp with time zone NULL, + hashed_secret bytea NOT NULL, + display_secret text NOT NULL, + app_id uuid NOT NULL REFERENCES oauth2_provider_apps (id) ON DELETE CASCADE, + PRIMARY KEY (id), + UNIQUE(app_id, hashed_secret) +); + +COMMENT ON COLUMN oauth2_provider_app_secrets.display_secret IS 'The tail end of the original secret so secrets can be differentiated.'; diff --git a/coderd/database/migrations/testdata/fixtures/000182_oauth2_provider.up.sql b/coderd/database/migrations/testdata/fixtures/000182_oauth2_provider.up.sql new file mode 100644 index 0000000000..d46622333b --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000182_oauth2_provider.up.sql @@ -0,0 +1,21 @@ +INSERT INTO oauth2_provider_apps + (id, created_at, updated_at, name, icon, callback_url) +VALUES ( + 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + '2023-06-15 10:23:54+00', + '2023-06-15 10:23:54+00', + 'oauth2-app', + '/some/icon.svg', + 'http://coder.com/oauth2/callback' +); + +INSERT INTO oauth2_provider_app_secrets + (id, created_at, last_used_at, hashed_secret, display_secret, app_id) +VALUES ( + 'b0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + '2023-06-15 10:25:33+00', + '2023-12-15 11:40:20+00', + CAST('abcdefg' AS bytea), + 'fg', + 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11' +); diff --git a/coderd/database/models.go b/coderd/database/models.go index 1a6c5bed8d..e8c8ae2c31 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1788,6 +1788,26 @@ type License struct { UUID uuid.UUID `db:"uuid" json:"uuid"` } +// A table used to configure apps that can use Coder as an OAuth2 provider, the reverse of what we are calling external authentication. +type OAuth2ProviderApp 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"` + CallbackURL string `db:"callback_url" json:"callback_url"` +} + +type OAuth2ProviderAppSecret struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + LastUsedAt sql.NullTime `db:"last_used_at" json:"last_used_at"` + HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` + // The tail end of the original secret so secrets can be differentiated. + DisplaySecret string `db:"display_secret" json:"display_secret"` + AppID uuid.UUID `db:"app_id" json:"app_id"` +} + type Organization struct { ID uuid.UUID `db:"id" json:"id"` Name string `db:"name" json:"name"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index eb7009adec..3d2631c49f 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -57,6 +57,8 @@ type sqlcQuerier interface { DeleteGroupMemberFromGroup(ctx context.Context, arg DeleteGroupMemberFromGroupParams) error DeleteGroupMembersByOrgAndUser(ctx context.Context, arg DeleteGroupMembersByOrgAndUserParams) error DeleteLicense(ctx context.Context, id int32) (int32, error) + DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error + DeleteOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) error // Delete provisioner daemons that have been created at least a week ago // and have not connected to coderd since a week. // A provisioner daemon with "zeroed" last_seen_at column indicates possible @@ -122,6 +124,10 @@ type sqlcQuerier interface { GetLicenseByID(ctx context.Context, id int32) (License, error) GetLicenses(ctx context.Context) ([]License, error) GetLogoURL(ctx context.Context) (string, error) + GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) + GetOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppSecret, error) + GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, appID uuid.UUID) ([]OAuth2ProviderAppSecret, error) + GetOAuth2ProviderApps(ctx context.Context) ([]OAuth2ProviderApp, error) GetOAuthSigningKey(ctx context.Context) (string, error) GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error) GetOrganizationByName(ctx context.Context, name string) (Organization, error) @@ -275,6 +281,8 @@ type sqlcQuerier interface { // values for avatar, display name, and quota allowance (all zero values). // If the name conflicts, do nothing. InsertMissingGroups(ctx context.Context, arg InsertMissingGroupsParams) ([]Group, error) + InsertOAuth2ProviderApp(ctx context.Context, arg InsertOAuth2ProviderAppParams) (OAuth2ProviderApp, error) + InsertOAuth2ProviderAppSecret(ctx context.Context, arg InsertOAuth2ProviderAppSecretParams) (OAuth2ProviderAppSecret, error) InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error) InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error) InsertProvisionerJob(ctx context.Context, arg InsertProvisionerJobParams) (ProvisionerJob, error) @@ -318,6 +326,8 @@ type sqlcQuerier interface { UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error) UpdateInactiveUsersToDormant(ctx context.Context, arg UpdateInactiveUsersToDormantParams) ([]UpdateInactiveUsersToDormantRow, error) UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error) + UpdateOAuth2ProviderAppByID(ctx context.Context, arg UpdateOAuth2ProviderAppByIDParams) (OAuth2ProviderApp, error) + UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg UpdateOAuth2ProviderAppSecretByIDParams) (OAuth2ProviderAppSecret, error) UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg UpdateProvisionerDaemonLastSeenAtParams) error UpdateProvisionerJobByID(ctx context.Context, arg UpdateProvisionerJobByIDParams) error UpdateProvisionerJobWithCancelByID(ctx context.Context, arg UpdateProvisionerJobWithCancelByIDParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 213c17ddbe..2a1f3b316c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2610,6 +2610,282 @@ func (q *sqlQuerier) TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock i return pg_try_advisory_xact_lock, err } +const deleteOAuth2ProviderAppByID = `-- name: DeleteOAuth2ProviderAppByID :exec +DELETE FROM oauth2_provider_apps WHERE id = $1 +` + +func (q *sqlQuerier) DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteOAuth2ProviderAppByID, id) + return err +} + +const deleteOAuth2ProviderAppSecretByID = `-- name: DeleteOAuth2ProviderAppSecretByID :exec +DELETE FROM oauth2_provider_app_secrets WHERE id = $1 +` + +func (q *sqlQuerier) DeleteOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteOAuth2ProviderAppSecretByID, id) + return err +} + +const getOAuth2ProviderAppByID = `-- name: GetOAuth2ProviderAppByID :one +SELECT id, created_at, updated_at, name, icon, callback_url FROM oauth2_provider_apps WHERE id = $1 +` + +func (q *sqlQuerier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) { + row := q.db.QueryRowContext(ctx, getOAuth2ProviderAppByID, id) + var i OAuth2ProviderApp + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Name, + &i.Icon, + &i.CallbackURL, + ) + return i, err +} + +const getOAuth2ProviderAppSecretByID = `-- name: GetOAuth2ProviderAppSecretByID :one +SELECT id, created_at, last_used_at, hashed_secret, display_secret, app_id FROM oauth2_provider_app_secrets WHERE id = $1 +` + +func (q *sqlQuerier) GetOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppSecret, error) { + row := q.db.QueryRowContext(ctx, getOAuth2ProviderAppSecretByID, id) + var i OAuth2ProviderAppSecret + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.LastUsedAt, + &i.HashedSecret, + &i.DisplaySecret, + &i.AppID, + ) + return i, err +} + +const getOAuth2ProviderAppSecretsByAppID = `-- name: GetOAuth2ProviderAppSecretsByAppID :many +SELECT id, created_at, last_used_at, hashed_secret, display_secret, app_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) { + rows, err := q.db.QueryContext(ctx, getOAuth2ProviderAppSecretsByAppID, appID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []OAuth2ProviderAppSecret + for rows.Next() { + var i OAuth2ProviderAppSecret + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.LastUsedAt, + &i.HashedSecret, + &i.DisplaySecret, + &i.AppID, + ); 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 getOAuth2ProviderApps = `-- name: GetOAuth2ProviderApps :many +SELECT id, created_at, updated_at, name, icon, callback_url FROM oauth2_provider_apps ORDER BY (name, id) ASC +` + +func (q *sqlQuerier) GetOAuth2ProviderApps(ctx context.Context) ([]OAuth2ProviderApp, error) { + rows, err := q.db.QueryContext(ctx, getOAuth2ProviderApps) + if err != nil { + return nil, err + } + defer rows.Close() + var items []OAuth2ProviderApp + for rows.Next() { + var i OAuth2ProviderApp + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Name, + &i.Icon, + &i.CallbackURL, + ); 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 insertOAuth2ProviderApp = `-- name: InsertOAuth2ProviderApp :one +INSERT INTO oauth2_provider_apps ( + id, + created_at, + updated_at, + name, + icon, + callback_url +) VALUES( + $1, + $2, + $3, + $4, + $5, + $6 +) RETURNING id, created_at, updated_at, name, icon, callback_url +` + +type InsertOAuth2ProviderAppParams 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"` + CallbackURL string `db:"callback_url" json:"callback_url"` +} + +func (q *sqlQuerier) InsertOAuth2ProviderApp(ctx context.Context, arg InsertOAuth2ProviderAppParams) (OAuth2ProviderApp, error) { + row := q.db.QueryRowContext(ctx, insertOAuth2ProviderApp, + arg.ID, + arg.CreatedAt, + arg.UpdatedAt, + arg.Name, + arg.Icon, + arg.CallbackURL, + ) + var i OAuth2ProviderApp + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Name, + &i.Icon, + &i.CallbackURL, + ) + return i, err +} + +const insertOAuth2ProviderAppSecret = `-- name: InsertOAuth2ProviderAppSecret :one +INSERT INTO oauth2_provider_app_secrets ( + id, + created_at, + hashed_secret, + display_secret, + app_id +) VALUES( + $1, + $2, + $3, + $4, + $5 +) RETURNING id, created_at, last_used_at, hashed_secret, display_secret, app_id +` + +type InsertOAuth2ProviderAppSecretParams struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + 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"` +} + +func (q *sqlQuerier) InsertOAuth2ProviderAppSecret(ctx context.Context, arg InsertOAuth2ProviderAppSecretParams) (OAuth2ProviderAppSecret, error) { + row := q.db.QueryRowContext(ctx, insertOAuth2ProviderAppSecret, + arg.ID, + arg.CreatedAt, + arg.HashedSecret, + arg.DisplaySecret, + arg.AppID, + ) + var i OAuth2ProviderAppSecret + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.LastUsedAt, + &i.HashedSecret, + &i.DisplaySecret, + &i.AppID, + ) + return i, err +} + +const updateOAuth2ProviderAppByID = `-- name: UpdateOAuth2ProviderAppByID :one +UPDATE oauth2_provider_apps SET + updated_at = $2, + name = $3, + icon = $4, + callback_url = $5 +WHERE id = $1 RETURNING id, created_at, updated_at, name, icon, callback_url +` + +type UpdateOAuth2ProviderAppByIDParams struct { + ID uuid.UUID `db:"id" json:"id"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Name string `db:"name" json:"name"` + Icon string `db:"icon" json:"icon"` + CallbackURL string `db:"callback_url" json:"callback_url"` +} + +func (q *sqlQuerier) UpdateOAuth2ProviderAppByID(ctx context.Context, arg UpdateOAuth2ProviderAppByIDParams) (OAuth2ProviderApp, error) { + row := q.db.QueryRowContext(ctx, updateOAuth2ProviderAppByID, + arg.ID, + arg.UpdatedAt, + arg.Name, + arg.Icon, + arg.CallbackURL, + ) + var i OAuth2ProviderApp + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Name, + &i.Icon, + &i.CallbackURL, + ) + return i, err +} + +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 +` + +type UpdateOAuth2ProviderAppSecretByIDParams struct { + ID uuid.UUID `db:"id" json:"id"` + LastUsedAt sql.NullTime `db:"last_used_at" json:"last_used_at"` +} + +func (q *sqlQuerier) UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg UpdateOAuth2ProviderAppSecretByIDParams) (OAuth2ProviderAppSecret, error) { + row := q.db.QueryRowContext(ctx, updateOAuth2ProviderAppSecretByID, arg.ID, arg.LastUsedAt) + var i OAuth2ProviderAppSecret + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.LastUsedAt, + &i.HashedSecret, + &i.DisplaySecret, + &i.AppID, + ) + return i, err +} + const getOrganizationIDsByMemberIDs = `-- name: GetOrganizationIDsByMemberIDs :many SELECT user_id, array_agg(organization_id) :: uuid [ ] AS "organization_IDs" diff --git a/coderd/database/queries/oauth2.sql b/coderd/database/queries/oauth2.sql new file mode 100644 index 0000000000..cd9a150d0b --- /dev/null +++ b/coderd/database/queries/oauth2.sql @@ -0,0 +1,62 @@ +-- name: GetOAuth2ProviderApps :many +SELECT * FROM oauth2_provider_apps ORDER BY (name, id) ASC; + +-- name: GetOAuth2ProviderAppByID :one +SELECT * FROM oauth2_provider_apps WHERE id = $1; + +-- name: InsertOAuth2ProviderApp :one +INSERT INTO oauth2_provider_apps ( + id, + created_at, + updated_at, + name, + icon, + callback_url +) VALUES( + $1, + $2, + $3, + $4, + $5, + $6 +) RETURNING *; + +-- name: UpdateOAuth2ProviderAppByID :one +UPDATE oauth2_provider_apps SET + updated_at = $2, + name = $3, + icon = $4, + callback_url = $5 +WHERE id = $1 RETURNING *; + +-- name: DeleteOAuth2ProviderAppByID :exec +DELETE FROM oauth2_provider_apps WHERE id = $1; + +-- name: GetOAuth2ProviderAppSecretByID :one +SELECT * FROM oauth2_provider_app_secrets WHERE id = $1; + +-- name: GetOAuth2ProviderAppSecretsByAppID :many +SELECT * FROM oauth2_provider_app_secrets WHERE app_id = $1 ORDER BY (created_at, id) ASC; + +-- name: InsertOAuth2ProviderAppSecret :one +INSERT INTO oauth2_provider_app_secrets ( + id, + created_at, + hashed_secret, + display_secret, + app_id +) VALUES( + $1, + $2, + $3, + $4, + $5 +) RETURNING *; + +-- name: UpdateOAuth2ProviderAppSecretByID :one +UPDATE oauth2_provider_app_secrets SET + last_used_at = $2 +WHERE id = $1 RETURNING *; + +-- name: DeleteOAuth2ProviderAppSecretByID :exec +DELETE FROM oauth2_provider_app_secrets WHERE id = $1; diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index cb78d25873..074949fbaf 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -80,6 +80,9 @@ overrides: template_ids: TemplateIDs active_user_ids: ActiveUserIDs display_app_ssh_helper: DisplayAppSSHHelper + oauth2_provider_app: OAuth2ProviderApp + oauth2_provider_app_secret: OAuth2ProviderAppSecret + callback_url: CallbackURL sql: - schema: "./dump.sql" diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 9e54f47652..f397692f1d 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -21,6 +21,10 @@ const ( UniqueGroupsPkey UniqueConstraint = "groups_pkey" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_pkey PRIMARY KEY (id); UniqueLicensesJWTKey UniqueConstraint = "licenses_jwt_key" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt); UniqueLicensesPkey UniqueConstraint = "licenses_pkey" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_pkey PRIMARY KEY (id); + UniqueOauth2ProviderAppSecretsAppIDHashedSecretKey UniqueConstraint = "oauth2_provider_app_secrets_app_id_hashed_secret_key" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_app_id_hashed_secret_key UNIQUE (app_id, hashed_secret); + UniqueOauth2ProviderAppSecretsPkey UniqueConstraint = "oauth2_provider_app_secrets_pkey" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_pkey PRIMARY KEY (id); + UniqueOauth2ProviderAppsNameKey UniqueConstraint = "oauth2_provider_apps_name_key" // ALTER TABLE ONLY oauth2_provider_apps ADD CONSTRAINT oauth2_provider_apps_name_key UNIQUE (name); + UniqueOauth2ProviderAppsPkey UniqueConstraint = "oauth2_provider_apps_pkey" // ALTER TABLE ONLY oauth2_provider_apps ADD CONSTRAINT oauth2_provider_apps_pkey PRIMARY KEY (id); UniqueOrganizationMembersPkey UniqueConstraint = "organization_members_pkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_pkey PRIMARY KEY (organization_id, user_id); UniqueOrganizationsPkey UniqueConstraint = "organizations_pkey" // ALTER TABLE ONLY organizations ADD CONSTRAINT organizations_pkey PRIMARY KEY (id); UniqueParameterSchemasJobIDNameKey UniqueConstraint = "parameter_schemas_job_id_name_key" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_job_id_name_key UNIQUE (job_id, name); diff --git a/coderd/httpapi/httpapi.go b/coderd/httpapi/httpapi.go index cf89f6e509..e6c63451a0 100644 --- a/coderd/httpapi/httpapi.go +++ b/coderd/httpapi/httpapi.go @@ -46,7 +46,7 @@ func init() { valid := NameValid(str) return valid == nil } - for _, tag := range []string{"username", "template_name", "workspace_name"} { + for _, tag := range []string{"username", "template_name", "workspace_name", "oauth2_app_name"} { err := Validate.RegisterValidation(tag, nameValidator) if err != nil { panic(err) diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index e51a17a5a8..c300576aa8 100644 --- a/coderd/httpmw/oauth2.go +++ b/coderd/httpmw/oauth2.go @@ -8,6 +8,7 @@ import ( "golang.org/x/oauth2" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/cryptorand" @@ -178,3 +179,96 @@ func ExtractOAuth2(config OAuth2Config, client *http.Client, authURLOpts map[str }) } } + +type ( + oauth2ProviderAppParamContextKey struct{} + oauth2ProviderAppSecretParamContextKey struct{} +) + +// OAuth2ProviderApp returns the OAuth2 app from the ExtractOAuth2ProviderAppParam handler. +func OAuth2ProviderApp(r *http.Request) database.OAuth2ProviderApp { + app, ok := r.Context().Value(oauth2ProviderAppParamContextKey{}).(database.OAuth2ProviderApp) + if !ok { + panic("developer error: oauth2 app param middleware not provided") + } + return app +} + +// ExtractOAuth2ProviderApp grabs an OAuth2 app from the "app" URL parameter. This +// middleware requires the API key middleware higher in the call stack for +// authentication. +func ExtractOAuth2ProviderApp(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + appID, ok := ParseUUIDParam(rw, r, "app") + if !ok { + return + } + + app, err := db.GetOAuth2ProviderAppByID(ctx, appID) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching OAuth2 app.", + Detail: err.Error(), + }) + return + } + ctx = context.WithValue(ctx, oauth2ProviderAppParamContextKey{}, app) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} + +// OAuth2ProviderAppSecret returns the OAuth2 app secret from the +// ExtractOAuth2ProviderAppSecretParam handler. +func OAuth2ProviderAppSecret(r *http.Request) database.OAuth2ProviderAppSecret { + app, ok := r.Context().Value(oauth2ProviderAppSecretParamContextKey{}).(database.OAuth2ProviderAppSecret) + if !ok { + panic("developer error: oauth2 app secret param middleware not provided") + } + return app +} + +// ExtractOAuth2ProviderAppSecret grabs an OAuth2 app secret from the "app" and +// "secret" URL parameters. This middleware requires the ExtractOAuth2ProviderApp +// middleware higher in the stack +func ExtractOAuth2ProviderAppSecret(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + secretID, ok := ParseUUIDParam(rw, r, "secretID") + if !ok { + return + } + app := OAuth2ProviderApp(r) + secret, err := db.GetOAuth2ProviderAppSecretByID(ctx, secretID) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching OAuth2 app secret.", + Detail: err.Error(), + }) + return + } + // If the user can read the secret they can probably also read the app it + // belongs to and they can read this app as well, so it seems safe to give + // them a more helpful message than a 404 on mismatches. + if app.ID != secret.AppID { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "App ID does not match secret app ID.", + }) + return + } + ctx = context.WithValue(ctx, oauth2ProviderAppSecretParamContextKey{}, secret) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index fa2a7938b4..373c5b6b2d 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -198,6 +198,22 @@ var ( ResourceTemplateInsights = Object{ Type: "template_insights", } + + // ResourceOAuth2ProviderApp CRUD. + // create/delete = Make or delete an OAuth2 app. + // update = Update the properties of the OAuth2 app. + // read = Read OAuth2 apps. + ResourceOAuth2ProviderApp = Object{ + Type: "oauth2_app", + } + + // ResourceOAuth2ProviderAppSecrets CRUD. + // create/delete = Make or delete an OAuth2 app secret. + // update = Update last used date. + // read = Read OAuth2 app hashed or truncated secret. + ResourceOAuth2ProviderAppSecret = Object{ + Type: "oauth2_app_secrets", + } ) // ResourceUserObject is a helper function to create a user object for authz checks. diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 69e6f54a6f..aadf3fa1ed 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -11,6 +11,8 @@ func AllResources() []Object { ResourceFile, ResourceGroup, ResourceLicense, + ResourceOAuth2ProviderApp, + ResourceOAuth2ProviderAppSecret, ResourceOrgRoleAssignment, ResourceOrganization, ResourceOrganizationMember, diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 76711ca7bd..9117a5131d 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -50,6 +50,7 @@ const ( FeatureExternalTokenEncryption FeatureName = "external_token_encryption" FeatureWorkspaceBatchActions FeatureName = "workspace_batch_actions" FeatureAccessControl FeatureName = "access_control" + FeatureOAuth2Provider FeatureName = "oauth2_provider" ) // FeatureNames must be kept in-sync with the Feature enum above. @@ -69,6 +70,7 @@ var FeatureNames = []FeatureName{ FeatureExternalTokenEncryption, FeatureWorkspaceBatchActions, FeatureAccessControl, + FeatureOAuth2Provider, } // Humanize returns the feature name in a human-readable format. @@ -78,6 +80,8 @@ func (n FeatureName) Humanize() string { return "Template RBAC" case FeatureSCIM: return "SCIM" + case FeatureOAuth2Provider: + return "OAuth Provider" default: return strings.Title(strings.ReplaceAll(string(n), "_", " ")) } diff --git a/codersdk/oauth2.go b/codersdk/oauth2.go new file mode 100644 index 0000000000..acebc9c718 --- /dev/null +++ b/codersdk/oauth2.go @@ -0,0 +1,158 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/google/uuid" +) + +type OAuth2ProviderApp struct { + ID uuid.UUID `json:"id" format:"uuid"` + Name string `json:"name"` + CallbackURL string `json:"callback_url"` + Icon string `json:"icon"` +} + +// OAuth2ProviderApps returns the applications configured to authenticate using +// Coder as an OAuth2 provider. +func (c *Client) OAuth2ProviderApps(ctx context.Context) ([]OAuth2ProviderApp, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/oauth2-provider/apps", nil) + if err != nil { + return []OAuth2ProviderApp{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return []OAuth2ProviderApp{}, ReadBodyAsError(res) + } + var apps []OAuth2ProviderApp + return apps, json.NewDecoder(res.Body).Decode(&apps) +} + +// OAuth2ProviderApp returns an application configured to authenticate using +// Coder as an OAuth2 provider. +func (c *Client) OAuth2ProviderApp(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s", id), nil) + if err != nil { + return OAuth2ProviderApp{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return OAuth2ProviderApp{}, ReadBodyAsError(res) + } + var apps OAuth2ProviderApp + return apps, json.NewDecoder(res.Body).Decode(&apps) +} + +type PostOAuth2ProviderAppRequest struct { + Name string `json:"name" validate:"required,oauth2_app_name"` + CallbackURL string `json:"callback_url" validate:"required,http_url"` + Icon string `json:"icon" validate:"omitempty"` +} + +// PostOAuth2ProviderApp adds an application that can authenticate using Coder +// as an OAuth2 provider. +func (c *Client) PostOAuth2ProviderApp(ctx context.Context, app PostOAuth2ProviderAppRequest) (OAuth2ProviderApp, error) { + res, err := c.Request(ctx, http.MethodPost, "/api/v2/oauth2-provider/apps", app) + if err != nil { + return OAuth2ProviderApp{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusCreated { + return OAuth2ProviderApp{}, ReadBodyAsError(res) + } + var resp OAuth2ProviderApp + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +type PutOAuth2ProviderAppRequest struct { + Name string `json:"name" validate:"required,oauth2_app_name"` + CallbackURL string `json:"callback_url" validate:"required,http_url"` + Icon string `json:"icon" validate:"omitempty"` +} + +// PutOAuth2ProviderApp updates an application that can authenticate using Coder +// as an OAuth2 provider. +func (c *Client) PutOAuth2ProviderApp(ctx context.Context, id uuid.UUID, app PutOAuth2ProviderAppRequest) (OAuth2ProviderApp, error) { + res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s", id), app) + if err != nil { + return OAuth2ProviderApp{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return OAuth2ProviderApp{}, ReadBodyAsError(res) + } + var resp OAuth2ProviderApp + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// DeleteOAuth2ProviderApp deletes an application, also invalidating any tokens +// that were generated from it. +func (c *Client) DeleteOAuth2ProviderApp(ctx context.Context, id uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s", id), nil) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + +type OAuth2ProviderAppSecretFull struct { + ID uuid.UUID `json:"id" format:"uuid"` + ClientSecretFull string `json:"client_secret_full"` +} + +type OAuth2ProviderAppSecret struct { + ID uuid.UUID `json:"id" format:"uuid"` + LastUsedAt NullTime `json:"last_used_at"` + ClientSecretTruncated string `json:"client_secret_truncated"` +} + +// OAuth2ProviderAppSecrets returns the truncated secrets for an OAuth2 +// application. +func (c *Client) OAuth2ProviderAppSecrets(ctx context.Context, appID uuid.UUID) ([]OAuth2ProviderAppSecret, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/secrets", appID), nil) + if err != nil { + return []OAuth2ProviderAppSecret{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return []OAuth2ProviderAppSecret{}, ReadBodyAsError(res) + } + var resp []OAuth2ProviderAppSecret + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// PostOAuth2ProviderAppSecret creates a new secret for an OAuth2 application. +// This is the only time the full secret will be revealed. +func (c *Client) PostOAuth2ProviderAppSecret(ctx context.Context, appID uuid.UUID) (OAuth2ProviderAppSecretFull, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/secrets", appID), nil) + if err != nil { + return OAuth2ProviderAppSecretFull{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return OAuth2ProviderAppSecretFull{}, ReadBodyAsError(res) + } + var resp OAuth2ProviderAppSecretFull + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// DeleteOAuth2ProviderAppSecret deletes a secret from an OAuth2 application, +// also invalidating any tokens that generated from it. +func (c *Client) DeleteOAuth2ProviderAppSecret(ctx context.Context, appID uuid.UUID, secretID uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/secrets/%s", appID, secretID), nil) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index b25e62c39b..a567cb0ba8 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -430,6 +430,352 @@ curl -X DELETE http://coder-server:8080/api/v2/licenses/{id} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get OAuth2 applications. + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/oauth2-provider/apps \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /oauth2-provider/apps` + +### Example responses + +> 200 Response + +```json +[ + { + "callback_url": "string", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | --------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.OAuth2ProviderApp](schemas.md#codersdkoauth2providerapp) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| ---------------- | ------------ | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» callback_url` | string | false | | | +| `» icon` | string | false | | | +| `» id` | string(uuid) | false | | | +| `» name` | string | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Create OAuth2 application. + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/oauth2-provider/apps \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /oauth2-provider/apps` + +> Body parameter + +```json +{ + "callback_url": "string", + "icon": "string", + "name": "string" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ------ | ---- | ---------------------------------------------------------------------------------------- | -------- | --------------------------------- | +| `body` | body | [codersdk.PostOAuth2ProviderAppRequest](schemas.md#codersdkpostoauth2providerapprequest) | true | The OAuth2 application to create. | + +### Example responses + +> 200 Response + +```json +{ + "callback_url": "string", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OAuth2ProviderApp](schemas.md#codersdkoauth2providerapp) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get OAuth2 application. + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/oauth2-provider/apps/{app} \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /oauth2-provider/apps/{app}` + +### Parameters + +| Name | In | Type | Required | Description | +| ----- | ---- | ------ | -------- | ----------- | +| `app` | path | string | true | App ID | + +### Example responses + +> 200 Response + +```json +{ + "callback_url": "string", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OAuth2ProviderApp](schemas.md#codersdkoauth2providerapp) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Update OAuth2 application. + +### Code samples + +```shell +# Example request using curl +curl -X PUT http://coder-server:8080/api/v2/oauth2-provider/apps/{app} \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PUT /oauth2-provider/apps/{app}` + +> Body parameter + +```json +{ + "callback_url": "string", + "icon": "string", + "name": "string" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ------ | ---- | -------------------------------------------------------------------------------------- | -------- | ----------------------------- | +| `app` | path | string | true | App ID | +| `body` | body | [codersdk.PutOAuth2ProviderAppRequest](schemas.md#codersdkputoauth2providerapprequest) | true | Update an OAuth2 application. | + +### Example responses + +> 200 Response + +```json +{ + "callback_url": "string", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OAuth2ProviderApp](schemas.md#codersdkoauth2providerapp) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Delete OAuth2 application. + +### Code samples + +```shell +# Example request using curl +curl -X DELETE http://coder-server:8080/api/v2/oauth2-provider/apps/{app} \ + -H 'Coder-Session-Token: API_KEY' +``` + +`DELETE /oauth2-provider/apps/{app}` + +### Parameters + +| Name | In | Type | Required | Description | +| ----- | ---- | ------ | -------- | ----------- | +| `app` | path | string | true | App ID | + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | --------------------------------------------------------------- | ----------- | ------ | +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get OAuth2 application secrets. + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/oauth2-provider/apps/{app}/secrets \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /oauth2-provider/apps/{app}/secrets` + +### Parameters + +| Name | In | Type | Required | Description | +| ----- | ---- | ------ | -------- | ----------- | +| `app` | path | string | true | App ID | + +### Example responses + +> 200 Response + +```json +[ + { + "client_secret_truncated": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_used_at": "string" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.OAuth2ProviderAppSecret](schemas.md#codersdkoauth2providerappsecret) | + +

Response Schema

+ +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 | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Create OAuth2 application secret. + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/oauth2-provider/apps/{app}/secrets \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /oauth2-provider/apps/{app}/secrets` + +### Parameters + +| Name | In | Type | Required | Description | +| ----- | ---- | ------ | -------- | ----------- | +| `app` | path | string | true | App ID | + +### Example responses + +> 200 Response + +```json +[ + { + "client_secret_full": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.OAuth2ProviderAppSecretFull](schemas.md#codersdkoauth2providerappsecretfull) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| ---------------------- | ------------ | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» client_secret_full` | string | false | | | +| `» id` | string(uuid) | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Delete OAuth2 application secret. + +### Code samples + +```shell +# Example request using curl +curl -X DELETE http://coder-server:8080/api/v2/oauth2-provider/apps/{app}/secrets/{secretID} \ + -H 'Coder-Session-Token: API_KEY' +``` + +`DELETE /oauth2-provider/apps/{app}/secrets/{secretID}` + +### Parameters + +| Name | In | Type | Required | Description | +| ---------- | ---- | ------ | -------- | ----------- | +| `app` | path | string | true | App ID | +| `secretID` | path | string | true | Secret ID | + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | --------------------------------------------------------------- | ----------- | ------ | +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get groups by organization ### Code samples diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 1c8e97c322..c18c4e5d4d 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3539,6 +3539,60 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `client_secret` | string | false | | | | `enterprise_base_url` | string | false | | | +## codersdk.OAuth2ProviderApp + +```json +{ + "callback_url": "string", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------ | -------- | ------------ | ----------- | +| `callback_url` | string | false | | | +| `icon` | string | false | | | +| `id` | string | false | | | +| `name` | string | false | | | + +## codersdk.OAuth2ProviderAppSecret + +```json +{ + "client_secret_truncated": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_used_at": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------------------- | ------ | -------- | ------------ | ----------- | +| `client_secret_truncated` | string | false | | | +| `id` | string | false | | | +| `last_used_at` | string | false | | | + +## codersdk.OAuth2ProviderAppSecretFull + +```json +{ + "client_secret_full": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------------- | ------ | -------- | ------------ | ----------- | +| `client_secret_full` | string | false | | | +| `id` | string | false | | | + ## codersdk.OAuthConversionResponse ```json @@ -3756,6 +3810,24 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `name` | string | true | | | | `regenerate_token` | boolean | false | | | +## codersdk.PostOAuth2ProviderAppRequest + +```json +{ + "callback_url": "string", + "icon": "string", + "name": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------ | -------- | ------------ | ----------- | +| `callback_url` | string | true | | | +| `icon` | string | false | | | +| `name` | string | true | | | + ## codersdk.PprofConfig ```json @@ -4035,6 +4107,24 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | ---------- | ------ | -------- | ------------ | ----------- | | `deadline` | string | true | | | +## codersdk.PutOAuth2ProviderAppRequest + +```json +{ + "callback_url": "string", + "icon": "string", + "name": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------ | -------- | ------------ | ----------- | +| `callback_url` | string | true | | | +| `icon` | string | false | | | +| `name` | string | true | | | + ## codersdk.RBACResource ```json diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 2d409d9991..bd6997506a 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -311,6 +311,33 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Get("/", api.userQuietHoursSchedule) r.Put("/", api.putUserQuietHoursSchedule) }) + r.Route("/oauth2-provider", func(r chi.Router) { + r.Use( + apiKeyMiddleware, + api.oAuth2ProviderMiddleware, + ) + r.Route("/apps", func(r chi.Router) { + r.Get("/", api.oAuth2ProviderApps) + r.Post("/", api.postOAuth2ProviderApp) + + r.Route("/{app}", func(r chi.Router) { + r.Use(httpmw.ExtractOAuth2ProviderApp(options.Database)) + r.Get("/", api.oAuth2ProviderApp) + r.Put("/", api.putOAuth2ProviderApp) + r.Delete("/", api.deleteOAuth2ProviderApp) + + r.Route("/secrets", func(r chi.Router) { + r.Get("/", api.oAuth2ProviderAppSecrets) + r.Post("/", api.postOAuth2ProviderAppSecret) + + r.Route("/{secretID}", func(r chi.Router) { + r.Use(httpmw.ExtractOAuth2ProviderAppSecret(options.Database)) + r.Delete("/", api.deleteOAuth2ProviderAppSecret) + }) + }) + }) + }) + }) }) if len(options.SCIMAPIKey) != 0 { @@ -487,6 +514,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { codersdk.FeatureBrowserOnly: api.BrowserOnly, codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0, codersdk.FeatureMultipleExternalAuth: len(api.ExternalAuthConfigs) > 1, + codersdk.FeatureOAuth2Provider: true, codersdk.FeatureTemplateRBAC: api.RBAC, codersdk.FeatureExternalTokenEncryption: len(api.ExternalTokenEncryption) > 0, codersdk.FeatureExternalProvisionerDaemons: true, diff --git a/enterprise/coderd/oauth2.go b/enterprise/coderd/oauth2.go new file mode 100644 index 0000000000..3d4c822131 --- /dev/null +++ b/enterprise/coderd/oauth2.go @@ -0,0 +1,255 @@ +package coderd + +import ( + "crypto/sha256" + "net/http" + + "github.com/google/uuid" + + "github.com/coder/coder/v2/buildinfo" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "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/codersdk" + "github.com/coder/coder/v2/cryptorand" +) + +func (api *API) oAuth2ProviderMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if !buildinfo.IsDev() { + httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{ + Message: "OAuth2 provider is under development.", + }) + return + } + + api.entitlementsMu.RLock() + entitled := api.entitlements.Features[codersdk.FeatureOAuth2Provider].Entitlement != codersdk.EntitlementNotEntitled + api.entitlementsMu.RUnlock() + + if !entitled { + httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{ + Message: "OAuth2 provider is an Enterprise feature. Contact sales!", + }) + return + } + + next.ServeHTTP(rw, r) + }) +} + +// @Summary Get OAuth2 applications. +// @ID get-oauth2-applications +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Success 200 {array} codersdk.OAuth2ProviderApp +// @Router /oauth2-provider/apps [get] +func (api *API) oAuth2ProviderApps(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + dbApps, err := api.Database.GetOAuth2ProviderApps(ctx) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApps(dbApps)) +} + +// @Summary Get OAuth2 application. +// @ID get-oauth2-application +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param app path string true "App ID" +// @Success 200 {object} codersdk.OAuth2ProviderApp +// @Router /oauth2-provider/apps/{app} [get] +func (*API) oAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + app := httpmw.OAuth2ProviderApp(r) + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApp(app)) +} + +// @Summary Create OAuth2 application. +// @ID create-oauth2-application +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Enterprise +// @Param request body codersdk.PostOAuth2ProviderAppRequest true "The OAuth2 application to create." +// @Success 200 {object} codersdk.OAuth2ProviderApp +// @Router /oauth2-provider/apps [post] +func (api *API) postOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var req codersdk.PostOAuth2ProviderAppRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + app, err := api.Database.InsertOAuth2ProviderApp(ctx, database.InsertOAuth2ProviderAppParams{ + ID: uuid.New(), + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + Name: req.Name, + Icon: req.Icon, + CallbackURL: req.CallbackURL, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error creating OAuth2 application.", + Detail: err.Error(), + }) + return + } + httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.OAuth2ProviderApp(app)) +} + +// @Summary Update OAuth2 application. +// @ID update-oauth2-application +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Enterprise +// @Param app path string true "App ID" +// @Param request body codersdk.PutOAuth2ProviderAppRequest true "Update an OAuth2 application." +// @Success 200 {object} codersdk.OAuth2ProviderApp +// @Router /oauth2-provider/apps/{app} [put] +func (api *API) putOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + app := httpmw.OAuth2ProviderApp(r) + var req codersdk.PutOAuth2ProviderAppRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + app, err := api.Database.UpdateOAuth2ProviderAppByID(ctx, database.UpdateOAuth2ProviderAppByIDParams{ + ID: app.ID, + UpdatedAt: dbtime.Now(), + Name: req.Name, + Icon: req.Icon, + CallbackURL: req.CallbackURL, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error creating OAuth2 application.", + Detail: err.Error(), + }) + return + } + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApp(app)) +} + +// @Summary Delete OAuth2 application. +// @ID delete-oauth2-application +// @Security CoderSessionToken +// @Tags Enterprise +// @Param app path string true "App ID" +// @Success 204 +// @Router /oauth2-provider/apps/{app} [delete] +func (api *API) deleteOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + app := httpmw.OAuth2ProviderApp(r) + err := api.Database.DeleteOAuth2ProviderAppByID(ctx, app.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error deleting OAuth2 application.", + Detail: err.Error(), + }) + return + } + httpapi.Write(ctx, rw, http.StatusNoContent, nil) +} + +// @Summary Get OAuth2 application secrets. +// @ID get-oauth2-application-secrets +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param app path string true "App ID" +// @Success 200 {array} codersdk.OAuth2ProviderAppSecret +// @Router /oauth2-provider/apps/{app}/secrets [get] +func (api *API) oAuth2ProviderAppSecrets(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + app := httpmw.OAuth2ProviderApp(r) + dbSecrets, err := api.Database.GetOAuth2ProviderAppSecretsByAppID(ctx, app.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error getting OAuth2 client secrets.", + Detail: err.Error(), + }) + return + } + secrets := []codersdk.OAuth2ProviderAppSecret{} + for _, secret := range dbSecrets { + secrets = append(secrets, codersdk.OAuth2ProviderAppSecret{ + ID: secret.ID, + LastUsedAt: codersdk.NullTime{NullTime: secret.LastUsedAt}, + ClientSecretTruncated: secret.DisplaySecret, + }) + } + httpapi.Write(ctx, rw, http.StatusOK, secrets) +} + +// @Summary Create OAuth2 application secret. +// @ID create-oauth2-application-secret +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param app path string true "App ID" +// @Success 200 {array} codersdk.OAuth2ProviderAppSecretFull +// @Router /oauth2-provider/apps/{app}/secrets [post] +func (api *API) postOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + app := httpmw.OAuth2ProviderApp(r) + // 40 characters matches the length of GitHub's client secrets. + rawSecret, err := cryptorand.String(40) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to generate OAuth2 client secret.", + }) + return + } + hashed := sha256.Sum256([]byte(rawSecret)) + secret, err := api.Database.InsertOAuth2ProviderAppSecret(ctx, database.InsertOAuth2ProviderAppSecretParams{ + ID: uuid.New(), + CreatedAt: dbtime.Now(), + HashedSecret: hashed[:], + // 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: rawSecret[len(rawSecret)-6:], + AppID: app.ID, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error creating OAuth2 client secret.", + Detail: err.Error(), + }) + return + } + httpapi.Write(ctx, rw, http.StatusOK, codersdk.OAuth2ProviderAppSecretFull{ + ID: secret.ID, + ClientSecretFull: rawSecret, + }) +} + +// @Summary Delete OAuth2 application secret. +// @ID delete-oauth2-application-secret +// @Security CoderSessionToken +// @Tags Enterprise +// @Param app path string true "App ID" +// @Param secretID path string true "Secret ID" +// @Success 204 +// @Router /oauth2-provider/apps/{app}/secrets/{secretID} [delete] +func (api *API) deleteOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + secret := httpmw.OAuth2ProviderAppSecret(r) + err := api.Database.DeleteOAuth2ProviderAppSecretByID(ctx, secret.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error deleting OAuth2 client secret.", + Detail: err.Error(), + }) + return + } + httpapi.Write(ctx, rw, http.StatusNoContent, nil) +} diff --git a/enterprise/coderd/oauth2_test.go b/enterprise/coderd/oauth2_test.go new file mode 100644 index 0000000000..de28095968 --- /dev/null +++ b/enterprise/coderd/oauth2_test.go @@ -0,0 +1,367 @@ +package coderd_test + +import ( + "strconv" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" +) + +func TestOAuthApps(t *testing.T) { + t.Parallel() + + t.Run("Validation", func(t *testing.T) { + t.Parallel() + + client, _ := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureOAuth2Provider: 1, + }, + }}) + + ctx := testutil.Context(t, testutil.WaitLong) + + tests := []struct { + name string + req codersdk.PostOAuth2ProviderAppRequest + }{ + { + name: "NameMissing", + req: codersdk.PostOAuth2ProviderAppRequest{ + CallbackURL: "http://localhost:3000", + }, + }, + { + name: "NameSpaces", + req: codersdk.PostOAuth2ProviderAppRequest{ + Name: "foo bar", + CallbackURL: "http://localhost:3000", + }, + }, + { + name: "NameTooLong", + req: codersdk.PostOAuth2ProviderAppRequest{ + Name: "too loooooooooooooooooooooooooong", + CallbackURL: "http://localhost:3000", + }, + }, + { + name: "NameTaken", + req: codersdk.PostOAuth2ProviderAppRequest{ + Name: "taken", + CallbackURL: "http://localhost:3000", + }, + }, + { + name: "URLMissing", + req: codersdk.PostOAuth2ProviderAppRequest{ + Name: "foo", + }, + }, + { + name: "URLLocalhostNoScheme", + req: codersdk.PostOAuth2ProviderAppRequest{ + Name: "foo", + CallbackURL: "localhost:3000", + }, + }, + { + name: "URLNoScheme", + req: codersdk.PostOAuth2ProviderAppRequest{ + Name: "foo", + CallbackURL: "coder.com", + }, + }, + { + name: "URLNoColon", + req: codersdk.PostOAuth2ProviderAppRequest{ + Name: "foo", + CallbackURL: "http//coder", + }, + }, + { + name: "URLJustBar", + req: codersdk.PostOAuth2ProviderAppRequest{ + Name: "foo", + CallbackURL: "bar", + }, + }, + { + name: "URLPathOnly", + req: codersdk.PostOAuth2ProviderAppRequest{ + Name: "foo", + CallbackURL: "/bar/baz/qux", + }, + }, + { + name: "URLJustHttp", + req: codersdk.PostOAuth2ProviderAppRequest{ + Name: "foo", + CallbackURL: "http", + }, + }, + { + name: "URLNoHost", + req: codersdk.PostOAuth2ProviderAppRequest{ + Name: "foo", + CallbackURL: "http://", + }, + }, + { + name: "URLSpaces", + req: codersdk.PostOAuth2ProviderAppRequest{ + Name: "foo", + CallbackURL: "bar baz qux", + }, + }, + } + + // Generate an application for testing name conflicts. + req := codersdk.PostOAuth2ProviderAppRequest{ + Name: "taken", + CallbackURL: "http://coder.com", + } + //nolint:gocritic // OAauth2 app management requires owner permission. + _, err := client.PostOAuth2ProviderApp(ctx, req) + require.NoError(t, err) + + // Generate an application for testing PUTs. + req = codersdk.PostOAuth2ProviderAppRequest{ + Name: "quark", + CallbackURL: "http://coder.com", + } + //nolint:gocritic // OAauth2 app management requires owner permission. + existingApp, err := client.PostOAuth2ProviderApp(ctx, req) + require.NoError(t, err) + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + //nolint:gocritic // OAauth2 app management requires owner permission. + _, err := client.PostOAuth2ProviderApp(ctx, test.req) + require.Error(t, err) + + //nolint:gocritic // OAauth2 app management requires owner permission. + _, err = client.PutOAuth2ProviderApp(ctx, existingApp.ID, codersdk.PutOAuth2ProviderAppRequest{ + Name: test.req.Name, + CallbackURL: test.req.CallbackURL, + }) + require.Error(t, err) + }) + } + }) + + t.Run("DeleteNonExisting", func(t *testing.T) { + t.Parallel() + + client, _ := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureOAuth2Provider: 1, + }, + }}) + + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // OAauth2 app management requires owner permission. + _, err := client.OAuth2ProviderApp(ctx, uuid.New()) + require.Error(t, err) + }) + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client, _ := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureOAuth2Provider: 1, + }, + }}) + + ctx := testutil.Context(t, testutil.WaitLong) + + // No apps yet. + //nolint:gocritic // OAauth2 app management requires owner permission. + apps, err := client.OAuth2ProviderApps(ctx) + require.NoError(t, err) + require.Len(t, apps, 0) + + // Should be able to add apps. + expected := []codersdk.OAuth2ProviderApp{} + for i := 0; i < 5; i++ { + postReq := codersdk.PostOAuth2ProviderAppRequest{ + Name: "foo-" + strconv.Itoa(i), + CallbackURL: "http://" + strconv.Itoa(i) + ".localhost:3000", + } + //nolint:gocritic // OAauth2 app management requires owner permission. + app, err := client.PostOAuth2ProviderApp(ctx, postReq) + require.NoError(t, err) + require.Equal(t, postReq.Name, app.Name) + require.Equal(t, postReq.CallbackURL, app.CallbackURL) + expected = append(expected, app) + } + + // Should get all the apps now. + //nolint:gocritic // OAauth2 app management requires owner permission. + apps, err = client.OAuth2ProviderApps(ctx) + require.NoError(t, err) + require.Len(t, apps, 5) + require.Equal(t, expected, apps) + + // Should be able to keep the same name when updating. + req := codersdk.PutOAuth2ProviderAppRequest{ + Name: expected[0].Name, + CallbackURL: "http://coder.com", + Icon: "test", + } + //nolint:gocritic // OAauth2 app management requires owner permission. + newApp, err := client.PutOAuth2ProviderApp(ctx, expected[0].ID, req) + require.NoError(t, err) + require.Equal(t, req.Name, newApp.Name) + require.Equal(t, req.CallbackURL, newApp.CallbackURL) + require.Equal(t, req.Icon, newApp.Icon) + require.Equal(t, expected[0].ID, newApp.ID) + + // Should be able to update name. + req = codersdk.PutOAuth2ProviderAppRequest{ + Name: "new-foo", + CallbackURL: "http://coder.com", + Icon: "test", + } + //nolint:gocritic // OAauth2 app management requires owner permission. + newApp, err = client.PutOAuth2ProviderApp(ctx, expected[0].ID, req) + require.NoError(t, err) + require.Equal(t, req.Name, newApp.Name) + require.Equal(t, req.CallbackURL, newApp.CallbackURL) + require.Equal(t, req.Icon, newApp.Icon) + require.Equal(t, expected[0].ID, newApp.ID) + + // Should be able to get a single app. + //nolint:gocritic // OAauth2 app management requires owner permission. + got, err := client.OAuth2ProviderApp(ctx, expected[0].ID) + require.NoError(t, err) + require.Equal(t, newApp, got) + + // Should be able to delete an app. + //nolint:gocritic // OAauth2 app management requires owner permission. + err = client.DeleteOAuth2ProviderApp(ctx, expected[0].ID) + require.NoError(t, err) + + // Should show the new count. + //nolint:gocritic // OAauth2 app management requires owner permission. + newApps, err := client.OAuth2ProviderApps(ctx) + require.NoError(t, err) + require.Len(t, newApps, 4) + require.Equal(t, expected[1:], newApps) + }) +} + +func TestOAuthAppSecrets(t *testing.T) { + t.Parallel() + + client, _ := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureOAuth2Provider: 1, + }, + }}) + + ctx := testutil.Context(t, testutil.WaitLong) + + // Make some apps. + //nolint:gocritic // OAauth2 app management requires owner permission. + app1, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{ + Name: "razzle-dazzle", + CallbackURL: "http://localhost", + }) + require.NoError(t, err) + + //nolint:gocritic // OAauth2 app management requires owner permission. + app2, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{ + Name: "razzle-dazzle-the-sequel", + CallbackURL: "http://localhost", + }) + require.NoError(t, err) + + t.Run("DeleteNonExisting", func(t *testing.T) { + t.Parallel() + + // Should not be able to create secrets for a non-existent app. + //nolint:gocritic // OAauth2 app management requires owner permission. + _, err = client.OAuth2ProviderAppSecrets(ctx, uuid.New()) + require.Error(t, err) + + // Should not be able to delete non-existing secrets when there is no app. + //nolint:gocritic // OAauth2 app management requires owner permission. + err = client.DeleteOAuth2ProviderAppSecret(ctx, uuid.New(), uuid.New()) + require.Error(t, err) + + // Should not be able to delete non-existing secrets when the app exists. + //nolint:gocritic // OAauth2 app management requires owner permission. + err = client.DeleteOAuth2ProviderAppSecret(ctx, app1.ID, uuid.New()) + require.Error(t, err) + + // Should not be able to delete an existing secret with the wrong app ID. + //nolint:gocritic // OAauth2 app management requires owner permission. + secret, err := client.PostOAuth2ProviderAppSecret(ctx, app2.ID) + require.NoError(t, err) + + //nolint:gocritic // OAauth2 app management requires owner permission. + err = client.DeleteOAuth2ProviderAppSecret(ctx, app1.ID, secret.ID) + require.Error(t, err) + }) + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + // No secrets yet. + //nolint:gocritic // OAauth2 app management requires owner permission. + secrets, err := client.OAuth2ProviderAppSecrets(ctx, app1.ID) + require.NoError(t, err) + require.Len(t, secrets, 0) + + // Should be able to create secrets. + for i := 0; i < 5; i++ { + //nolint:gocritic // OAauth2 app management requires owner permission. + secret, err := client.PostOAuth2ProviderAppSecret(ctx, app1.ID) + require.NoError(t, err) + require.NotEmpty(t, secret.ClientSecretFull) + require.True(t, len(secret.ClientSecretFull) > 6) + + //nolint:gocritic // OAauth2 app management requires owner permission. + _, err = client.PostOAuth2ProviderAppSecret(ctx, app2.ID) + require.NoError(t, err) + } + + // Should get secrets now, but only for the one app. + //nolint:gocritic // OAauth2 app management requires owner permission. + secrets, err = client.OAuth2ProviderAppSecrets(ctx, app1.ID) + require.NoError(t, err) + require.Len(t, secrets, 5) + for _, secret := range secrets { + require.Len(t, secret.ClientSecretTruncated, 6) + } + + // Should be able to delete a secret. + //nolint:gocritic // OAauth2 app management requires owner permission. + err = client.DeleteOAuth2ProviderAppSecret(ctx, app1.ID, secrets[0].ID) + require.NoError(t, err) + secrets, err = client.OAuth2ProviderAppSecrets(ctx, app1.ID) + require.NoError(t, err) + require.Len(t, secrets, 4) + + // No secrets once the app is deleted. + //nolint:gocritic // OAauth2 app management requires owner permission. + err = client.DeleteOAuth2ProviderApp(ctx, app1.ID) + require.NoError(t, err) + + //nolint:gocritic // OAauth2 app management requires owner permission. + _, err = client.OAuth2ProviderAppSecrets(ctx, app1.ID) + require.Error(t, err) + }) +} diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index dcdd25800f..6e01d6ad90 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -120,6 +120,24 @@ const ExternalAuthSettingsPage = lazy( "./pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPage" ), ); +const OAuth2AppsSettingsPage = lazy( + () => + import( + "./pages/DeploySettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage" + ), +); +const EditOAuth2AppPage = lazy( + () => + import( + "./pages/DeploySettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPage" + ), +); +const CreateOAuth2AppPage = lazy( + () => + import( + "./pages/DeploySettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPage" + ), +); const NetworkSettingsPage = lazy( () => import( @@ -315,6 +333,16 @@ export const AppRouter: FC = () => { path="external-auth" element={} /> + + + } /> + + } /> + } /> + } /> + + + } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 153b238e52..3b11fd6929 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -960,6 +960,61 @@ export const unlinkExternalAuthProvider = async ( return resp.data; }; +export const getOAuth2ProviderApps = async (): Promise< + TypesGen.OAuth2ProviderApp[] +> => { + const resp = await axios.get(`/api/v2/oauth2-provider/apps`); + return resp.data; +}; + +export const getOAuth2ProviderApp = async ( + id: string, +): Promise => { + const resp = await axios.get(`/api/v2/oauth2-provider/apps/${id}`); + return resp.data; +}; + +export const postOAuth2ProviderApp = async ( + data: TypesGen.PostOAuth2ProviderAppRequest, +): Promise => { + const response = await axios.post(`/api/v2/oauth2-provider/apps`, data); + return response.data; +}; + +export const putOAuth2ProviderApp = async ( + id: string, + data: TypesGen.PutOAuth2ProviderAppRequest, +): Promise => { + const response = await axios.put(`/api/v2/oauth2-provider/apps/${id}`, data); + return response.data; +}; +export const deleteOAuth2ProviderApp = async (id: string): Promise => { + await axios.delete(`/api/v2/oauth2-provider/apps/${id}`); +}; + +export const getOAuth2ProviderAppSecrets = async ( + id: string, +): Promise => { + const resp = await axios.get(`/api/v2/oauth2-provider/apps/${id}/secrets`); + return resp.data; +}; + +export const postOAuth2ProviderAppSecret = async ( + id: string, +): Promise => { + const resp = await axios.post(`/api/v2/oauth2-provider/apps/${id}/secrets`); + return resp.data; +}; + +export const deleteOAuth2ProviderAppSecret = async ( + appId: string, + secretId: string, +): Promise => { + await axios.delete( + `/api/v2/oauth2-provider/apps/${appId}/secrets/${secretId}`, + ); +}; + export const getAuditLogs = async ( options: TypesGen.AuditLogsRequest, ): Promise => { diff --git a/site/src/api/queries/oauth2.ts b/site/src/api/queries/oauth2.ts new file mode 100644 index 0000000000..849cbc5241 --- /dev/null +++ b/site/src/api/queries/oauth2.ts @@ -0,0 +1,93 @@ +import type { QueryClient } from "react-query"; +import * as API from "api/api"; +import type * as TypesGen from "api/typesGenerated"; + +const appsKey = ["oauth2-provider", "apps"]; +const appKey = (id: string) => appsKey.concat(id); +const appSecretsKey = (id: string) => appKey(id).concat("secrets"); + +export const getApps = () => { + return { + queryKey: appsKey, + queryFn: () => API.getOAuth2ProviderApps(), + }; +}; + +export const getApp = (id: string) => { + return { + queryKey: appKey(id), + queryFn: () => API.getOAuth2ProviderApp(id), + }; +}; + +export const postApp = (queryClient: QueryClient) => { + return { + mutationFn: API.postOAuth2ProviderApp, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: appsKey, + }); + }, + }; +}; + +export const putApp = (queryClient: QueryClient) => { + return { + mutationFn: ({ + id, + req, + }: { + id: string; + req: TypesGen.PutOAuth2ProviderAppRequest; + }) => API.putOAuth2ProviderApp(id, req), + onSuccess: async (app: TypesGen.OAuth2ProviderApp) => { + await queryClient.invalidateQueries({ + queryKey: appKey(app.id), + }); + }, + }; +}; + +export const deleteApp = (queryClient: QueryClient) => { + return { + mutationFn: API.deleteOAuth2ProviderApp, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: appsKey, + }); + }, + }; +}; + +export const getAppSecrets = (id: string) => { + return { + queryKey: appSecretsKey(id), + queryFn: () => API.getOAuth2ProviderAppSecrets(id), + }; +}; + +export const postAppSecret = (queryClient: QueryClient) => { + return { + mutationFn: API.postOAuth2ProviderAppSecret, + onSuccess: async ( + _: TypesGen.OAuth2ProviderAppSecretFull, + appId: string, + ) => { + await queryClient.invalidateQueries({ + queryKey: appSecretsKey(appId), + }); + }, + }; +}; + +export const deleteAppSecret = (queryClient: QueryClient) => { + return { + mutationFn: ({ appId, secretId }: { appId: string; secretId: string }) => + API.deleteOAuth2ProviderAppSecret(appId, secretId), + onSuccess: async (_: void, { appId }: { appId: string }) => { + await queryClient.invalidateQueries({ + queryKey: appSecretsKey(appId), + }); + }, + }; +}; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 1c9f64be2f..98def777d9 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -660,6 +660,27 @@ export interface OAuth2GithubConfig { readonly enterprise_base_url: string; } +// From codersdk/oauth2.go +export interface OAuth2ProviderApp { + readonly id: string; + readonly name: string; + readonly callback_url: string; + readonly icon: string; +} + +// From codersdk/oauth2.go +export interface OAuth2ProviderAppSecret { + readonly id: string; + readonly last_used_at?: string; + readonly client_secret_truncated: string; +} + +// From codersdk/oauth2.go +export interface OAuth2ProviderAppSecretFull { + readonly id: string; + readonly client_secret_full: string; +} + // From codersdk/users.go export interface OAuthConversionResponse { readonly state_string: string; @@ -750,6 +771,13 @@ export interface PatchWorkspaceProxy { readonly regenerate_token: boolean; } +// From codersdk/oauth2.go +export interface PostOAuth2ProviderAppRequest { + readonly name: string; + readonly callback_url: string; + readonly icon: string; +} + // From codersdk/deployment.go export interface PprofConfig { readonly enable: boolean; @@ -823,6 +851,13 @@ export interface PutExtendWorkspaceRequest { readonly deadline: string; } +// From codersdk/oauth2.go +export interface PutOAuth2ProviderAppRequest { + readonly name: string; + readonly callback_url: string; + readonly icon: string; +} + // From codersdk/deployment.go export interface RateLimitConfig { readonly disable_all: boolean; @@ -1799,6 +1834,7 @@ export type FeatureName = | "external_token_encryption" | "high_availability" | "multiple_external_auth" + | "oauth2_provider" | "scim" | "template_rbac" | "user_limit" @@ -1815,6 +1851,7 @@ export const FeatureNames: FeatureName[] = [ "external_token_encryption", "high_availability", "multiple_external_auth", + "oauth2_provider", "scim", "template_rbac", "user_limit", diff --git a/site/src/components/DeploySettingsLayout/Sidebar.tsx b/site/src/components/DeploySettingsLayout/Sidebar.tsx index db024ee8be..d51cba7bb6 100644 --- a/site/src/components/DeploySettingsLayout/Sidebar.tsx +++ b/site/src/components/DeploySettingsLayout/Sidebar.tsx @@ -7,6 +7,7 @@ import Globe from "@mui/icons-material/PublicOutlined"; import HubOutlinedIcon from "@mui/icons-material/HubOutlined"; import VpnKeyOutlined from "@mui/icons-material/VpnKeyOutlined"; import MonitorHeartOutlined from "@mui/icons-material/MonitorHeartOutlined"; +// import Token from "@mui/icons-material/Token"; import { type FC } from "react"; import { useDashboard } from "components/Dashboard/DashboardProvider"; import { GitIcon } from "components/Icons/GitIcon"; @@ -35,6 +36,10 @@ export const Sidebar: FC = () => { External Authentication + {/* Not exposing this yet since token exchange is not finished yet. + + OAuth2 Applications + */} Network diff --git a/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPage.tsx b/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPage.tsx new file mode 100644 index 0000000000..c76ce96f34 --- /dev/null +++ b/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPage.tsx @@ -0,0 +1,40 @@ +import { useMutation, useQueryClient } from "react-query"; +import { postApp } from "api/queries/oauth2"; +import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; +import { FC } from "react"; +import { useNavigate } from "react-router-dom"; +import { CreateOAuth2AppPageView } from "./CreateOAuth2AppPageView"; +import { pageTitle } from "utils/page"; +import { Helmet } from "react-helmet-async"; + +const CreateOAuth2AppPage: FC = () => { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const postAppMutation = useMutation(postApp(queryClient)); + + return ( + <> + + {pageTitle("New OAuth2 Application")} + + + { + try { + const app = await postAppMutation.mutateAsync(req); + displaySuccess( + `Successfully added the OAuth2 application "${app.name}".`, + ); + navigate(`/deployment/oauth2-provider/apps/${app.id}?created=true`); + } catch (ignore) { + displayError("Failed to create OAuth2 application"); + } + }} + /> + + ); +}; + +export default CreateOAuth2AppPage; diff --git a/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPageView.stories.tsx b/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPageView.stories.tsx new file mode 100644 index 0000000000..455de6dc43 --- /dev/null +++ b/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPageView.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { mockApiError } from "testHelpers/entities"; +import { CreateOAuth2AppPageView } from "./CreateOAuth2AppPageView"; + +const meta: Meta = { + title: "pages/DeploySettingsPage/CreateOAuth2AppPageView", + component: CreateOAuth2AppPageView, +}; +export default meta; + +type Story = StoryObj; + +export const Updating: Story = { + args: { + isUpdating: true, + }, +}; + +export const Error: Story = { + args: { + error: mockApiError({ + message: "Validation failed", + validations: [ + { + field: "name", + detail: "name error", + }, + { + field: "callback_url", + detail: "url error", + }, + { + field: "icon", + detail: "icon error", + }, + ], + }), + }, +}; + +export const Default: Story = { + args: { + // Nothing. + }, +}; diff --git a/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPageView.tsx b/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPageView.tsx new file mode 100644 index 0000000000..d29466bd41 --- /dev/null +++ b/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPageView.tsx @@ -0,0 +1,52 @@ +import Button from "@mui/material/Button"; +import KeyboardArrowLeft from "@mui/icons-material/KeyboardArrowLeft"; +import { type FC } from "react"; +import { Link } from "react-router-dom"; +import type * as TypesGen from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Header } from "components/DeploySettingsLayout/Header"; +import { Stack } from "components/Stack/Stack"; +import { OAuth2AppForm } from "./OAuth2AppForm"; + +type CreateOAuth2AppProps = { + isUpdating: boolean; + createApp: (req: TypesGen.PostOAuth2ProviderAppRequest) => void; + error?: unknown; +}; + +export const CreateOAuth2AppPageView: FC = ({ + isUpdating, + createApp, + error, +}) => { + return ( + <> + +
+ + + + + {error ? : undefined} + + + + ); +}; diff --git a/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPage.tsx b/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPage.tsx new file mode 100644 index 0000000000..16e07632ef --- /dev/null +++ b/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPage.tsx @@ -0,0 +1,105 @@ +import { useMutation, useQuery, useQueryClient } from "react-query"; +import type * as TypesGen from "api/typesGenerated"; +import * as oauth2 from "api/queries/oauth2"; +import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; +import { FC, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { EditOAuth2AppPageView } from "./EditOAuth2AppPageView"; +import { pageTitle } from "utils/page"; +import { Helmet } from "react-helmet-async"; + +const EditOAuth2AppPage: FC = () => { + const navigate = useNavigate(); + const { appId } = useParams() as { appId: string }; + + // When a new secret is created it is returned with the full secret. This is + // the only time it will be visible. The secret list only returns a truncated + // version of the secret (for differentiation purposes). Once the user + // acknowledges the secret we will clear it from the state. + const [fullNewSecret, setFullNewSecret] = + useState(); + + const queryClient = useQueryClient(); + const appQuery = useQuery(oauth2.getApp(appId)); + const putAppMutation = useMutation(oauth2.putApp(queryClient)); + const deleteAppMutation = useMutation(oauth2.deleteApp(queryClient)); + const secretsQuery = useQuery(oauth2.getAppSecrets(appId)); + const postSecretMutation = useMutation(oauth2.postAppSecret(queryClient)); + const deleteSecretMutation = useMutation(oauth2.deleteAppSecret(queryClient)); + + return ( + <> + + {pageTitle("Edit OAuth2 Application")} + + + setFullNewSecret(undefined)} + error={ + appQuery.error || + putAppMutation.error || + deleteAppMutation.error || + secretsQuery.error || + postSecretMutation.error || + deleteSecretMutation.error + } + updateApp={async (req) => { + try { + await putAppMutation.mutateAsync({ id: appId, req }); + // REVIEW: Maybe it is better to stay on the same page? + displaySuccess( + `Successfully updated the OAuth2 application "${req.name}".`, + ); + navigate("/deployment/oauth2-provider/apps?updated=true"); + } catch (ignore) { + displayError("Failed to update OAuth2 application"); + } + }} + deleteApp={async (name) => { + try { + await deleteAppMutation.mutateAsync(appId); + displaySuccess( + `You have successfully deleted the OAuth2 application "${name}"`, + ); + navigate("/deployment/oauth2-provider/apps?deleted=true"); + } catch (error) { + displayError("Failed to delete OAuth2 application"); + } + }} + generateAppSecret={async () => { + try { + const secret = await postSecretMutation.mutateAsync(appId); + displaySuccess("Successfully generated OAuth2 client secret"); + setFullNewSecret(secret); + } catch (ignore) { + displayError("Failed to generate OAuth2 client secret"); + } + }} + deleteAppSecret={async (secretId: string) => { + try { + await deleteSecretMutation.mutateAsync({ appId, secretId }); + displaySuccess("Successfully deleted an OAuth2 client secret"); + if (fullNewSecret?.id === secretId) { + setFullNewSecret(undefined); + } + } catch (ignore) { + displayError("Failed to delete OAuth2 client secret"); + } + }} + /> + + ); +}; + +export default EditOAuth2AppPage; diff --git a/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPageView.stories.tsx b/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPageView.stories.tsx new file mode 100644 index 0000000000..a56b2d13de --- /dev/null +++ b/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPageView.stories.tsx @@ -0,0 +1,83 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + MockOAuth2ProviderApps, + MockOAuth2ProviderAppSecrets, + mockApiError, +} from "testHelpers/entities"; +import { EditOAuth2AppPageView } from "./EditOAuth2AppPageView"; + +const meta: Meta = { + title: "pages/DeploySettingsPage/EditOAuth2AppPageView", + component: EditOAuth2AppPageView, +}; +export default meta; + +type Story = StoryObj; + +export const LoadingApp: Story = { + args: { + isLoadingApp: true, + mutatingResource: { + updateApp: false, + deleteApp: false, + createSecret: false, + deleteSecret: false, + }, + }, +}; + +export const LoadingSecrets: Story = { + args: { + app: MockOAuth2ProviderApps[0], + isLoadingSecrets: true, + mutatingResource: { + updateApp: false, + deleteApp: false, + createSecret: false, + deleteSecret: false, + }, + }, +}; + +export const Error: Story = { + args: { + app: MockOAuth2ProviderApps[0], + secrets: MockOAuth2ProviderAppSecrets, + mutatingResource: { + updateApp: false, + deleteApp: false, + createSecret: false, + deleteSecret: false, + }, + error: mockApiError({ + message: "Validation failed", + validations: [ + { + field: "name", + detail: "name error", + }, + { + field: "callback_url", + detail: "url error", + }, + { + field: "icon", + detail: "icon error", + }, + ], + }), + }, +}; + +export const Default: Story = { + args: { + app: MockOAuth2ProviderApps[0], + secrets: MockOAuth2ProviderAppSecrets, + mutatingResource: { + updateApp: false, + deleteApp: false, + createSecret: false, + deleteSecret: false, + }, + }, +}; diff --git a/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPageView.tsx b/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPageView.tsx new file mode 100644 index 0000000000..8b6bc84bf4 --- /dev/null +++ b/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPageView.tsx @@ -0,0 +1,305 @@ +import { useTheme } from "@emotion/react"; +import CopyIcon from "@mui/icons-material/FileCopyOutlined"; +import KeyboardArrowLeft from "@mui/icons-material/KeyboardArrowLeft"; +import Divider from "@mui/material/Divider"; +import LoadingButton from "@mui/lab/LoadingButton"; +import Button from "@mui/material/Button"; +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 { type FC, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import type * as TypesGen from "api/typesGenerated"; +import { Alert } from "components/Alert/Alert"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { CodeExample } from "components/CodeExample/CodeExample"; +import { CopyableValue } from "components/CopyableValue/CopyableValue"; +import { Header } from "components/DeploySettingsLayout/Header"; +import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; +import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; +import { Loader } from "components/Loader/Loader"; +import { Stack } from "components/Stack/Stack"; +import { TableLoader } from "components/TableLoader/TableLoader"; +import { createDayString } from "utils/createDayString"; +import { OAuth2AppForm } from "./OAuth2AppForm"; + +export type MutatingResource = { + updateApp: boolean; + createSecret: boolean; + deleteApp: boolean; + deleteSecret: boolean; +}; + +type EditOAuth2AppProps = { + app?: TypesGen.OAuth2ProviderApp; + isLoadingApp: boolean; + isLoadingSecrets: boolean; + // mutatingResource indicates which resources, if any, are currently being + // mutated. + mutatingResource: MutatingResource; + updateApp: (req: TypesGen.PutOAuth2ProviderAppRequest) => void; + deleteApp: (name: string) => void; + generateAppSecret: () => void; + deleteAppSecret: (id: string) => void; + secrets?: readonly TypesGen.OAuth2ProviderAppSecret[]; + fullNewSecret?: TypesGen.OAuth2ProviderAppSecretFull; + ackFullNewSecret: () => void; + error?: unknown; +}; + +export const EditOAuth2AppPageView: FC = ({ + app, + isLoadingApp, + isLoadingSecrets, + mutatingResource, + updateApp, + deleteApp, + generateAppSecret, + deleteAppSecret, + secrets, + fullNewSecret, + ackFullNewSecret, + error, +}) => { + const theme = useTheme(); + const [searchParams] = useSearchParams(); + const [showDelete, setShowDelete] = useState(false); + + return ( + <> + +
+ + + + {fullNewSecret && ( + +

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

+ + + } + /> + )} + + + {searchParams.has("created") && ( + + Your OAuth2 application has been created. Generate a client secret + below to start using your application. + + )} + + {error ? : undefined} + + {isLoadingApp && } + + {!isLoadingApp && app && ( + <> + deleteApp(app.name)} + onCancel={() => setShowDelete(false)} + /> + +

Client ID

+ + {app.id}{" "} + + + + + + setShowDelete(true)} + > + Delete… + + } + /> + + + + + + )} +
+ + ); +}; + +type OAuth2AppSecretsTableProps = { + secrets?: readonly TypesGen.OAuth2ProviderAppSecret[]; + generateAppSecret: () => void; + isLoadingSecrets: boolean; + mutatingResource: MutatingResource; + deleteAppSecret: (id: string) => void; +}; + +const OAuth2AppSecretsTable: FC = ({ + secrets, + generateAppSecret, + isLoadingSecrets, + mutatingResource, + deleteAppSecret, +}) => { + return ( + <> + +

Client secrets

+ + Generate secret + +
+ + + + + + Secret + Last Used + + + + + {isLoadingSecrets && } + {!isLoadingSecrets && (!secrets || secrets.length === 0) && ( + + +
+ No client secrets have been generated. +
+
+
+ )} + {!isLoadingSecrets && + secrets?.map((secret) => ( + + ))} +
+
+
+ + ); +}; + +type OAuth2SecretRowProps = { + secret: TypesGen.OAuth2ProviderAppSecret; + deleteAppSecret: (id: string) => void; + mutatingResource: MutatingResource; +}; + +const OAuth2SecretRow: FC = ({ + secret, + deleteAppSecret, + mutatingResource, +}) => { + const [showDelete, setShowDelete] = useState(false); + + return ( + + *****{secret.client_secret_truncated} + + {secret.last_used_at ? createDayString(secret.last_used_at) : "never"} + + + deleteAppSecret(secret.id)} + onClose={() => setShowDelete(false)} + title="Delete OAuth2 client secret" + confirmLoading={mutatingResource.deleteSecret} + confirmText="Delete" + description={ + <> + Deleting *****{secret.client_secret_truncated} is + irreversible and will revoke all the tokens generated by it. Are + you sure you want to proceed? + + } + /> + + + + ); +}; diff --git a/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/OAuth2AppForm.tsx b/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/OAuth2AppForm.tsx new file mode 100644 index 0000000000..6cfa546c41 --- /dev/null +++ b/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/OAuth2AppForm.tsx @@ -0,0 +1,87 @@ +import LoadingButton from "@mui/lab/LoadingButton"; +import TextField from "@mui/material/TextField"; +import { type FC, type ReactNode } from "react"; +import { isApiValidationError, mapApiErrorToFieldErrors } from "api/errors"; +import type * as TypesGen from "api/typesGenerated"; +import { Stack } from "components/Stack/Stack"; + +type OAuth2AppFormProps = { + app?: TypesGen.OAuth2ProviderApp; + onSubmit: (data: { + name: string; + callback_url: string; + icon: string; + }) => void; + error?: unknown; + isUpdating: boolean; + actions?: ReactNode; +}; + +export const OAuth2AppForm: FC = ({ + app, + onSubmit, + error, + isUpdating, + actions, +}) => { + const apiValidationErrors = isApiValidationError(error) + ? mapApiErrorToFieldErrors(error.response.data) + : undefined; + + return ( +
{ + event.preventDefault(); + const formData = new FormData(event.target as HTMLFormElement); + onSubmit({ + name: formData.get("name") as string, + callback_url: formData.get("callback_url") as string, + icon: formData.get("icon") as string, + }); + }} + > + + + + + + + + {app ? "Update application" : "Create application"} + + {actions} + + +
+ ); +}; diff --git a/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx b/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx new file mode 100644 index 0000000000..6e2035f2f9 --- /dev/null +++ b/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage.tsx @@ -0,0 +1,29 @@ +import { useQuery } from "react-query"; +import { getApps } from "api/queries/oauth2"; +import { useDashboard } from "components/Dashboard/DashboardProvider"; +import { FC } from "react"; +import { Helmet } from "react-helmet-async"; +import { pageTitle } from "utils/page"; +import OAuth2AppsSettingsPageView from "./OAuth2AppsSettingsPageView"; + +const OAuth2AppsSettingsPage: FC = () => { + const { entitlements } = useDashboard(); + const appsQuery = useQuery(getApps()); + + return ( + <> + + {pageTitle("OAuth2 Applications")} + + + + ); +}; + +export default OAuth2AppsSettingsPage; diff --git a/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx b/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx new file mode 100644 index 0000000000..8987099d73 --- /dev/null +++ b/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { MockOAuth2ProviderApps } from "testHelpers/entities"; +import OAuth2AppsSettingsPageView from "./OAuth2AppsSettingsPageView"; + +const meta: Meta = { + title: "pages/DeploySettingsPage/OAuth2AppsSettingsPageView", + component: OAuth2AppsSettingsPageView, +}; +export default meta; + +type Story = StoryObj; + +export const Loading: Story = { + args: { + isLoading: true, + }, +}; + +export const Unentitled: Story = { + args: { + isLoading: false, + apps: MockOAuth2ProviderApps, + }, +}; + +export const Entitled: Story = { + args: { + isLoading: false, + apps: MockOAuth2ProviderApps, + isEntitled: true, + }, +}; + +export const Empty: Story = { + args: { + isLoading: false, + }, +}; diff --git a/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx b/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx new file mode 100644 index 0000000000..a3752a1264 --- /dev/null +++ b/site/src/pages/DeploySettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPageView.tsx @@ -0,0 +1,132 @@ +import { useTheme } from "@emotion/react"; +import AddIcon from "@mui/icons-material/AddOutlined"; +import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight"; +import Button from "@mui/material/Button"; +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 { type FC } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import type * as TypesGen from "api/typesGenerated"; +import { AvatarData } from "components/AvatarData/AvatarData"; +import { Avatar } from "components/Avatar/Avatar"; +import { + Badges, + DisabledBadge, + EnterpriseBadge, + EntitledBadge, +} from "components/Badges/Badges"; +import { Header } from "components/DeploySettingsLayout/Header"; +import { TableLoader } from "components/TableLoader/TableLoader"; +import { Stack } from "components/Stack/Stack"; +import { useClickableTableRow } from "hooks/useClickableTableRow"; + +type OAuth2AppsSettingsProps = { + apps?: TypesGen.OAuth2ProviderApp[]; + isEntitled: boolean; + isLoading: boolean; +}; + +const OAuth2AppsSettingsPageView: FC = ({ + apps, + isEntitled, + isLoading, +}) => { + return ( + <> + +
+
+ + {isEntitled ? : } + + +
+ + +
+ + + + + + Name + + + + + {isLoading && } + {!isLoading && + apps?.map((app) => )} + {!isLoading && (!apps || apps?.length === 0) && ( + + +
+ No OAuth2 applications have been configured. +
+
+
+ )} +
+
+
+ + ); +}; + +type OAuth2AppRowProps = { + app: TypesGen.OAuth2ProviderApp; +}; + +const OAuth2AppRow: FC = ({ app }) => { + const theme = useTheme(); + const navigate = useNavigate(); + const clickableProps = useClickableTableRow({ + onClick: () => navigate(`/deployment/oauth2-provider/apps/${app.id}`), + }); + + return ( + + + + ) + } + /> + + + +
+ +
+
+
+ ); +}; + +export default OAuth2AppsSettingsPageView; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 6687acc82a..822eed72df 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3214,3 +3214,25 @@ export const MockGithubAuthLink: TypesGen.ExternalAuthLink = { authenticated: true, validate_error: "", }; + +export const MockOAuth2ProviderApps: TypesGen.OAuth2ProviderApp[] = [ + { + id: "1", + name: "foo", + callback_url: "http://localhost:3001", + icon: "/icon/github.svg", + }, +]; + +export const MockOAuth2ProviderAppSecrets: TypesGen.OAuth2ProviderAppSecret[] = + [ + { + id: "1", + client_secret_truncated: "foo", + }, + { + id: "1", + last_used_at: "2022-12-16T20:10:45.637452Z", + client_secret_truncated: "foo", + }, + ];