diff --git a/.claude/docs/TESTING.md b/.claude/docs/TESTING.md
index eff655b0acadc..1354c496c2785 100644
--- a/.claude/docs/TESTING.md
+++ b/.claude/docs/TESTING.md
@@ -62,21 +62,23 @@ coderd/
### Running Tests
-| Command | Purpose |
-|---------|---------|
-| `make test` | Run all Go tests |
-| `make test RUN=TestFunctionName` | Run specific test |
-| `go test -v ./path/to/package -run TestFunctionName` | Run test with verbose output |
-| `make test-postgres` | Run tests with Postgres database |
-| `make test-race` | Run tests with Go race detector |
-| `make test-e2e` | Run end-to-end tests |
+| Command | Purpose |
+|------------------------------------------------------|----------------------------------|
+| `make test` | Run all Go tests |
+| `make test PACKAGE=./pkg/...` | Run tests for specific package |
+| `make test RUN=TestFunctionName` | Run specific test |
+| `make test PACKAGE=./pkg/... RUN=TestFunctionName` | Run specific test in package |
+| `go test -v ./path/to/package -run TestFunctionName` | Run test with verbose output |
+| `make test-postgres` | Run tests with Postgres database |
+| `make test-race` | Run tests with Go race detector |
+| `make test-e2e` | Run end-to-end tests |
### Frontend Testing
-| Command | Purpose |
-|---------|---------|
-| `pnpm test` | Run frontend tests |
-| `pnpm check` | Run code checks |
+| Command | Purpose |
+|--------------|--------------------|
+| `pnpm test` | Run frontend tests |
+| `pnpm check` | Run code checks |
## Common Testing Issues
@@ -207,6 +209,7 @@ func BenchmarkFunction(b *testing.B) {
```
Run benchmarks with:
+
```bash
go test -bench=. -benchmem ./package/path
```
diff --git a/.claude/docs/WORKFLOWS.md b/.claude/docs/WORKFLOWS.md
index 8fc43002bba7d..de1a2e67f0d7c 100644
--- a/.claude/docs/WORKFLOWS.md
+++ b/.claude/docs/WORKFLOWS.md
@@ -104,7 +104,9 @@
### Test Execution
- Run full test suite: `make test`
+- Run specific package: `make test PACKAGE=./coderd/oauth2/...`
- Run specific test: `make test RUN=TestFunctionName`
+- Run specific test in package: `make test PACKAGE=./coderd/oauth2/... RUN=TestFunctionName`
- Run with Postgres: `make test-postgres`
- Run with race detector: `make test-race`
- Run end-to-end tests: `make test-e2e`
diff --git a/CLAUDE.md b/CLAUDE.md
index 3de33a5466054..01c688f81f5ad 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -7,19 +7,21 @@
## ๐ Essential Commands
-| Task | Command | Notes |
-|-------------------|--------------------------|----------------------------------|
-| **Development** | `./scripts/develop.sh` | โ ๏ธ Don't use manual build |
-| **Build** | `make build` | Fat binaries (includes server) |
-| **Build Slim** | `make build-slim` | Slim binaries |
-| **Test** | `make test` | Full test suite |
-| **Test Single** | `make test RUN=TestName` | Faster than full suite |
-| **Test Postgres** | `make test-postgres` | Run tests with Postgres database |
-| **Test Race** | `make test-race` | Run tests with Go race detector |
-| **Lint** | `make lint` | Always run after changes |
-| **Generate** | `make gen` | After database changes |
-| **Format** | `make fmt` | Auto-format code |
-| **Clean** | `make clean` | Clean build artifacts |
+| Task | Command | Notes |
+|-------------------|--------------------------------------------|----------------------------------|
+| **Development** | `./scripts/develop.sh` | โ ๏ธ Don't use manual build |
+| **Build** | `make build` | Fat binaries (includes server) |
+| **Build Slim** | `make build-slim` | Slim binaries |
+| **Test** | `make test` | Full test suite |
+| **Test Package** | `make test PACKAGE=./pkg/...` | Test specific package |
+| **Test Single** | `make test RUN=TestName` | Faster than full suite |
+| **Test Combined** | `make test PACKAGE=./pkg/... RUN=TestName` | Test specific test in package |
+| **Test Postgres** | `make test-postgres` | Run tests with Postgres database |
+| **Test Race** | `make test-race` | Run tests with Go race detector |
+| **Lint** | `make lint` | Always run after changes |
+| **Generate** | `make gen` | After database changes |
+| **Format** | `make fmt` | Auto-format code |
+| **Clean** | `make clean` | Clean build artifacts |
### Frontend Commands (site directory)
diff --git a/Makefile b/Makefile
index bd3f04a4874cd..ded2a67cd9a99 100644
--- a/Makefile
+++ b/Makefile
@@ -936,7 +936,7 @@ GOTESTSUM_RETRY_FLAGS :=
endif
test:
- $(GIT_FLAGS) gotestsum --format standard-quiet $(GOTESTSUM_RETRY_FLAGS) --packages="./..." -- -v -short -count=1 $(if $(RUN),-run $(RUN))
+ $(GIT_FLAGS) gotestsum --format standard-quiet $(GOTESTSUM_RETRY_FLAGS) --packages="$(if $(PACKAGE),$(PACKAGE),./...)" -- -v -short -count=1 $(if $(RUN),-run $(RUN))
.PHONY: test
test-cli:
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 3d677306696b5..e82c6ca0e28bc 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -1995,6 +1995,12 @@ const docTemplate = `{
"description": "Filter by applications authorized for a user",
"name": "user_id",
"in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Filter by applications owned by a user",
+ "name": "owner_id",
+ "in": "query"
}
],
"responses": {
@@ -14435,6 +14441,13 @@ const docTemplate = `{
"codersdk.OAuth2ProviderApp": {
"type": "object",
"properties": {
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "email": {
+ "type": "string"
+ },
"endpoints": {
"description": "Endpoints are included in the app response for easier discovery. The OAuth2\nspec does not have a defined place to find these (for comparison, OIDC has\na '/.well-known/openid-configuration' endpoint).",
"allOf": [
@@ -14443,6 +14456,12 @@ const docTemplate = `{
}
]
},
+ "grant_types": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"icon": {
"type": "string"
},
@@ -14458,6 +14477,13 @@ const docTemplate = `{
"items": {
"type": "string"
}
+ },
+ "user_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "username": {
+ "type": "string"
}
}
},
@@ -14467,6 +14493,10 @@ const docTemplate = `{
"client_secret_truncated": {
"type": "string"
},
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
"id": {
"type": "string",
"format": "uuid"
@@ -15075,8 +15105,7 @@ const docTemplate = `{
"codersdk.PostOAuth2ProviderAppRequest": {
"type": "object",
"required": [
- "name",
- "redirect_uris"
+ "name"
],
"properties": {
"grant_types": {
@@ -15093,7 +15122,6 @@ const docTemplate = `{
},
"redirect_uris": {
"type": "array",
- "minItems": 1,
"items": {
"type": "string"
}
@@ -15825,8 +15853,7 @@ const docTemplate = `{
"codersdk.PutOAuth2ProviderAppRequest": {
"type": "object",
"required": [
- "name",
- "redirect_uris"
+ "name"
],
"properties": {
"grant_types": {
@@ -15843,7 +15870,6 @@ const docTemplate = `{
},
"redirect_uris": {
"type": "array",
- "minItems": 1,
"items": {
"type": "string"
}
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index fdede9169b308..7c1cbbb2732ce 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -1737,6 +1737,12 @@
"description": "Filter by applications authorized for a user",
"name": "user_id",
"in": "query"
+ },
+ {
+ "type": "string",
+ "description": "Filter by applications owned by a user",
+ "name": "owner_id",
+ "in": "query"
}
],
"responses": {
@@ -13025,6 +13031,13 @@
"codersdk.OAuth2ProviderApp": {
"type": "object",
"properties": {
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "email": {
+ "type": "string"
+ },
"endpoints": {
"description": "Endpoints are included in the app response for easier discovery. The OAuth2\nspec does not have a defined place to find these (for comparison, OIDC has\na '/.well-known/openid-configuration' endpoint).",
"allOf": [
@@ -13033,6 +13046,12 @@
}
]
},
+ "grant_types": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"icon": {
"type": "string"
},
@@ -13048,6 +13067,13 @@
"items": {
"type": "string"
}
+ },
+ "user_id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "username": {
+ "type": "string"
}
}
},
@@ -13057,6 +13083,10 @@
"client_secret_truncated": {
"type": "string"
},
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
"id": {
"type": "string",
"format": "uuid"
@@ -13645,7 +13675,7 @@
},
"codersdk.PostOAuth2ProviderAppRequest": {
"type": "object",
- "required": ["name", "redirect_uris"],
+ "required": ["name"],
"properties": {
"grant_types": {
"type": "array",
@@ -13661,7 +13691,6 @@
},
"redirect_uris": {
"type": "array",
- "minItems": 1,
"items": {
"type": "string"
}
@@ -14361,7 +14390,7 @@
},
"codersdk.PutOAuth2ProviderAppRequest": {
"type": "object",
- "required": ["name", "redirect_uris"],
+ "required": ["name"],
"properties": {
"grant_types": {
"type": "array",
@@ -14377,7 +14406,6 @@
},
"redirect_uris": {
"type": "array",
- "minItems": 1,
"items": {
"type": "string"
}
diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go
index c8fc95dcec215..f5fbd2bbd6459 100644
--- a/coderd/database/db2sdk/db2sdk.go
+++ b/coderd/database/db2sdk/db2sdk.go
@@ -354,23 +354,34 @@ func TemplateVersionParameterOptionFromPreview(option *previewtypes.ParameterOpt
}
}
+// oauth2AppEndpoints generates the OAuth2 endpoints for an app
+func oauth2AppEndpoints(accessURL *url.URL) codersdk.OAuth2AppEndpoints {
+ return codersdk.OAuth2AppEndpoints{
+ Authorization: accessURL.ResolveReference(&url.URL{
+ Path: "/oauth2/authorize",
+ }).String(),
+ Token: accessURL.ResolveReference(&url.URL{
+ Path: "/oauth2/token",
+ }).String(),
+ DeviceAuth: accessURL.ResolveReference(&url.URL{
+ Path: "/oauth2/device",
+ }).String(),
+ Revocation: accessURL.ResolveReference(&url.URL{
+ Path: "/oauth2/revoke",
+ }).String(),
+ }
+}
+
func OAuth2ProviderApp(accessURL *url.URL, dbApp database.OAuth2ProviderApp) codersdk.OAuth2ProviderApp {
return codersdk.OAuth2ProviderApp{
ID: dbApp.ID,
Name: dbApp.Name,
RedirectURIs: dbApp.RedirectUris,
Icon: dbApp.Icon,
- Endpoints: codersdk.OAuth2AppEndpoints{
- Authorization: accessURL.ResolveReference(&url.URL{
- Path: "/oauth2/authorize",
- }).String(),
- Token: accessURL.ResolveReference(&url.URL{
- Path: "/oauth2/token",
- }).String(),
- DeviceAuth: accessURL.ResolveReference(&url.URL{
- Path: "/oauth2/device/authorize",
- }).String(),
- },
+ CreatedAt: dbApp.CreatedAt,
+ GrantTypes: dbApp.GrantTypes,
+ UserID: dbApp.UserID.UUID,
+ Endpoints: oauth2AppEndpoints(accessURL),
}
}
@@ -380,6 +391,55 @@ func OAuth2ProviderApps(accessURL *url.URL, dbApps []database.OAuth2ProviderApp)
})
}
+func OAuth2ProviderAppRow(accessURL *url.URL, dbApp database.GetOAuth2ProviderAppByIDRow) codersdk.OAuth2ProviderApp {
+ return codersdk.OAuth2ProviderApp{
+ ID: dbApp.ID,
+ Name: dbApp.Name,
+ RedirectURIs: dbApp.RedirectUris,
+ Icon: dbApp.Icon,
+ CreatedAt: dbApp.CreatedAt,
+ GrantTypes: dbApp.GrantTypes,
+ UserID: dbApp.UserID.UUID,
+ Username: dbApp.Username.String,
+ Email: dbApp.Email.String,
+ Endpoints: oauth2AppEndpoints(accessURL),
+ }
+}
+
+func OAuth2ProviderAppsRows(accessURL *url.URL, dbApps []database.GetOAuth2ProviderAppsRow) []codersdk.OAuth2ProviderApp {
+ return List(dbApps, func(dbApp database.GetOAuth2ProviderAppsRow) codersdk.OAuth2ProviderApp {
+ return codersdk.OAuth2ProviderApp{
+ ID: dbApp.ID,
+ Name: dbApp.Name,
+ RedirectURIs: dbApp.RedirectUris,
+ Icon: dbApp.Icon,
+ CreatedAt: dbApp.CreatedAt,
+ GrantTypes: dbApp.GrantTypes,
+ UserID: dbApp.UserID.UUID,
+ Username: dbApp.Username.String,
+ Email: dbApp.Email.String,
+ Endpoints: oauth2AppEndpoints(accessURL),
+ }
+ })
+}
+
+func OAuth2ProviderAppsByOwnerIDRows(accessURL *url.URL, dbApps []database.GetOAuth2ProviderAppsByOwnerIDRow) []codersdk.OAuth2ProviderApp {
+ return List(dbApps, func(dbApp database.GetOAuth2ProviderAppsByOwnerIDRow) codersdk.OAuth2ProviderApp {
+ return codersdk.OAuth2ProviderApp{
+ ID: dbApp.ID,
+ Name: dbApp.Name,
+ RedirectURIs: dbApp.RedirectUris,
+ Icon: dbApp.Icon,
+ CreatedAt: dbApp.CreatedAt,
+ GrantTypes: dbApp.GrantTypes,
+ UserID: dbApp.UserID.UUID,
+ Username: dbApp.Username.String,
+ Email: dbApp.Email.String,
+ Endpoints: oauth2AppEndpoints(accessURL),
+ }
+ })
+}
+
func convertDisplayApps(apps []database.DisplayApp) []codersdk.DisplayApp {
dapps := make([]codersdk.DisplayApp, 0, len(apps))
for _, app := range apps {
diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go
index 4ada84f552374..8f0dfd24b2016 100644
--- a/coderd/database/dbauthz/dbauthz.go
+++ b/coderd/database/dbauthz/dbauthz.go
@@ -1587,7 +1587,14 @@ func (q *querier) DeleteOAuth2ProviderAppByClientID(ctx context.Context, id uuid
}
func (q *querier) DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error {
- if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceOauth2App); err != nil {
+ // Get the app to check ownership for proper authorization
+ app, err := q.db.GetOAuth2ProviderAppByID(ctx, id)
+ if err != nil {
+ return err
+ }
+
+ // Check if user can delete this specific app
+ if err := q.authorizeContext(ctx, policy.ActionDelete, app.RBACObject()); err != nil {
return err
}
return q.db.DeleteOAuth2ProviderAppByID(ctx, id)
@@ -1613,10 +1620,7 @@ func (q *querier) DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx context.Context
}
func (q *querier) DeleteOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) error {
- if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceOauth2AppSecret); err != nil {
- return err
- }
- return q.db.DeleteOAuth2ProviderAppSecretByID(ctx, id)
+ return fetchAndExec(q.log, q.auth, policy.ActionDelete, q.db.GetOAuth2ProviderAppSecretByID, q.db.DeleteOAuth2ProviderAppSecretByID)(ctx, id)
}
func (q *querier) DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx context.Context, arg database.DeleteOAuth2ProviderAppTokensByAppAndUserIDParams) error {
@@ -2344,11 +2348,8 @@ func (q *querier) GetOAuth2ProviderAppByClientID(ctx context.Context, id uuid.UU
return q.db.GetOAuth2ProviderAppByClientID(ctx, id)
}
-func (q *querier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) {
- if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOauth2App); err != nil {
- return database.OAuth2ProviderApp{}, err
- }
- return q.db.GetOAuth2ProviderAppByID(ctx, id)
+func (q *querier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.GetOAuth2ProviderAppByIDRow, error) {
+ return fetch(q.log, q.auth, q.db.GetOAuth2ProviderAppByID)(ctx, id)
}
func (q *querier) GetOAuth2ProviderAppByRegistrationToken(ctx context.Context, registrationAccessToken sql.NullString) (database.OAuth2ProviderApp, error) {
@@ -2367,10 +2368,7 @@ func (q *querier) GetOAuth2ProviderAppCodeByPrefix(ctx context.Context, secretPr
}
func (q *querier) GetOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderAppSecret, error) {
- if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOauth2AppSecret); err != nil {
- return database.OAuth2ProviderAppSecret{}, err
- }
- return q.db.GetOAuth2ProviderAppSecretByID(ctx, id)
+ return fetch(q.log, q.auth, q.db.GetOAuth2ProviderAppSecretByID)(ctx, id)
}
func (q *querier) GetOAuth2ProviderAppSecretByPrefix(ctx context.Context, secretPrefix []byte) (database.OAuth2ProviderAppSecret, error) {
@@ -2378,10 +2376,7 @@ func (q *querier) GetOAuth2ProviderAppSecretByPrefix(ctx context.Context, secret
}
func (q *querier) GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, appID uuid.UUID) ([]database.OAuth2ProviderAppSecret, error) {
- if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOauth2AppSecret); err != nil {
- return []database.OAuth2ProviderAppSecret{}, err
- }
- return q.db.GetOAuth2ProviderAppSecretsByAppID(ctx, appID)
+ return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetOAuth2ProviderAppSecretsByAppID)(ctx, appID)
}
func (q *querier) GetOAuth2ProviderAppTokenByAPIKeyID(ctx context.Context, apiKeyID string) (database.OAuth2ProviderAppToken, error) {
@@ -2410,11 +2405,32 @@ func (q *querier) GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, hashPre
return token, nil
}
-func (q *querier) GetOAuth2ProviderApps(ctx context.Context) ([]database.OAuth2ProviderApp, error) {
- if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOauth2App); err != nil {
- return []database.OAuth2ProviderApp{}, err
+func (q *querier) GetOAuth2ProviderApps(ctx context.Context) ([]database.GetOAuth2ProviderAppsRow, error) {
+ // Ensure actor is present before attempting authorization
+ if _, ok := ActorFromContext(ctx); !ok {
+ return nil, ErrNoActor
}
- return q.db.GetOAuth2ProviderApps(ctx)
+
+ // Get all apps from the database
+ apps, err := q.db.GetOAuth2ProviderApps(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // Filter apps based on authorization
+ var filteredApps []database.GetOAuth2ProviderAppsRow
+ for _, app := range apps {
+ // Check authorization for each app using proper ownership model
+ if err := q.authorizeContext(ctx, policy.ActionRead, app.RBACObject()); err == nil {
+ filteredApps = append(filteredApps, app)
+ }
+ }
+
+ return filteredApps, nil
+}
+
+func (q *querier) GetOAuth2ProviderAppsByOwnerID(ctx context.Context, userID uuid.NullUUID) ([]database.GetOAuth2ProviderAppsByOwnerIDRow, error) {
+ return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetOAuth2ProviderAppsByOwnerID)(ctx, userID)
}
func (q *querier) GetOAuth2ProviderAppsByUserID(ctx context.Context, userID uuid.UUID) ([]database.GetOAuth2ProviderAppsByUserIDRow, error) {
@@ -3881,7 +3897,7 @@ func (q *querier) InsertMissingGroups(ctx context.Context, arg database.InsertMi
}
func (q *querier) InsertOAuth2ProviderApp(ctx context.Context, arg database.InsertOAuth2ProviderAppParams) (database.OAuth2ProviderApp, error) {
- if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceOauth2App); err != nil {
+ if err := q.authorizeContext(ctx, policy.ActionCreate, arg.RBACObject()); err != nil {
return database.OAuth2ProviderApp{}, err
}
return q.db.InsertOAuth2ProviderApp(ctx, arg)
@@ -3896,9 +3912,28 @@ func (q *querier) InsertOAuth2ProviderAppCode(ctx context.Context, arg database.
}
func (q *querier) InsertOAuth2ProviderAppSecret(ctx context.Context, arg database.InsertOAuth2ProviderAppSecretParams) (database.OAuth2ProviderAppSecret, error) {
- if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceOauth2AppSecret); err != nil {
+ // Fetch the app to get ownership information
+ app, err := q.db.GetOAuth2ProviderAppByID(ctx, arg.AppID)
+ if err != nil {
+ return database.OAuth2ProviderAppSecret{}, xerrors.Errorf("fetch parent app: %w", err)
+ }
+
+ // Populate denormalized field if not already set
+ if !arg.AppOwnerUserID.Valid {
+ arg.AppOwnerUserID = app.UserID
+ }
+
+ // Create a secret with the denormalized ownership for authorization
+ secret := database.OAuth2ProviderAppSecret{
+ AppID: arg.AppID,
+ ID: arg.ID,
+ AppOwnerUserID: arg.AppOwnerUserID,
+ }
+
+ if err := q.authorizeContext(ctx, policy.ActionCreate, secret.RBACObject()); err != nil {
return database.OAuth2ProviderAppSecret{}, err
}
+
return q.db.InsertOAuth2ProviderAppSecret(ctx, arg)
}
@@ -4585,17 +4620,24 @@ func (q *querier) UpdateOAuth2ProviderAppByClientID(ctx context.Context, arg dat
}
func (q *querier) UpdateOAuth2ProviderAppByID(ctx context.Context, arg database.UpdateOAuth2ProviderAppByIDParams) (database.OAuth2ProviderApp, error) {
- if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceOauth2App); err != nil {
+ // Get the app to check ownership for proper authorization
+ app, err := q.db.GetOAuth2ProviderAppByID(ctx, arg.ID)
+ if err != nil {
+ return database.OAuth2ProviderApp{}, err
+ }
+
+ // Check if user can update this specific app
+ if err := q.authorizeContext(ctx, policy.ActionUpdate, app.RBACObject()); err != nil {
return database.OAuth2ProviderApp{}, err
}
return q.db.UpdateOAuth2ProviderAppByID(ctx, arg)
}
func (q *querier) UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg database.UpdateOAuth2ProviderAppSecretByIDParams) (database.OAuth2ProviderAppSecret, error) {
- if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceOauth2AppSecret); err != nil {
- return database.OAuth2ProviderAppSecret{}, err
+ fetch := func(ctx context.Context, arg database.UpdateOAuth2ProviderAppSecretByIDParams) (database.OAuth2ProviderAppSecret, error) {
+ return q.db.GetOAuth2ProviderAppSecretByID(ctx, arg.ID)
}
- return q.db.UpdateOAuth2ProviderAppSecretByID(ctx, arg)
+ return fetchAndQuery(q.log, q.auth, policy.ActionUpdate, fetch, q.db.UpdateOAuth2ProviderAppSecretByID)(ctx, arg)
}
func (q *querier) UpdateOAuth2ProviderDeviceCodeAuthorization(ctx context.Context, arg database.UpdateOAuth2ProviderDeviceCodeAuthorizationParams) (database.OAuth2ProviderDeviceCode, error) {
diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go
index db7e4815e6d9a..3b8391bd0d00b 100644
--- a/coderd/database/dbauthz/dbauthz_test.go
+++ b/coderd/database/dbauthz/dbauthz_test.go
@@ -5259,15 +5259,110 @@ func (s *MethodTestSuite) TestPrebuilds() {
func (s *MethodTestSuite) TestOAuth2ProviderApps() {
s.Run("GetOAuth2ProviderApps", s.Subtest(func(db database.Store, check *expects) {
- apps := []database.OAuth2ProviderApp{
- dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{Name: "first"}),
- dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{Name: "last"}),
+ app1 := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{Name: "first"})
+ app2 := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{Name: "last"})
+ apps := []database.GetOAuth2ProviderAppsRow{
+ {
+ ID: app1.ID,
+ CreatedAt: app1.CreatedAt,
+ UpdatedAt: app1.UpdatedAt,
+ Name: app1.Name,
+ Icon: app1.Icon,
+ RedirectUris: app1.RedirectUris,
+ ClientType: app1.ClientType,
+ DynamicallyRegistered: app1.DynamicallyRegistered,
+ ClientIDIssuedAt: app1.ClientIDIssuedAt,
+ ClientSecretExpiresAt: app1.ClientSecretExpiresAt,
+ GrantTypes: app1.GrantTypes,
+ ResponseTypes: app1.ResponseTypes,
+ TokenEndpointAuthMethod: app1.TokenEndpointAuthMethod,
+ Scope: app1.Scope,
+ Contacts: app1.Contacts,
+ ClientUri: app1.ClientUri,
+ LogoUri: app1.LogoUri,
+ TosUri: app1.TosUri,
+ PolicyUri: app1.PolicyUri,
+ JwksUri: app1.JwksUri,
+ Jwks: app1.Jwks,
+ SoftwareID: app1.SoftwareID,
+ SoftwareVersion: app1.SoftwareVersion,
+ RegistrationAccessToken: app1.RegistrationAccessToken,
+ RegistrationClientUri: app1.RegistrationClientUri,
+ UserID: app1.UserID,
+ Username: sql.NullString{},
+ Email: sql.NullString{},
+ },
+ {
+ ID: app2.ID,
+ CreatedAt: app2.CreatedAt,
+ UpdatedAt: app2.UpdatedAt,
+ Name: app2.Name,
+ Icon: app2.Icon,
+ RedirectUris: app2.RedirectUris,
+ ClientType: app2.ClientType,
+ DynamicallyRegistered: app2.DynamicallyRegistered,
+ ClientIDIssuedAt: app2.ClientIDIssuedAt,
+ ClientSecretExpiresAt: app2.ClientSecretExpiresAt,
+ GrantTypes: app2.GrantTypes,
+ ResponseTypes: app2.ResponseTypes,
+ TokenEndpointAuthMethod: app2.TokenEndpointAuthMethod,
+ Scope: app2.Scope,
+ Contacts: app2.Contacts,
+ ClientUri: app2.ClientUri,
+ LogoUri: app2.LogoUri,
+ TosUri: app2.TosUri,
+ PolicyUri: app2.PolicyUri,
+ JwksUri: app2.JwksUri,
+ Jwks: app2.Jwks,
+ SoftwareID: app2.SoftwareID,
+ SoftwareVersion: app2.SoftwareVersion,
+ RegistrationAccessToken: app2.RegistrationAccessToken,
+ RegistrationClientUri: app2.RegistrationClientUri,
+ UserID: app2.UserID,
+ Username: sql.NullString{},
+ Email: sql.NullString{},
+ },
}
- check.Args().Asserts(rbac.ResourceOauth2App, policy.ActionRead).Returns(apps)
+ // fetchWithPostFilter calls RBACObject() on each app automatically
+ // Pass the actual app objects and let the framework call their RBACObject() method
+ check.Args().
+ Asserts(app1, policy.ActionRead, app2, policy.ActionRead).
+ OutOfOrder().
+ Returns(apps)
}))
s.Run("GetOAuth2ProviderAppByID", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
- check.Args(app.ID).Asserts(rbac.ResourceOauth2App, policy.ActionRead).Returns(app)
+ appRow := database.GetOAuth2ProviderAppByIDRow{
+ ID: app.ID,
+ CreatedAt: app.CreatedAt,
+ UpdatedAt: app.UpdatedAt,
+ Name: app.Name,
+ Icon: app.Icon,
+ RedirectUris: app.RedirectUris,
+ ClientType: app.ClientType,
+ DynamicallyRegistered: app.DynamicallyRegistered,
+ ClientIDIssuedAt: app.ClientIDIssuedAt,
+ ClientSecretExpiresAt: app.ClientSecretExpiresAt,
+ GrantTypes: app.GrantTypes,
+ ResponseTypes: app.ResponseTypes,
+ TokenEndpointAuthMethod: app.TokenEndpointAuthMethod,
+ Scope: app.Scope,
+ Contacts: app.Contacts,
+ ClientUri: app.ClientUri,
+ LogoUri: app.LogoUri,
+ TosUri: app.TosUri,
+ PolicyUri: app.PolicyUri,
+ JwksUri: app.JwksUri,
+ Jwks: app.Jwks,
+ SoftwareID: app.SoftwareID,
+ SoftwareVersion: app.SoftwareVersion,
+ RegistrationAccessToken: app.RegistrationAccessToken,
+ RegistrationClientUri: app.RegistrationClientUri,
+ UserID: app.UserID,
+ Username: sql.NullString{},
+ Email: sql.NullString{},
+ }
+ check.Args(app.ID).Asserts(rbac.ResourceOauth2App, policy.ActionRead).Returns(appRow)
}))
s.Run("GetOAuth2ProviderAppsByUserID", s.Subtest(func(db database.Store, check *expects) {
dbtestutil.DisableForeignKeysAndTriggers(s.T(), db)
@@ -5353,6 +5448,91 @@ func (s *MethodTestSuite) TestOAuth2ProviderApps() {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
check.Args(app.ID).Asserts(rbac.ResourceOauth2App, policy.ActionDelete)
}))
+ s.Run("GetOAuth2ProviderAppsByOwnerID", s.Subtest(func(db database.Store, check *expects) {
+ owner := dbgen.User(s.T(), db, database.User{})
+ // Create multiple apps with the same owner
+ app1 := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{
+ UserID: uuid.NullUUID{UUID: owner.ID, Valid: true},
+ })
+ app2 := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{
+ UserID: uuid.NullUUID{UUID: owner.ID, Valid: true},
+ })
+ // Create an app with a different owner to ensure filtering works
+ differentOwner := dbgen.User(s.T(), db, database.User{})
+ _ = dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{
+ UserID: uuid.NullUUID{UUID: differentOwner.ID, Valid: true},
+ })
+
+ apps := []database.GetOAuth2ProviderAppsByOwnerIDRow{
+ {
+ ID: app1.ID,
+ CreatedAt: app1.CreatedAt,
+ UpdatedAt: app1.UpdatedAt,
+ Name: app1.Name,
+ Icon: app1.Icon,
+ RedirectUris: app1.RedirectUris,
+ ClientType: app1.ClientType,
+ DynamicallyRegistered: app1.DynamicallyRegistered,
+ ClientIDIssuedAt: app1.ClientIDIssuedAt,
+ ClientSecretExpiresAt: app1.ClientSecretExpiresAt,
+ GrantTypes: app1.GrantTypes,
+ ResponseTypes: app1.ResponseTypes,
+ TokenEndpointAuthMethod: app1.TokenEndpointAuthMethod,
+ Scope: app1.Scope,
+ Contacts: app1.Contacts,
+ ClientUri: app1.ClientUri,
+ LogoUri: app1.LogoUri,
+ TosUri: app1.TosUri,
+ PolicyUri: app1.PolicyUri,
+ JwksUri: app1.JwksUri,
+ Jwks: app1.Jwks,
+ SoftwareID: app1.SoftwareID,
+ SoftwareVersion: app1.SoftwareVersion,
+ RegistrationAccessToken: app1.RegistrationAccessToken,
+ RegistrationClientUri: app1.RegistrationClientUri,
+ UserID: app1.UserID,
+ Username: sql.NullString{String: owner.Username, Valid: true},
+ Email: sql.NullString{String: owner.Email, Valid: true},
+ },
+ {
+ ID: app2.ID,
+ CreatedAt: app2.CreatedAt,
+ UpdatedAt: app2.UpdatedAt,
+ Name: app2.Name,
+ Icon: app2.Icon,
+ RedirectUris: app2.RedirectUris,
+ ClientType: app2.ClientType,
+ DynamicallyRegistered: app2.DynamicallyRegistered,
+ ClientIDIssuedAt: app2.ClientIDIssuedAt,
+ ClientSecretExpiresAt: app2.ClientSecretExpiresAt,
+ GrantTypes: app2.GrantTypes,
+ ResponseTypes: app2.ResponseTypes,
+ TokenEndpointAuthMethod: app2.TokenEndpointAuthMethod,
+ Scope: app2.Scope,
+ Contacts: app2.Contacts,
+ ClientUri: app2.ClientUri,
+ LogoUri: app2.LogoUri,
+ TosUri: app2.TosUri,
+ PolicyUri: app2.PolicyUri,
+ JwksUri: app2.JwksUri,
+ Jwks: app2.Jwks,
+ SoftwareID: app2.SoftwareID,
+ SoftwareVersion: app2.SoftwareVersion,
+ RegistrationAccessToken: app2.RegistrationAccessToken,
+ RegistrationClientUri: app2.RegistrationClientUri,
+ UserID: app2.UserID,
+ Username: sql.NullString{String: owner.Username, Valid: true},
+ Email: sql.NullString{String: owner.Email, Valid: true},
+ },
+ }
+
+ // fetchWithPostFilter calls RBACObject() on each app automatically
+ // Pass the actual app objects and let the framework call their RBACObject() method
+ check.Args(uuid.NullUUID{UUID: owner.ID, Valid: true}).
+ Asserts(app1, policy.ActionRead, app2, policy.ActionRead).
+ OutOfOrder().
+ Returns(apps)
+ }))
s.Run("GetOAuth2ProviderAppByClientID", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
check.Args(app.ID).Asserts(rbac.ResourceOauth2App, policy.ActionRead).Returns(app)
@@ -5417,27 +5597,31 @@ func (s *MethodTestSuite) TestOAuth2ProviderAppSecrets() {
AppID: app2.ID,
SecretPrefix: []byte("3"),
})
- check.Args(app1.ID).Asserts(rbac.ResourceOauth2AppSecret, policy.ActionRead).Returns(secrets)
+ // fetchWithPostFilter calls RBACObject() on each secret automatically
+ // Pass the actual secret objects and let the framework call their RBACObject() method
+ check.Args(app1.ID).
+ Asserts(secrets[0], policy.ActionRead, secrets[1], policy.ActionRead).
+ Returns(secrets)
}))
s.Run("GetOAuth2ProviderAppSecretByID", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
secret := dbgen.OAuth2ProviderAppSecret(s.T(), db, database.OAuth2ProviderAppSecret{
AppID: app.ID,
})
- check.Args(secret.ID).Asserts(rbac.ResourceOauth2AppSecret, policy.ActionRead).Returns(secret)
+ check.Args(secret.ID).Asserts(rbac.ResourceOauth2AppSecret.WithID(secret.ID), policy.ActionRead).Returns(secret)
}))
s.Run("GetOAuth2ProviderAppSecretByPrefix", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
secret := dbgen.OAuth2ProviderAppSecret(s.T(), db, database.OAuth2ProviderAppSecret{
AppID: app.ID,
})
- check.Args(secret.SecretPrefix).Asserts(rbac.ResourceOauth2AppSecret, policy.ActionRead).Returns(secret)
+ check.Args(secret.SecretPrefix).Asserts(rbac.ResourceOauth2AppSecret.WithID(secret.ID), policy.ActionRead).Returns(secret)
}))
s.Run("InsertOAuth2ProviderAppSecret", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
check.Args(database.InsertOAuth2ProviderAppSecretParams{
AppID: app.ID,
- }).Asserts(rbac.ResourceOauth2AppSecret, policy.ActionCreate)
+ }).Asserts(rbac.ResourceOauth2AppSecret.WithID(uuid.Nil), policy.ActionCreate)
}))
s.Run("UpdateOAuth2ProviderAppSecretByID", s.Subtest(func(db database.Store, check *expects) {
dbtestutil.DisableForeignKeysAndTriggers(s.T(), db)
@@ -5449,14 +5633,14 @@ func (s *MethodTestSuite) TestOAuth2ProviderAppSecrets() {
check.Args(database.UpdateOAuth2ProviderAppSecretByIDParams{
ID: secret.ID,
LastUsedAt: secret.LastUsedAt,
- }).Asserts(rbac.ResourceOauth2AppSecret, policy.ActionUpdate).Returns(secret)
+ }).Asserts(rbac.ResourceOauth2AppSecret.WithID(secret.ID), policy.ActionUpdate).Returns(secret)
}))
s.Run("DeleteOAuth2ProviderAppSecretByID", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
secret := dbgen.OAuth2ProviderAppSecret(s.T(), db, database.OAuth2ProviderAppSecret{
AppID: app.ID,
})
- check.Args(secret.ID).Asserts(rbac.ResourceOauth2AppSecret, policy.ActionDelete)
+ check.Args(secret.ID).Asserts(rbac.ResourceOauth2AppSecret.WithID(secret.ID), policy.ActionDelete)
}))
}
diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go
index a22de6b174681..06d3d2946161f 100644
--- a/coderd/database/dbgen/dbgen.go
+++ b/coderd/database/dbgen/dbgen.go
@@ -1231,12 +1231,13 @@ func OAuth2ProviderApp(t testing.TB, db database.Store, seed database.OAuth2Prov
func OAuth2ProviderAppSecret(t testing.TB, db database.Store, seed database.OAuth2ProviderAppSecret) database.OAuth2ProviderAppSecret {
app, err := db.InsertOAuth2ProviderAppSecret(genCtx, database.InsertOAuth2ProviderAppSecretParams{
- ID: takeFirst(seed.ID, uuid.New()),
- CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()),
- SecretPrefix: takeFirstSlice(seed.SecretPrefix, []byte("prefix")),
- HashedSecret: takeFirstSlice(seed.HashedSecret, []byte("hashed-secret")),
- DisplaySecret: takeFirst(seed.DisplaySecret, "secret"),
- AppID: takeFirst(seed.AppID, uuid.New()),
+ ID: takeFirst(seed.ID, uuid.New()),
+ CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()),
+ SecretPrefix: takeFirstSlice(seed.SecretPrefix, []byte("prefix")),
+ HashedSecret: takeFirstSlice(seed.HashedSecret, []byte("hashed-secret")),
+ DisplaySecret: takeFirst(seed.DisplaySecret, "secret"),
+ AppID: takeFirst(seed.AppID, uuid.New()),
+ AppOwnerUserID: takeFirst(seed.AppOwnerUserID, uuid.NullUUID{}),
})
require.NoError(t, err, "insert oauth2 app secret")
return app
diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go
index 3cd55b9ef3c87..502f6d53c7ae9 100644
--- a/coderd/database/dbmetrics/querymetrics.go
+++ b/coderd/database/dbmetrics/querymetrics.go
@@ -1069,7 +1069,7 @@ func (m queryMetricsStore) GetOAuth2ProviderAppByClientID(ctx context.Context, i
return r0, r1
}
-func (m queryMetricsStore) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) {
+func (m queryMetricsStore) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.GetOAuth2ProviderAppByIDRow, error) {
start := time.Now()
r0, r1 := m.s.GetOAuth2ProviderAppByID(ctx, id)
m.queryLatencies.WithLabelValues("GetOAuth2ProviderAppByID").Observe(time.Since(start).Seconds())
@@ -1132,13 +1132,20 @@ func (m queryMetricsStore) GetOAuth2ProviderAppTokenByPrefix(ctx context.Context
return r0, r1
}
-func (m queryMetricsStore) GetOAuth2ProviderApps(ctx context.Context) ([]database.OAuth2ProviderApp, error) {
+func (m queryMetricsStore) GetOAuth2ProviderApps(ctx context.Context) ([]database.GetOAuth2ProviderAppsRow, error) {
start := time.Now()
r0, r1 := m.s.GetOAuth2ProviderApps(ctx)
m.queryLatencies.WithLabelValues("GetOAuth2ProviderApps").Observe(time.Since(start).Seconds())
return r0, r1
}
+func (m queryMetricsStore) GetOAuth2ProviderAppsByOwnerID(ctx context.Context, userID uuid.NullUUID) ([]database.GetOAuth2ProviderAppsByOwnerIDRow, error) {
+ start := time.Now()
+ r0, r1 := m.s.GetOAuth2ProviderAppsByOwnerID(ctx, userID)
+ m.queryLatencies.WithLabelValues("GetOAuth2ProviderAppsByOwnerID").Observe(time.Since(start).Seconds())
+ return r0, r1
+}
+
func (m queryMetricsStore) GetOAuth2ProviderAppsByUserID(ctx context.Context, userID uuid.UUID) ([]database.GetOAuth2ProviderAppsByUserIDRow, error) {
start := time.Now()
r0, r1 := m.s.GetOAuth2ProviderAppsByUserID(ctx, userID)
diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go
index 0d36854089e5a..e6a65ea68a689 100644
--- a/coderd/database/dbmock/dbmock.go
+++ b/coderd/database/dbmock/dbmock.go
@@ -2233,10 +2233,10 @@ func (mr *MockStoreMockRecorder) GetOAuth2ProviderAppByClientID(ctx, id any) *go
}
// GetOAuth2ProviderAppByID mocks base method.
-func (m *MockStore) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) {
+func (m *MockStore) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.GetOAuth2ProviderAppByIDRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOAuth2ProviderAppByID", ctx, id)
- ret0, _ := ret[0].(database.OAuth2ProviderApp)
+ ret0, _ := ret[0].(database.GetOAuth2ProviderAppByIDRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
@@ -2368,10 +2368,10 @@ func (mr *MockStoreMockRecorder) GetOAuth2ProviderAppTokenByPrefix(ctx, hashPref
}
// GetOAuth2ProviderApps mocks base method.
-func (m *MockStore) GetOAuth2ProviderApps(ctx context.Context) ([]database.OAuth2ProviderApp, error) {
+func (m *MockStore) GetOAuth2ProviderApps(ctx context.Context) ([]database.GetOAuth2ProviderAppsRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOAuth2ProviderApps", ctx)
- ret0, _ := ret[0].([]database.OAuth2ProviderApp)
+ ret0, _ := ret[0].([]database.GetOAuth2ProviderAppsRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
@@ -2382,6 +2382,21 @@ func (mr *MockStoreMockRecorder) GetOAuth2ProviderApps(ctx any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuth2ProviderApps", reflect.TypeOf((*MockStore)(nil).GetOAuth2ProviderApps), ctx)
}
+// GetOAuth2ProviderAppsByOwnerID mocks base method.
+func (m *MockStore) GetOAuth2ProviderAppsByOwnerID(ctx context.Context, userID uuid.NullUUID) ([]database.GetOAuth2ProviderAppsByOwnerIDRow, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetOAuth2ProviderAppsByOwnerID", ctx, userID)
+ ret0, _ := ret[0].([]database.GetOAuth2ProviderAppsByOwnerIDRow)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetOAuth2ProviderAppsByOwnerID indicates an expected call of GetOAuth2ProviderAppsByOwnerID.
+func (mr *MockStoreMockRecorder) GetOAuth2ProviderAppsByOwnerID(ctx, userID any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuth2ProviderAppsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetOAuth2ProviderAppsByOwnerID), ctx, userID)
+}
+
// GetOAuth2ProviderAppsByUserID mocks base method.
func (m *MockStore) GetOAuth2ProviderAppsByUserID(ctx context.Context, userID uuid.UUID) ([]database.GetOAuth2ProviderAppsByUserIDRow, error) {
m.ctrl.T.Helper()
diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql
index f15a86b97ea52..49e1dc77c70ea 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -1192,11 +1192,14 @@ CREATE TABLE oauth2_provider_app_secrets (
hashed_secret bytea NOT NULL,
display_secret text NOT NULL,
app_id uuid NOT NULL,
- secret_prefix bytea NOT NULL
+ secret_prefix bytea NOT NULL,
+ app_owner_user_id uuid
);
COMMENT ON COLUMN oauth2_provider_app_secrets.display_secret IS 'The tail end of the original secret so secrets can be differentiated.';
+COMMENT ON COLUMN oauth2_provider_app_secrets.app_owner_user_id IS 'Denormalized owner user ID from parent app for efficient authorization without N+1 queries';
+
CREATE TABLE oauth2_provider_app_tokens (
id uuid NOT NULL,
created_at timestamp with time zone NOT NULL,
@@ -1242,12 +1245,12 @@ CREATE TABLE oauth2_provider_apps (
registration_access_token text,
registration_client_uri text,
user_id uuid,
- CONSTRAINT redirect_uris_not_empty CHECK ((cardinality(redirect_uris) > 0))
+ CONSTRAINT redirect_uris_not_empty_unless_client_credentials CHECK ((((grant_types = ARRAY['client_credentials'::text]) AND (cardinality(redirect_uris) >= 0)) OR ((grant_types <> ARRAY['client_credentials'::text]) AND (cardinality(redirect_uris) > 0))))
);
COMMENT ON TABLE oauth2_provider_apps IS 'A table used to configure apps that can use Coder as an OAuth2 provider, the reverse of what we are calling external authentication.';
-COMMENT ON COLUMN oauth2_provider_apps.redirect_uris IS 'RFC 6749 compliant list of valid redirect URIs for the application';
+COMMENT ON COLUMN oauth2_provider_apps.redirect_uris IS 'RFC 6749 compliant list of valid redirect URIs for the application. May be empty for client credentials applications.';
COMMENT ON COLUMN oauth2_provider_apps.client_type IS 'OAuth2 client type: confidential or public';
@@ -2872,6 +2875,8 @@ CREATE INDEX idx_inbox_notifications_user_id_template_id_targets ON inbox_notifi
CREATE INDEX idx_notification_messages_status ON notification_messages USING btree (status);
+CREATE INDEX idx_oauth2_provider_app_secrets_app_owner_user_id ON oauth2_provider_app_secrets USING btree (app_owner_user_id);
+
CREATE INDEX idx_oauth2_provider_device_codes_cleanup ON oauth2_provider_device_codes USING btree (expires_at);
CREATE INDEX idx_oauth2_provider_device_codes_client_id ON oauth2_provider_device_codes USING btree (client_id);
diff --git a/coderd/database/migrations/000359_allow_empty_redirect_uris_for_client_credentials.down.sql b/coderd/database/migrations/000359_allow_empty_redirect_uris_for_client_credentials.down.sql
new file mode 100644
index 0000000000000..8deac0a0524b8
--- /dev/null
+++ b/coderd/database/migrations/000359_allow_empty_redirect_uris_for_client_credentials.down.sql
@@ -0,0 +1,16 @@
+-- Restore the original constraint that requires at least one redirect URI
+-- This will fail if there are existing client credentials applications with empty redirect URIs
+ALTER TABLE oauth2_provider_apps
+ DROP CONSTRAINT IF EXISTS redirect_uris_not_empty_unless_client_credentials;
+
+ALTER TABLE oauth2_provider_apps
+ ADD CONSTRAINT redirect_uris_not_empty CHECK (cardinality(redirect_uris) > 0);
+
+COMMENT ON COLUMN oauth2_provider_apps.redirect_uris IS 'RFC 6749 compliant list of valid redirect URIs for the application';
+
+-- Remove the denormalized app_owner_user_id column
+DROP INDEX IF EXISTS idx_oauth2_provider_app_secrets_app_owner_user_id;
+
+ALTER TABLE oauth2_provider_app_secrets
+ DROP COLUMN IF EXISTS app_owner_user_id;
+
diff --git a/coderd/database/migrations/000359_allow_empty_redirect_uris_for_client_credentials.up.sql b/coderd/database/migrations/000359_allow_empty_redirect_uris_for_client_credentials.up.sql
new file mode 100644
index 0000000000000..69c71f31a4272
--- /dev/null
+++ b/coderd/database/migrations/000359_allow_empty_redirect_uris_for_client_credentials.up.sql
@@ -0,0 +1,31 @@
+-- Allow empty redirect URIs for client credentials applications
+-- Client credentials flow doesn't require redirect URIs (RFC 6749 Section 4.4)
+-- This replaces the simple constraint with a more sophisticated one
+ALTER TABLE oauth2_provider_apps
+ DROP CONSTRAINT IF EXISTS redirect_uris_not_empty;
+
+-- Add a more sophisticated constraint that allows empty redirect URIs only for client credentials applications
+-- For client credentials applications (grant_types = ['client_credentials']), redirect URIs can be empty
+-- For all other grant types, at least one redirect URI is required
+ALTER TABLE oauth2_provider_apps
+ ADD CONSTRAINT redirect_uris_not_empty_unless_client_credentials CHECK ((grant_types = ARRAY['client_credentials'::text] AND cardinality(redirect_uris) >= 0) OR (grant_types != ARRAY['client_credentials'::text] AND cardinality(redirect_uris) > 0));
+
+COMMENT ON COLUMN oauth2_provider_apps.redirect_uris IS 'RFC 6749 compliant list of valid redirect URIs for the application. May be empty for client credentials applications.';
+
+-- Add app_owner_user_id column to oauth2_provider_app_secrets for denormalization
+-- This avoids N+1 queries when authorizing secret operations
+ALTER TABLE oauth2_provider_app_secrets
+ ADD COLUMN app_owner_user_id UUID NULL;
+
+-- Populate the new column with existing data from parent apps
+UPDATE oauth2_provider_app_secrets
+SET app_owner_user_id = oauth2_provider_apps.user_id
+FROM oauth2_provider_apps
+WHERE oauth2_provider_app_secrets.app_id = oauth2_provider_apps.id;
+
+-- Add index for efficient authorization queries
+CREATE INDEX IF NOT EXISTS idx_oauth2_provider_app_secrets_app_owner_user_id
+ ON oauth2_provider_app_secrets(app_owner_user_id);
+
+COMMENT ON COLUMN oauth2_provider_app_secrets.app_owner_user_id IS 'Denormalized owner user ID from parent app for efficient authorization without N+1 queries';
+
diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go
index cc87fe1cd2f7c..4002a0339e64c 100644
--- a/coderd/database/modelmethods.go
+++ b/coderd/database/modelmethods.go
@@ -404,11 +404,17 @@ func (t OAuth2ProviderAppToken) RBACObject() rbac.Object {
return rbac.ResourceOauth2AppCodeToken.WithOwner(t.UserID.String()).WithID(t.ID)
}
-func (OAuth2ProviderAppSecret) RBACObject() rbac.Object {
- return rbac.ResourceOauth2AppSecret
+func (s OAuth2ProviderAppSecret) RBACObject() rbac.Object {
+ if s.AppOwnerUserID.Valid {
+ return rbac.ResourceOauth2AppSecret.WithID(s.ID).WithOwner(s.AppOwnerUserID.UUID.String())
+ }
+ return rbac.ResourceOauth2AppSecret.WithID(s.ID)
}
-func (OAuth2ProviderApp) RBACObject() rbac.Object {
+func (app OAuth2ProviderApp) RBACObject() rbac.Object {
+ if app.UserID.Valid {
+ return rbac.ResourceOauth2App.WithOwner(app.UserID.UUID.String())
+ }
return rbac.ResourceOauth2App
}
@@ -416,6 +422,34 @@ func (a GetOAuth2ProviderAppsByUserIDRow) RBACObject() rbac.Object {
return a.OAuth2ProviderApp.RBACObject()
}
+func (a GetOAuth2ProviderAppByIDRow) RBACObject() rbac.Object {
+ if a.UserID.Valid {
+ return rbac.ResourceOauth2App.WithOwner(a.UserID.UUID.String())
+ }
+ return rbac.ResourceOauth2App
+}
+
+func (a GetOAuth2ProviderAppsRow) RBACObject() rbac.Object {
+ if a.UserID.Valid {
+ return rbac.ResourceOauth2App.WithOwner(a.UserID.UUID.String())
+ }
+ return rbac.ResourceOauth2App
+}
+
+func (params InsertOAuth2ProviderAppParams) RBACObject() rbac.Object {
+ if params.UserID.Valid {
+ return rbac.ResourceOauth2App.WithOwner(params.UserID.UUID.String())
+ }
+ return rbac.ResourceOauth2App
+}
+
+func (a GetOAuth2ProviderAppsByOwnerIDRow) RBACObject() rbac.Object {
+ if a.UserID.Valid {
+ return rbac.ResourceOauth2App.WithOwner(a.UserID.UUID.String())
+ }
+ return rbac.ResourceOauth2App
+}
+
func (d OAuth2ProviderDeviceCode) RBACObject() rbac.Object {
// Device codes are similar to OAuth2 app code tokens
if d.UserID.Valid {
diff --git a/coderd/database/models.go b/coderd/database/models.go
index 900fa3aaa89c9..031863a3007ee 100644
--- a/coderd/database/models.go
+++ b/coderd/database/models.go
@@ -3274,7 +3274,7 @@ type OAuth2ProviderApp struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Name string `db:"name" json:"name"`
Icon string `db:"icon" json:"icon"`
- // RFC 6749 compliant list of valid redirect URIs for the application
+ // RFC 6749 compliant list of valid redirect URIs for the application. May be empty for client credentials applications.
RedirectUris []string `db:"redirect_uris" json:"redirect_uris"`
// OAuth2 client type: confidential or public
ClientType sql.NullString `db:"client_type" json:"client_type"`
@@ -3343,6 +3343,8 @@ type OAuth2ProviderAppSecret struct {
DisplaySecret string `db:"display_secret" json:"display_secret"`
AppID uuid.UUID `db:"app_id" json:"app_id"`
SecretPrefix []byte `db:"secret_prefix" json:"secret_prefix"`
+ // Denormalized owner user ID from parent app for efficient authorization without N+1 queries
+ AppOwnerUserID uuid.NullUUID `db:"app_owner_user_id" json:"app_owner_user_id"`
}
type OAuth2ProviderAppToken struct {
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index 33d0d73f42b05..d587dc9798afa 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -234,7 +234,7 @@ type sqlcQuerier interface {
GetOAuth2GithubDefaultEligible(ctx context.Context) (bool, error)
// RFC 7591/7592 Dynamic Client Registration queries
GetOAuth2ProviderAppByClientID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error)
- GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error)
+ GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (GetOAuth2ProviderAppByIDRow, error)
GetOAuth2ProviderAppByRegistrationToken(ctx context.Context, registrationAccessToken sql.NullString) (OAuth2ProviderApp, error)
GetOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppCode, error)
GetOAuth2ProviderAppCodeByPrefix(ctx context.Context, secretPrefix []byte) (OAuth2ProviderAppCode, error)
@@ -243,7 +243,8 @@ type sqlcQuerier interface {
GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, appID uuid.UUID) ([]OAuth2ProviderAppSecret, error)
GetOAuth2ProviderAppTokenByAPIKeyID(ctx context.Context, apiKeyID string) (OAuth2ProviderAppToken, error)
GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, hashPrefix []byte) (OAuth2ProviderAppToken, error)
- GetOAuth2ProviderApps(ctx context.Context) ([]OAuth2ProviderApp, error)
+ GetOAuth2ProviderApps(ctx context.Context) ([]GetOAuth2ProviderAppsRow, error)
+ GetOAuth2ProviderAppsByOwnerID(ctx context.Context, userID uuid.NullUUID) ([]GetOAuth2ProviderAppsByOwnerIDRow, error)
GetOAuth2ProviderAppsByUserID(ctx context.Context, userID uuid.UUID) ([]GetOAuth2ProviderAppsByUserIDRow, error)
GetOAuth2ProviderDeviceCodeByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderDeviceCode, error)
GetOAuth2ProviderDeviceCodeByPrefix(ctx context.Context, deviceCodePrefix string) (OAuth2ProviderDeviceCode, error)
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index d9249b84622b7..db103c811b986 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -5565,12 +5565,49 @@ func (q *sqlQuerier) GetOAuth2ProviderAppByClientID(ctx context.Context, id uuid
}
const getOAuth2ProviderAppByID = `-- name: GetOAuth2ProviderAppByID :one
-SELECT id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, policy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id FROM oauth2_provider_apps WHERE id = $1
+SELECT
+ oauth2_provider_apps.id, oauth2_provider_apps.created_at, oauth2_provider_apps.updated_at, oauth2_provider_apps.name, oauth2_provider_apps.icon, oauth2_provider_apps.redirect_uris, oauth2_provider_apps.client_type, oauth2_provider_apps.dynamically_registered, oauth2_provider_apps.client_id_issued_at, oauth2_provider_apps.client_secret_expires_at, oauth2_provider_apps.grant_types, oauth2_provider_apps.response_types, oauth2_provider_apps.token_endpoint_auth_method, oauth2_provider_apps.scope, oauth2_provider_apps.contacts, oauth2_provider_apps.client_uri, oauth2_provider_apps.logo_uri, oauth2_provider_apps.tos_uri, oauth2_provider_apps.policy_uri, oauth2_provider_apps.jwks_uri, oauth2_provider_apps.jwks, oauth2_provider_apps.software_id, oauth2_provider_apps.software_version, oauth2_provider_apps.registration_access_token, oauth2_provider_apps.registration_client_uri, oauth2_provider_apps.user_id,
+ users.username,
+ users.email
+FROM oauth2_provider_apps
+LEFT JOIN users ON oauth2_provider_apps.user_id = users.id
+WHERE oauth2_provider_apps.id = $1
`
-func (q *sqlQuerier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) {
+type GetOAuth2ProviderAppByIDRow struct {
+ ID uuid.UUID `db:"id" json:"id"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+ Name string `db:"name" json:"name"`
+ Icon string `db:"icon" json:"icon"`
+ RedirectUris []string `db:"redirect_uris" json:"redirect_uris"`
+ ClientType sql.NullString `db:"client_type" json:"client_type"`
+ DynamicallyRegistered sql.NullBool `db:"dynamically_registered" json:"dynamically_registered"`
+ ClientIDIssuedAt sql.NullTime `db:"client_id_issued_at" json:"client_id_issued_at"`
+ ClientSecretExpiresAt sql.NullTime `db:"client_secret_expires_at" json:"client_secret_expires_at"`
+ GrantTypes []string `db:"grant_types" json:"grant_types"`
+ ResponseTypes []string `db:"response_types" json:"response_types"`
+ TokenEndpointAuthMethod sql.NullString `db:"token_endpoint_auth_method" json:"token_endpoint_auth_method"`
+ Scope sql.NullString `db:"scope" json:"scope"`
+ Contacts []string `db:"contacts" json:"contacts"`
+ ClientUri sql.NullString `db:"client_uri" json:"client_uri"`
+ LogoUri sql.NullString `db:"logo_uri" json:"logo_uri"`
+ TosUri sql.NullString `db:"tos_uri" json:"tos_uri"`
+ PolicyUri sql.NullString `db:"policy_uri" json:"policy_uri"`
+ JwksUri sql.NullString `db:"jwks_uri" json:"jwks_uri"`
+ Jwks pqtype.NullRawMessage `db:"jwks" json:"jwks"`
+ SoftwareID sql.NullString `db:"software_id" json:"software_id"`
+ SoftwareVersion sql.NullString `db:"software_version" json:"software_version"`
+ RegistrationAccessToken sql.NullString `db:"registration_access_token" json:"registration_access_token"`
+ RegistrationClientUri sql.NullString `db:"registration_client_uri" json:"registration_client_uri"`
+ UserID uuid.NullUUID `db:"user_id" json:"user_id"`
+ Username sql.NullString `db:"username" json:"username"`
+ Email sql.NullString `db:"email" json:"email"`
+}
+
+func (q *sqlQuerier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (GetOAuth2ProviderAppByIDRow, error) {
row := q.db.QueryRowContext(ctx, getOAuth2ProviderAppByID, id)
- var i OAuth2ProviderApp
+ var i GetOAuth2ProviderAppByIDRow
err := row.Scan(
&i.ID,
&i.CreatedAt,
@@ -5598,6 +5635,8 @@ func (q *sqlQuerier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID)
&i.RegistrationAccessToken,
&i.RegistrationClientUri,
&i.UserID,
+ &i.Username,
+ &i.Email,
)
return i, err
}
@@ -5685,7 +5724,7 @@ func (q *sqlQuerier) GetOAuth2ProviderAppCodeByPrefix(ctx context.Context, secre
}
const getOAuth2ProviderAppSecretByID = `-- name: GetOAuth2ProviderAppSecretByID :one
-SELECT id, created_at, last_used_at, hashed_secret, display_secret, app_id, secret_prefix FROM oauth2_provider_app_secrets WHERE id = $1
+SELECT id, created_at, last_used_at, hashed_secret, display_secret, app_id, secret_prefix, app_owner_user_id FROM oauth2_provider_app_secrets WHERE id = $1
`
func (q *sqlQuerier) GetOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppSecret, error) {
@@ -5699,12 +5738,13 @@ func (q *sqlQuerier) GetOAuth2ProviderAppSecretByID(ctx context.Context, id uuid
&i.DisplaySecret,
&i.AppID,
&i.SecretPrefix,
+ &i.AppOwnerUserID,
)
return i, err
}
const getOAuth2ProviderAppSecretByPrefix = `-- name: GetOAuth2ProviderAppSecretByPrefix :one
-SELECT id, created_at, last_used_at, hashed_secret, display_secret, app_id, secret_prefix FROM oauth2_provider_app_secrets WHERE secret_prefix = $1
+SELECT id, created_at, last_used_at, hashed_secret, display_secret, app_id, secret_prefix, app_owner_user_id FROM oauth2_provider_app_secrets WHERE secret_prefix = $1
`
func (q *sqlQuerier) GetOAuth2ProviderAppSecretByPrefix(ctx context.Context, secretPrefix []byte) (OAuth2ProviderAppSecret, error) {
@@ -5718,12 +5758,13 @@ func (q *sqlQuerier) GetOAuth2ProviderAppSecretByPrefix(ctx context.Context, sec
&i.DisplaySecret,
&i.AppID,
&i.SecretPrefix,
+ &i.AppOwnerUserID,
)
return i, err
}
const getOAuth2ProviderAppSecretsByAppID = `-- name: GetOAuth2ProviderAppSecretsByAppID :many
-SELECT id, created_at, last_used_at, hashed_secret, display_secret, app_id, secret_prefix FROM oauth2_provider_app_secrets WHERE app_id = $1 ORDER BY (created_at, id) ASC
+SELECT id, created_at, last_used_at, hashed_secret, display_secret, app_id, secret_prefix, app_owner_user_id FROM oauth2_provider_app_secrets WHERE app_id = $1 ORDER BY (created_at, id) ASC
`
func (q *sqlQuerier) GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, appID uuid.UUID) ([]OAuth2ProviderAppSecret, error) {
@@ -5743,6 +5784,7 @@ func (q *sqlQuerier) GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, app
&i.DisplaySecret,
&i.AppID,
&i.SecretPrefix,
+ &i.AppOwnerUserID,
); err != nil {
return nil, err
}
@@ -5800,18 +5842,149 @@ func (q *sqlQuerier) GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, hash
}
const getOAuth2ProviderApps = `-- name: GetOAuth2ProviderApps :many
-SELECT id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, policy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id FROM oauth2_provider_apps ORDER BY (name, id) ASC
+SELECT
+ oauth2_provider_apps.id, oauth2_provider_apps.created_at, oauth2_provider_apps.updated_at, oauth2_provider_apps.name, oauth2_provider_apps.icon, oauth2_provider_apps.redirect_uris, oauth2_provider_apps.client_type, oauth2_provider_apps.dynamically_registered, oauth2_provider_apps.client_id_issued_at, oauth2_provider_apps.client_secret_expires_at, oauth2_provider_apps.grant_types, oauth2_provider_apps.response_types, oauth2_provider_apps.token_endpoint_auth_method, oauth2_provider_apps.scope, oauth2_provider_apps.contacts, oauth2_provider_apps.client_uri, oauth2_provider_apps.logo_uri, oauth2_provider_apps.tos_uri, oauth2_provider_apps.policy_uri, oauth2_provider_apps.jwks_uri, oauth2_provider_apps.jwks, oauth2_provider_apps.software_id, oauth2_provider_apps.software_version, oauth2_provider_apps.registration_access_token, oauth2_provider_apps.registration_client_uri, oauth2_provider_apps.user_id,
+ users.username,
+ users.email
+FROM oauth2_provider_apps
+LEFT JOIN users ON oauth2_provider_apps.user_id = users.id
+ORDER BY (oauth2_provider_apps.name, oauth2_provider_apps.id) ASC
`
-func (q *sqlQuerier) GetOAuth2ProviderApps(ctx context.Context) ([]OAuth2ProviderApp, error) {
+type GetOAuth2ProviderAppsRow struct {
+ ID uuid.UUID `db:"id" json:"id"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+ Name string `db:"name" json:"name"`
+ Icon string `db:"icon" json:"icon"`
+ RedirectUris []string `db:"redirect_uris" json:"redirect_uris"`
+ ClientType sql.NullString `db:"client_type" json:"client_type"`
+ DynamicallyRegistered sql.NullBool `db:"dynamically_registered" json:"dynamically_registered"`
+ ClientIDIssuedAt sql.NullTime `db:"client_id_issued_at" json:"client_id_issued_at"`
+ ClientSecretExpiresAt sql.NullTime `db:"client_secret_expires_at" json:"client_secret_expires_at"`
+ GrantTypes []string `db:"grant_types" json:"grant_types"`
+ ResponseTypes []string `db:"response_types" json:"response_types"`
+ TokenEndpointAuthMethod sql.NullString `db:"token_endpoint_auth_method" json:"token_endpoint_auth_method"`
+ Scope sql.NullString `db:"scope" json:"scope"`
+ Contacts []string `db:"contacts" json:"contacts"`
+ ClientUri sql.NullString `db:"client_uri" json:"client_uri"`
+ LogoUri sql.NullString `db:"logo_uri" json:"logo_uri"`
+ TosUri sql.NullString `db:"tos_uri" json:"tos_uri"`
+ PolicyUri sql.NullString `db:"policy_uri" json:"policy_uri"`
+ JwksUri sql.NullString `db:"jwks_uri" json:"jwks_uri"`
+ Jwks pqtype.NullRawMessage `db:"jwks" json:"jwks"`
+ SoftwareID sql.NullString `db:"software_id" json:"software_id"`
+ SoftwareVersion sql.NullString `db:"software_version" json:"software_version"`
+ RegistrationAccessToken sql.NullString `db:"registration_access_token" json:"registration_access_token"`
+ RegistrationClientUri sql.NullString `db:"registration_client_uri" json:"registration_client_uri"`
+ UserID uuid.NullUUID `db:"user_id" json:"user_id"`
+ Username sql.NullString `db:"username" json:"username"`
+ Email sql.NullString `db:"email" json:"email"`
+}
+
+func (q *sqlQuerier) GetOAuth2ProviderApps(ctx context.Context) ([]GetOAuth2ProviderAppsRow, error) {
rows, err := q.db.QueryContext(ctx, getOAuth2ProviderApps)
if err != nil {
return nil, err
}
defer rows.Close()
- var items []OAuth2ProviderApp
+ var items []GetOAuth2ProviderAppsRow
+ for rows.Next() {
+ var i GetOAuth2ProviderAppsRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Name,
+ &i.Icon,
+ pq.Array(&i.RedirectUris),
+ &i.ClientType,
+ &i.DynamicallyRegistered,
+ &i.ClientIDIssuedAt,
+ &i.ClientSecretExpiresAt,
+ pq.Array(&i.GrantTypes),
+ pq.Array(&i.ResponseTypes),
+ &i.TokenEndpointAuthMethod,
+ &i.Scope,
+ pq.Array(&i.Contacts),
+ &i.ClientUri,
+ &i.LogoUri,
+ &i.TosUri,
+ &i.PolicyUri,
+ &i.JwksUri,
+ &i.Jwks,
+ &i.SoftwareID,
+ &i.SoftwareVersion,
+ &i.RegistrationAccessToken,
+ &i.RegistrationClientUri,
+ &i.UserID,
+ &i.Username,
+ &i.Email,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getOAuth2ProviderAppsByOwnerID = `-- name: GetOAuth2ProviderAppsByOwnerID :many
+SELECT
+ oauth2_provider_apps.id, oauth2_provider_apps.created_at, oauth2_provider_apps.updated_at, oauth2_provider_apps.name, oauth2_provider_apps.icon, oauth2_provider_apps.redirect_uris, oauth2_provider_apps.client_type, oauth2_provider_apps.dynamically_registered, oauth2_provider_apps.client_id_issued_at, oauth2_provider_apps.client_secret_expires_at, oauth2_provider_apps.grant_types, oauth2_provider_apps.response_types, oauth2_provider_apps.token_endpoint_auth_method, oauth2_provider_apps.scope, oauth2_provider_apps.contacts, oauth2_provider_apps.client_uri, oauth2_provider_apps.logo_uri, oauth2_provider_apps.tos_uri, oauth2_provider_apps.policy_uri, oauth2_provider_apps.jwks_uri, oauth2_provider_apps.jwks, oauth2_provider_apps.software_id, oauth2_provider_apps.software_version, oauth2_provider_apps.registration_access_token, oauth2_provider_apps.registration_client_uri, oauth2_provider_apps.user_id,
+ users.username,
+ users.email
+FROM oauth2_provider_apps
+LEFT JOIN users ON oauth2_provider_apps.user_id = users.id
+WHERE oauth2_provider_apps.user_id = $1
+ORDER BY (oauth2_provider_apps.name, oauth2_provider_apps.id) ASC
+`
+
+type GetOAuth2ProviderAppsByOwnerIDRow struct {
+ ID uuid.UUID `db:"id" json:"id"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+ Name string `db:"name" json:"name"`
+ Icon string `db:"icon" json:"icon"`
+ RedirectUris []string `db:"redirect_uris" json:"redirect_uris"`
+ ClientType sql.NullString `db:"client_type" json:"client_type"`
+ DynamicallyRegistered sql.NullBool `db:"dynamically_registered" json:"dynamically_registered"`
+ ClientIDIssuedAt sql.NullTime `db:"client_id_issued_at" json:"client_id_issued_at"`
+ ClientSecretExpiresAt sql.NullTime `db:"client_secret_expires_at" json:"client_secret_expires_at"`
+ GrantTypes []string `db:"grant_types" json:"grant_types"`
+ ResponseTypes []string `db:"response_types" json:"response_types"`
+ TokenEndpointAuthMethod sql.NullString `db:"token_endpoint_auth_method" json:"token_endpoint_auth_method"`
+ Scope sql.NullString `db:"scope" json:"scope"`
+ Contacts []string `db:"contacts" json:"contacts"`
+ ClientUri sql.NullString `db:"client_uri" json:"client_uri"`
+ LogoUri sql.NullString `db:"logo_uri" json:"logo_uri"`
+ TosUri sql.NullString `db:"tos_uri" json:"tos_uri"`
+ PolicyUri sql.NullString `db:"policy_uri" json:"policy_uri"`
+ JwksUri sql.NullString `db:"jwks_uri" json:"jwks_uri"`
+ Jwks pqtype.NullRawMessage `db:"jwks" json:"jwks"`
+ SoftwareID sql.NullString `db:"software_id" json:"software_id"`
+ SoftwareVersion sql.NullString `db:"software_version" json:"software_version"`
+ RegistrationAccessToken sql.NullString `db:"registration_access_token" json:"registration_access_token"`
+ RegistrationClientUri sql.NullString `db:"registration_client_uri" json:"registration_client_uri"`
+ UserID uuid.NullUUID `db:"user_id" json:"user_id"`
+ Username sql.NullString `db:"username" json:"username"`
+ Email sql.NullString `db:"email" json:"email"`
+}
+
+func (q *sqlQuerier) GetOAuth2ProviderAppsByOwnerID(ctx context.Context, userID uuid.NullUUID) ([]GetOAuth2ProviderAppsByOwnerIDRow, error) {
+ rows, err := q.db.QueryContext(ctx, getOAuth2ProviderAppsByOwnerID, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetOAuth2ProviderAppsByOwnerIDRow
for rows.Next() {
- var i OAuth2ProviderApp
+ var i GetOAuth2ProviderAppsByOwnerIDRow
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
@@ -5839,6 +6012,8 @@ func (q *sqlQuerier) GetOAuth2ProviderApps(ctx context.Context) ([]OAuth2Provide
&i.RegistrationAccessToken,
&i.RegistrationClientUri,
&i.UserID,
+ &i.Username,
+ &i.Email,
); err != nil {
return nil, err
}
@@ -6269,24 +6444,27 @@ INSERT INTO oauth2_provider_app_secrets (
secret_prefix,
hashed_secret,
display_secret,
- app_id
+ app_id,
+ app_owner_user_id
) VALUES(
$1,
$2,
$3,
$4,
$5,
- $6
-) RETURNING id, created_at, last_used_at, hashed_secret, display_secret, app_id, secret_prefix
+ $6,
+ $7
+) RETURNING id, created_at, last_used_at, hashed_secret, display_secret, app_id, secret_prefix, app_owner_user_id
`
type InsertOAuth2ProviderAppSecretParams struct {
- ID uuid.UUID `db:"id" json:"id"`
- CreatedAt time.Time `db:"created_at" json:"created_at"`
- SecretPrefix []byte `db:"secret_prefix" json:"secret_prefix"`
- HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"`
- DisplaySecret string `db:"display_secret" json:"display_secret"`
- AppID uuid.UUID `db:"app_id" json:"app_id"`
+ ID uuid.UUID `db:"id" json:"id"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ SecretPrefix []byte `db:"secret_prefix" json:"secret_prefix"`
+ HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"`
+ DisplaySecret string `db:"display_secret" json:"display_secret"`
+ AppID uuid.UUID `db:"app_id" json:"app_id"`
+ AppOwnerUserID uuid.NullUUID `db:"app_owner_user_id" json:"app_owner_user_id"`
}
func (q *sqlQuerier) InsertOAuth2ProviderAppSecret(ctx context.Context, arg InsertOAuth2ProviderAppSecretParams) (OAuth2ProviderAppSecret, error) {
@@ -6297,6 +6475,7 @@ func (q *sqlQuerier) InsertOAuth2ProviderAppSecret(ctx context.Context, arg Inse
arg.HashedSecret,
arg.DisplaySecret,
arg.AppID,
+ arg.AppOwnerUserID,
)
var i OAuth2ProviderAppSecret
err := row.Scan(
@@ -6307,6 +6486,7 @@ func (q *sqlQuerier) InsertOAuth2ProviderAppSecret(ctx context.Context, arg Inse
&i.DisplaySecret,
&i.AppID,
&i.SecretPrefix,
+ &i.AppOwnerUserID,
)
return i, err
}
@@ -6666,7 +6846,7 @@ func (q *sqlQuerier) UpdateOAuth2ProviderAppByID(ctx context.Context, arg Update
const updateOAuth2ProviderAppSecretByID = `-- name: UpdateOAuth2ProviderAppSecretByID :one
UPDATE oauth2_provider_app_secrets SET
last_used_at = $2
-WHERE id = $1 RETURNING id, created_at, last_used_at, hashed_secret, display_secret, app_id, secret_prefix
+WHERE id = $1 RETURNING id, created_at, last_used_at, hashed_secret, display_secret, app_id, secret_prefix, app_owner_user_id
`
type UpdateOAuth2ProviderAppSecretByIDParams struct {
@@ -6685,6 +6865,7 @@ func (q *sqlQuerier) UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg
&i.DisplaySecret,
&i.AppID,
&i.SecretPrefix,
+ &i.AppOwnerUserID,
)
return i, err
}
diff --git a/coderd/database/queries/oauth2.sql b/coderd/database/queries/oauth2.sql
index b58b5f198b774..f262596c27108 100644
--- a/coderd/database/queries/oauth2.sql
+++ b/coderd/database/queries/oauth2.sql
@@ -1,8 +1,30 @@
-- name: GetOAuth2ProviderApps :many
-SELECT * FROM oauth2_provider_apps ORDER BY (name, id) ASC;
+SELECT
+ oauth2_provider_apps.*,
+ users.username,
+ users.email
+FROM oauth2_provider_apps
+LEFT JOIN users ON oauth2_provider_apps.user_id = users.id
+ORDER BY (oauth2_provider_apps.name, oauth2_provider_apps.id) ASC;
-- name: GetOAuth2ProviderAppByID :one
-SELECT * FROM oauth2_provider_apps WHERE id = $1;
+SELECT
+ oauth2_provider_apps.*,
+ users.username,
+ users.email
+FROM oauth2_provider_apps
+LEFT JOIN users ON oauth2_provider_apps.user_id = users.id
+WHERE oauth2_provider_apps.id = $1;
+
+-- name: GetOAuth2ProviderAppsByOwnerID :many
+SELECT
+ oauth2_provider_apps.*,
+ users.username,
+ users.email
+FROM oauth2_provider_apps
+LEFT JOIN users ON oauth2_provider_apps.user_id = users.id
+WHERE oauth2_provider_apps.user_id = $1
+ORDER BY (oauth2_provider_apps.name, oauth2_provider_apps.id) ASC;
-- name: InsertOAuth2ProviderApp :one
INSERT INTO oauth2_provider_apps (
@@ -104,14 +126,16 @@ INSERT INTO oauth2_provider_app_secrets (
secret_prefix,
hashed_secret,
display_secret,
- app_id
+ app_id,
+ app_owner_user_id
) VALUES(
$1,
$2,
$3,
$4,
$5,
- $6
+ $6,
+ $7
) RETURNING *;
-- name: UpdateOAuth2ProviderAppSecretByID :one
diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go
index d569a6ad0067a..7e1be737ae673 100644
--- a/coderd/httpmw/oauth2.go
+++ b/coderd/httpmw/oauth2.go
@@ -195,8 +195,45 @@ type (
)
// OAuth2ProviderApp returns the OAuth2 app from the ExtractOAuth2ProviderAppParam handler.
+// This returns the base OAuth2ProviderApp type for backward compatibility with existing code.
func OAuth2ProviderApp(r *http.Request) database.OAuth2ProviderApp {
- app, ok := r.Context().Value(oauth2ProviderAppParamContextKey{}).(database.OAuth2ProviderApp)
+ appRow := OAuth2ProviderAppRow(r)
+
+ // Convert to base type for backward compatibility
+ return database.OAuth2ProviderApp{
+ ID: appRow.ID,
+ CreatedAt: appRow.CreatedAt,
+ UpdatedAt: appRow.UpdatedAt,
+ Name: appRow.Name,
+ Icon: appRow.Icon,
+ RedirectUris: appRow.RedirectUris,
+ ClientType: appRow.ClientType,
+ DynamicallyRegistered: appRow.DynamicallyRegistered,
+ ClientIDIssuedAt: appRow.ClientIDIssuedAt,
+ ClientSecretExpiresAt: appRow.ClientSecretExpiresAt,
+ GrantTypes: appRow.GrantTypes,
+ ResponseTypes: appRow.ResponseTypes,
+ TokenEndpointAuthMethod: appRow.TokenEndpointAuthMethod,
+ Scope: appRow.Scope,
+ Contacts: appRow.Contacts,
+ ClientUri: appRow.ClientUri,
+ LogoUri: appRow.LogoUri,
+ TosUri: appRow.TosUri,
+ PolicyUri: appRow.PolicyUri,
+ JwksUri: appRow.JwksUri,
+ Jwks: appRow.Jwks,
+ SoftwareID: appRow.SoftwareID,
+ SoftwareVersion: appRow.SoftwareVersion,
+ RegistrationAccessToken: appRow.RegistrationAccessToken,
+ RegistrationClientUri: appRow.RegistrationClientUri,
+ UserID: appRow.UserID,
+ }
+}
+
+// OAuth2ProviderAppRow returns the full OAuth2 app row from the ExtractOAuth2ProviderAppParam handler.
+// This includes username and email fields for API responses that need them.
+func OAuth2ProviderAppRow(r *http.Request) database.GetOAuth2ProviderAppByIDRow {
+ app, ok := r.Context().Value(oauth2ProviderAppParamContextKey{}).(database.GetOAuth2ProviderAppByIDRow)
if !ok {
panic("developer error: oauth2 app param middleware not provided")
}
@@ -322,6 +359,7 @@ func extractOAuth2ProviderAppBase(db database.Store, errWriter errorWriter) func
})
return
}
+
ctx = context.WithValue(ctx, oauth2ProviderAppParamContextKey{}, app)
next.ServeHTTP(rw, r.WithContext(ctx))
})
diff --git a/coderd/oauth2.go b/coderd/oauth2.go
index 94e1224e01f75..6701b329187dc 100644
--- a/coderd/oauth2.go
+++ b/coderd/oauth2.go
@@ -12,10 +12,11 @@ import (
// @Produce json
// @Tags Enterprise
// @Param user_id query string false "Filter by applications authorized for a user"
+// @Param owner_id query string false "Filter by applications owned by a user"
// @Success 200 {array} codersdk.OAuth2ProviderApp
// @Router /oauth2-provider/apps [get]
func (api *API) oAuth2ProviderApps() http.HandlerFunc {
- return oauth2provider.ListApps(api.Database, api.AccessURL)
+ return oauth2provider.ListApps(api.Database, api.AccessURL, api.Logger)
}
// @Summary Get OAuth2 application.
@@ -77,7 +78,7 @@ func (api *API) deleteOAuth2ProviderApp() http.HandlerFunc {
// @Success 200 {array} codersdk.OAuth2ProviderAppSecret
// @Router /oauth2-provider/apps/{app}/secrets [get]
func (api *API) oAuth2ProviderAppSecrets() http.HandlerFunc {
- return oauth2provider.GetAppSecrets(api.Database)
+ return oauth2provider.GetAppSecrets(api.Database, api.Logger)
}
// @Summary Create OAuth2 application secret.
diff --git a/coderd/oauth2provider/app_secrets.go b/coderd/oauth2provider/app_secrets.go
index 5549ece4266f2..974025240737e 100644
--- a/coderd/oauth2provider/app_secrets.go
+++ b/coderd/oauth2provider/app_secrets.go
@@ -16,12 +16,13 @@ import (
)
// GetAppSecrets returns an http.HandlerFunc that handles GET /oauth2-provider/apps/{app}/secrets
-func GetAppSecrets(db database.Store) http.HandlerFunc {
+func GetAppSecrets(db database.Store, logger slog.Logger) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
app := httpmw.OAuth2ProviderApp(r)
dbSecrets, err := db.GetOAuth2ProviderAppSecretsByAppID(ctx, app.ID)
if err != nil {
+ logger.Error(ctx, "failed to get OAuth2 client secrets", slog.Error(err), slog.F("app_id", app.ID))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error getting OAuth2 client secrets.",
Detail: err.Error(),
@@ -56,6 +57,7 @@ func CreateAppSecret(db database.Store, auditor *audit.Auditor, logger slog.Logg
defer commitAudit()
secret, err := GenerateSecret()
if err != nil {
+ logger.Error(ctx, "failed to generate OAuth2 client secret", slog.Error(err), slog.F("app_id", app.ID))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to generate OAuth2 client secret.",
Detail: err.Error(),
@@ -70,10 +72,12 @@ func CreateAppSecret(db database.Store, auditor *audit.Auditor, logger slog.Logg
// DisplaySecret is the last six characters of the original unhashed secret.
// This is done so they can be differentiated and it matches how GitHub
// displays their client secrets.
- DisplaySecret: secret.Formatted[len(secret.Formatted)-6:],
- AppID: app.ID,
+ DisplaySecret: secret.Formatted[len(secret.Formatted)-6:],
+ AppID: app.ID,
+ AppOwnerUserID: app.UserID,
})
if err != nil {
+ logger.Error(ctx, "failed to create OAuth2 client secret", slog.Error(err), slog.F("app_id", app.ID))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error creating OAuth2 client secret.",
Detail: err.Error(),
@@ -105,6 +109,7 @@ func DeleteAppSecret(db database.Store, auditor *audit.Auditor, logger slog.Logg
defer commitAudit()
err := db.DeleteOAuth2ProviderAppSecretByID(ctx, secret.ID)
if err != nil {
+ logger.Error(ctx, "failed to delete OAuth2 client secret", slog.Error(err), slog.F("secret_id", secret.ID), slog.F("app_id", secret.AppID))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error deleting OAuth2 client secret.",
Detail: err.Error(),
diff --git a/coderd/oauth2provider/apps.go b/coderd/oauth2provider/apps.go
index 2533e33da77c1..8f3a3899370c5 100644
--- a/coderd/oauth2provider/apps.go
+++ b/coderd/oauth2provider/apps.go
@@ -18,37 +18,77 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
+ "github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
)
// ListApps returns an http.HandlerFunc that handles GET /oauth2-provider/apps
-func ListApps(db database.Store, accessURL *url.URL) http.HandlerFunc {
+func ListApps(db database.Store, accessURL *url.URL, logger slog.Logger) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
rawUserID := r.URL.Query().Get("user_id")
- if rawUserID == "" {
+ rawOwnerID := r.URL.Query().Get("owner_id")
+
+ // If neither filter is provided, return all apps
+ if rawUserID == "" && rawOwnerID == "" {
dbApps, err := db.GetOAuth2ProviderApps(ctx)
if err != nil {
- httpapi.InternalServerError(rw, err)
+ logger.Error(ctx, "failed to get OAuth2 provider apps", slog.Error(err))
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Internal error retrieving OAuth2 applications.",
+ })
return
}
- httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApps(accessURL, dbApps))
+ httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderAppsRows(accessURL, dbApps))
return
}
+ // Handle owner_id filter - apps created by the user
+ if rawOwnerID != "" {
+ ownerID, err := uuid.Parse(rawOwnerID)
+ if err != nil {
+ logger.Warn(ctx, "invalid owner UUID provided", slog.F("owner_id", rawOwnerID), slog.Error(err))
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "Invalid owner UUID",
+ Detail: fmt.Sprintf("queried owner_id=%q", rawOwnerID),
+ })
+ return
+ }
+
+ ownedApps, err := db.GetOAuth2ProviderAppsByOwnerID(ctx, uuid.NullUUID{
+ UUID: ownerID,
+ Valid: true,
+ })
+ if err != nil {
+ logger.Error(ctx, "failed to get OAuth2 provider apps by owner", slog.F("owner_id", ownerID), slog.Error(err))
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Internal error retrieving OAuth2 applications.",
+ })
+ return
+ }
+
+ httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderAppsByOwnerIDRows(accessURL, ownedApps))
+ return
+ }
+
+ // Handle user_id filter - apps the user has authorized (has tokens for)
userID, err := uuid.Parse(rawUserID)
if err != nil {
+ logger.Warn(ctx, "invalid user UUID provided", slog.F("user_id", rawUserID), slog.Error(err))
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid user UUID",
- Detail: fmt.Sprintf("queried user_id=%q", userID),
+ Detail: fmt.Sprintf("queried user_id=%q", rawUserID),
})
return
}
userApps, err := db.GetOAuth2ProviderAppsByUserID(ctx, userID)
if err != nil {
- httpapi.InternalServerError(rw, err)
+ logger.Error(ctx, "failed to get OAuth2 provider apps by user", slog.F("user_id", userID), slog.Error(err))
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "Internal error retrieving OAuth2 applications.",
+ })
return
}
@@ -64,8 +104,8 @@ func ListApps(db database.Store, accessURL *url.URL) http.HandlerFunc {
func GetApp(accessURL *url.URL) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
- app := httpmw.OAuth2ProviderApp(r)
- httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApp(accessURL, app))
+ appRow := httpmw.OAuth2ProviderAppRow(r)
+ httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderAppRow(accessURL, appRow))
}
}
@@ -89,6 +129,7 @@ func CreateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, lo
// Validate grant types and redirect URI requirements
if err := req.Validate(); err != nil {
+ logger.Warn(ctx, "invalid OAuth2 application request", slog.Error(err), slog.F("request", req))
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid OAuth2 application request.",
Detail: err.Error(),
@@ -143,9 +184,16 @@ func CreateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, lo
RegistrationClientUri: sql.NullString{},
})
if err != nil {
+ if rbac.IsUnauthorizedError(err) {
+ logger.Debug(ctx, "unauthorized to create OAuth2 application", slog.Error(err), slog.F("app_name", req.Name))
+ httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
+ Message: "You are not authorized to create this type of OAuth2 application.",
+ })
+ return
+ }
+ logger.Error(ctx, "failed to create OAuth2 application", slog.Error(err), slog.F("app_name", req.Name))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error creating OAuth2 application.",
- Detail: err.Error(),
})
return
}
@@ -176,6 +224,7 @@ func UpdateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, lo
// Validate the update request
if err := req.Validate(); err != nil {
+ logger.Warn(ctx, "invalid OAuth2 application update request", slog.Error(err), slog.F("app_id", app.ID), slog.F("request", req))
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid OAuth2 application update request.",
Detail: err.Error(),
@@ -213,9 +262,9 @@ func UpdateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, lo
SoftwareVersion: app.SoftwareVersion, // Keep existing value
})
if err != nil {
+ logger.Error(ctx, "failed to update OAuth2 application", slog.Error(err), slog.F("app_id", app.ID), slog.F("app_name", req.Name))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating OAuth2 application.",
- Detail: err.Error(),
})
return
}
@@ -241,9 +290,9 @@ func DeleteApp(db database.Store, auditor *audit.Auditor, logger slog.Logger) ht
defer commitAudit()
err := db.DeleteOAuth2ProviderAppByID(ctx, app.ID)
if err != nil {
+ logger.Error(ctx, "failed to delete OAuth2 application", slog.Error(err), slog.F("app_id", app.ID), slog.F("app_name", app.Name))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error deleting OAuth2 application.",
- Detail: err.Error(),
})
return
}
diff --git a/coderd/oauth2provider/provider_test.go b/coderd/oauth2provider/provider_test.go
index 33ce47ba6f2b8..143d26c5055e6 100644
--- a/coderd/oauth2provider/provider_test.go
+++ b/coderd/oauth2provider/provider_test.go
@@ -652,12 +652,12 @@ func TestOAuth2ProviderAppOperations(t *testing.T) {
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
- another, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
+ _, _ = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
ctx := testutil.Context(t, testutil.WaitLong)
// No apps yet.
- apps, err := another.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{})
+ apps, err := client.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{})
require.NoError(t, err)
require.Len(t, apps, 0)
@@ -669,7 +669,7 @@ func TestOAuth2ProviderAppOperations(t *testing.T) {
}
// Should get all the apps now.
- apps, err = another.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{})
+ apps, err = client.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{})
require.NoError(t, err)
require.Len(t, apps, 5)
require.Equal(t, expectedOrder, apps)
@@ -703,7 +703,7 @@ func TestOAuth2ProviderAppOperations(t *testing.T) {
require.Equal(t, expectedApps.Default.ID, newApp.ID)
// Should be able to get a single app.
- got, err := another.OAuth2ProviderApp(ctx, expectedApps.Default.ID)
+ got, err := client.OAuth2ProviderApp(ctx, expectedApps.Default.ID)
require.NoError(t, err)
require.Equal(t, newApp, got)
@@ -713,7 +713,7 @@ func TestOAuth2ProviderAppOperations(t *testing.T) {
require.NoError(t, err)
// Should show the new count.
- newApps, err := another.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{})
+ newApps, err := client.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{})
require.NoError(t, err)
require.Len(t, newApps, 4)
@@ -724,10 +724,10 @@ func TestOAuth2ProviderAppOperations(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
- another, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
+ _, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
ctx := testutil.Context(t, testutil.WaitLong)
_ = generateApps(ctx, t, client, "by-user")
- apps, err := another.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{
+ apps, err := client.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{
UserID: user.ID,
})
require.NoError(t, err)
@@ -770,3 +770,162 @@ func generateApps(ctx context.Context, t *testing.T, client *codersdk.Client, su
},
}
}
+
+// TestOAuth2ProviderAppNonAdminClientCredentials tests that non-admin users can create
+// client credentials OAuth2 apps (regression test for authorization bug).
+func TestOAuth2ProviderAppNonAdminClientCredentials(t *testing.T) {
+ t.Parallel()
+
+ client := coderdtest.New(t, nil)
+ owner := coderdtest.CreateFirstUser(t, client)
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ // Create a non-admin user (member role)
+ memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
+
+ // Test that member can create client credentials app
+ app, err := memberClient.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
+ Name: "member-client-credentials-app",
+ RedirectURIs: []string{}, // Empty for client credentials
+ GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials},
+ })
+ require.NoError(t, err)
+ require.Equal(t, "member-client-credentials-app", app.Name)
+ require.Equal(t, memberUser.ID, app.UserID)
+ require.Contains(t, app.GrantTypes, string(codersdk.OAuth2ProviderGrantTypeClientCredentials))
+
+ // Test that member can create a secret for their app
+ secret, err := memberClient.PostOAuth2ProviderAppSecret(ctx, app.ID)
+ require.NoError(t, err)
+ require.NotEmpty(t, secret.ClientSecretFull)
+
+ // Test that member can list secrets for their own app
+ secrets, err := memberClient.OAuth2ProviderAppSecrets(ctx, app.ID)
+ require.NoError(t, err)
+ require.Len(t, secrets, 1)
+ require.Equal(t, secret.ID, secrets[0].ID)
+
+ // Test that member can get their own app
+ retrievedApp, err := memberClient.OAuth2ProviderApp(ctx, app.ID)
+ require.NoError(t, err)
+ require.Equal(t, app.ID, retrievedApp.ID)
+ require.Equal(t, memberUser.ID, retrievedApp.UserID)
+
+ // Test that member can use client credentials flow
+ conf := clientcredentials.Config{
+ ClientID: retrievedApp.ID.String(),
+ ClientSecret: secret.ClientSecretFull,
+ TokenURL: memberClient.URL.String() + "/oauth2/token",
+ }
+
+ token, err := conf.Token(ctx)
+ require.NoError(t, err)
+ require.NotEmpty(t, token.AccessToken)
+ require.Equal(t, "Bearer", token.TokenType)
+
+ // Test that member cannot create system-level apps (authorization code flow)
+ _, err = memberClient.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
+ Name: "member-system-app",
+ RedirectURIs: []string{"http://localhost:3000"},
+ GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeAuthorizationCode},
+ })
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "You are not authorized to create this type of OAuth2 application")
+}
+
+// TestOAuth2ProviderAppOwnershipAuthorization tests that users can only access their own OAuth2 apps
+func TestOAuth2ProviderAppOwnershipAuthorization(t *testing.T) {
+ t.Parallel()
+
+ client := coderdtest.New(t, nil)
+ owner := coderdtest.CreateFirstUser(t, client)
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ // Create two non-admin users
+ user1Client, user1 := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
+ user2Client, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
+
+ // User1 creates a client credentials app
+ user1App, err := user1Client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
+ Name: "user1-app",
+ RedirectURIs: []string{},
+ GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials},
+ })
+ require.NoError(t, err)
+ require.Equal(t, user1.ID, user1App.UserID)
+
+ // User1 creates a secret for their app
+ user1Secret, err := user1Client.PostOAuth2ProviderAppSecret(ctx, user1App.ID)
+ require.NoError(t, err)
+ require.NotEmpty(t, user1Secret.ClientSecretFull)
+
+ // Test that user1 can access their own app
+ retrievedApp, err := user1Client.OAuth2ProviderApp(ctx, user1App.ID)
+ require.NoError(t, err)
+ require.Equal(t, user1App.ID, retrievedApp.ID)
+
+ // Test that user1 can list secrets for their own app
+ secrets, err := user1Client.OAuth2ProviderAppSecrets(ctx, user1App.ID)
+ require.NoError(t, err)
+ require.Len(t, secrets, 1)
+ require.Equal(t, user1Secret.ID, secrets[0].ID)
+
+ // Test that user2 cannot access user1's app
+ _, err = user2Client.OAuth2ProviderApp(ctx, user1App.ID)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "not found")
+
+ // Test that user2 cannot list secrets for user1's app
+ _, err = user2Client.OAuth2ProviderAppSecrets(ctx, user1App.ID)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "not found")
+
+ // Test that user2 cannot create a secret for user1's app
+ _, err = user2Client.PostOAuth2ProviderAppSecret(ctx, user1App.ID)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "not found")
+
+ // Test that user2 cannot delete user1's app
+ err = user2Client.DeleteOAuth2ProviderApp(ctx, user1App.ID)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "not found")
+
+ // Test that user2 cannot update user1's app
+ _, err = user2Client.PutOAuth2ProviderApp(ctx, user1App.ID, codersdk.PutOAuth2ProviderAppRequest{
+ Name: "hacked-app",
+ RedirectURIs: []string{"http://localhost:8080"},
+ Icon: "evil",
+ })
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "not found")
+
+ // Test that user2 cannot delete user1's app secret
+ err = user2Client.DeleteOAuth2ProviderAppSecret(ctx, user1App.ID, user1Secret.ID)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "not found")
+
+ // Test that user1 CAN delete their own app secret
+ err = user1Client.DeleteOAuth2ProviderAppSecret(ctx, user1App.ID, user1Secret.ID)
+ require.NoError(t, err)
+
+ // Test that user1 CAN update their own app
+ updatedApp, err := user1Client.PutOAuth2ProviderApp(ctx, user1App.ID, codersdk.PutOAuth2ProviderAppRequest{
+ Name: "updated-app",
+ RedirectURIs: []string{},
+ Icon: "new-icon",
+ GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials},
+ })
+ require.NoError(t, err)
+ require.Equal(t, "updated-app", updatedApp.Name)
+ require.Equal(t, "new-icon", updatedApp.Icon)
+
+ // Test that user1 CAN delete their own app
+ err = user1Client.DeleteOAuth2ProviderApp(ctx, user1App.ID)
+ require.NoError(t, err)
+
+ // Verify app is gone
+ _, err = user1Client.OAuth2ProviderApp(ctx, user1App.ID)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "not found")
+}
diff --git a/coderd/oauth2provider/registration.go b/coderd/oauth2provider/registration.go
index 93ae5ec2f2f7c..4d07f939bcc87 100644
--- a/coderd/oauth2provider/registration.go
+++ b/coderd/oauth2provider/registration.go
@@ -130,12 +130,13 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi
//nolint:gocritic // Using AsSystemOAuth2 for OAuth2 dynamic client registration
_, err = db.InsertOAuth2ProviderAppSecret(dbauthz.AsSystemOAuth2(ctx), database.InsertOAuth2ProviderAppSecretParams{
- ID: uuid.New(),
- CreatedAt: now,
- SecretPrefix: []byte(parsedSecret.prefix),
- HashedSecret: []byte(hashedSecret),
- DisplaySecret: createDisplaySecret(clientSecret),
- AppID: clientID,
+ ID: uuid.New(),
+ CreatedAt: now,
+ SecretPrefix: []byte(parsedSecret.prefix),
+ HashedSecret: []byte(hashedSecret),
+ DisplaySecret: createDisplaySecret(clientSecret),
+ AppID: clientID,
+ AppOwnerUserID: uuid.NullUUID{Valid: false}, // System-level secret
})
if err != nil {
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
diff --git a/coderd/oauth2provider/tokens.go b/coderd/oauth2provider/tokens.go
index 5891f04cc2cb3..0bfc048e7c192 100644
--- a/coderd/oauth2provider/tokens.go
+++ b/coderd/oauth2provider/tokens.go
@@ -123,14 +123,6 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime) http.HandlerF
ctx := r.Context()
app := httpmw.OAuth2ProviderApp(r)
- // Validate that app has registered redirect URIs
- if len(app.RedirectUris) == 0 {
- httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
- Message: "OAuth2 app has no registered redirect URIs.",
- })
- return
- }
-
params, validationErrs, err := extractTokenParams(r, app.RedirectUris)
if err != nil {
// Check for specific validation errors in priority order
@@ -156,6 +148,14 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime) http.HandlerF
return
}
+ // Validate redirect URIs only for grant types that require them
+ if params.grantType == codersdk.OAuth2ProviderGrantTypeAuthorizationCode && len(app.RedirectUris) == 0 {
+ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
+ Message: "OAuth2 app has no registered redirect URIs.",
+ })
+ return
+ }
+
var token oauth2.Token
//nolint:gocritic,revive // More cases will be added later.
switch params.grantType {
diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go
index 33635f34e5914..c3a6739790e5f 100644
--- a/coderd/rbac/roles.go
+++ b/coderd/rbac/roles.go
@@ -289,9 +289,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
Identifier: RoleMember(),
DisplayName: "Member",
Site: Permissions(map[string][]policy.Action{
- ResourceAssignRole.Type: {policy.ActionRead},
- // All users can see OAuth2 provider applications.
- ResourceOauth2App.Type: {policy.ActionRead},
+ ResourceAssignRole.Type: {policy.ActionRead},
ResourceWorkspaceProxy.Type: {policy.ActionRead},
}),
Org: map[string][]Permission{},
@@ -306,6 +304,10 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
ResourceOrganizationMember.Type: {policy.ActionRead},
// Users can create provisioner daemons scoped to themselves.
ResourceProvisionerDaemon.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionRead, policy.ActionUpdate},
+ // Users can create OAuth2 apps scoped to themselves.
+ ResourceOauth2App.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
+ ResourceOauth2AppSecret.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
+ ResourceOauth2AppCodeToken.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
})...,
),
}.withCachedRegoValue()
diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go
index c790d2cf140ca..fd32af8e30cfb 100644
--- a/coderd/rbac/roles_test.go
+++ b/coderd/rbac/roles_test.go
@@ -628,8 +628,20 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionRead},
Resource: rbac.ResourceOauth2App,
AuthorizeMap: map[bool][]hasAuthSubjects{
- true: {owner, setOrgNotMe, setOtherOrg, memberMe, orgMemberMe, templateAdmin, userAdmin},
- false: {},
+ // Only owners can read system-scoped OAuth2 apps
+ // User-level permissions only apply to user-owned resources
+ true: {owner},
+ false: {memberMe, orgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin, otherOrgAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, templateAdmin, userAdmin, orgMemberMe, otherOrgMember},
+ },
+ },
+ {
+ Name: "Oauth2AppReadOwned",
+ Actions: []policy.Action{policy.ActionRead},
+ Resource: rbac.ResourceOauth2App.WithOwner(currentUser.String()),
+ AuthorizeMap: map[bool][]hasAuthSubjects{
+ // Users can read their own OAuth2 apps through ownership
+ true: {owner, memberMe, orgMemberMe},
+ false: {orgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin, otherOrgAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, templateAdmin, userAdmin, otherOrgMember},
},
},
{
diff --git a/codersdk/oauth2.go b/codersdk/oauth2.go
index 0c0732a15cfca..c915cf71f46c3 100644
--- a/codersdk/oauth2.go
+++ b/codersdk/oauth2.go
@@ -9,6 +9,7 @@ import (
"net/http"
"net/url"
"strings"
+ "time"
"github.com/google/uuid"
"golang.org/x/oauth2"
@@ -25,6 +26,11 @@ type OAuth2ProviderApp struct {
Name string `json:"name"`
RedirectURIs []string `json:"redirect_uris"`
Icon string `json:"icon"`
+ CreatedAt time.Time `json:"created_at" format:"date-time"`
+ GrantTypes []string `json:"grant_types"`
+ UserID uuid.UUID `json:"user_id,omitempty" format:"uuid"`
+ Username string `json:"username,omitempty"`
+ Email string `json:"email,omitempty"`
// Endpoints are included in the app response for easier discovery. The OAuth2
// spec does not have a defined place to find these (for comparison, OIDC has
@@ -41,7 +47,8 @@ type OAuth2AppEndpoints struct {
}
type OAuth2ProviderAppFilter struct {
- UserID uuid.UUID `json:"user_id,omitempty" format:"uuid"`
+ UserID uuid.UUID `json:"user_id,omitempty" format:"uuid"`
+ OwnerID uuid.UUID `json:"owner_id,omitempty" format:"uuid"`
}
// OAuth2ProviderApps returns the applications configured to authenticate using
@@ -49,11 +56,14 @@ type OAuth2ProviderAppFilter struct {
func (c *Client) OAuth2ProviderApps(ctx context.Context, filter OAuth2ProviderAppFilter) ([]OAuth2ProviderApp, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/oauth2-provider/apps", nil,
func(r *http.Request) {
+ q := r.URL.Query()
if filter.UserID != uuid.Nil {
- q := r.URL.Query()
q.Set("user_id", filter.UserID.String())
- r.URL.RawQuery = q.Encode()
}
+ if filter.OwnerID != uuid.Nil {
+ q.Set("owner_id", filter.OwnerID.String())
+ }
+ r.URL.RawQuery = q.Encode()
})
if err != nil {
return []OAuth2ProviderApp{}, err
@@ -83,7 +93,7 @@ func (c *Client) OAuth2ProviderApp(ctx context.Context, id uuid.UUID) (OAuth2Pro
type PostOAuth2ProviderAppRequest struct {
Name string `json:"name" validate:"required,oauth2_app_display_name"`
- RedirectURIs []string `json:"redirect_uris" validate:"required,min=1,dive,http_url"`
+ RedirectURIs []string `json:"redirect_uris" validate:"dive,http_url"`
Icon string `json:"icon" validate:"omitempty"`
GrantTypes []OAuth2ProviderGrantType `json:"grant_types,omitempty" validate:"dive,oneof=authorization_code refresh_token client_credentials urn:ietf:params:oauth:grant-type:device_code"`
}
@@ -140,7 +150,7 @@ func (c *Client) PostOAuth2ProviderApp(ctx context.Context, app PostOAuth2Provid
type PutOAuth2ProviderAppRequest struct {
Name string `json:"name" validate:"required,oauth2_app_display_name"`
- RedirectURIs []string `json:"redirect_uris" validate:"required,min=1,dive,http_url"`
+ RedirectURIs []string `json:"redirect_uris" validate:"dive,http_url"`
Icon string `json:"icon" validate:"omitempty"`
GrantTypes []OAuth2ProviderGrantType `json:"grant_types,omitempty" validate:"dive,oneof=authorization_code refresh_token client_credentials urn:ietf:params:oauth:grant-type:device_code"`
}
@@ -186,6 +196,7 @@ type OAuth2ProviderAppSecretFull struct {
type OAuth2ProviderAppSecret struct {
ID uuid.UUID `json:"id" format:"uuid"`
+ CreatedAt time.Time `json:"created_at" format:"date-time"`
LastUsedAt NullTime `json:"last_used_at"`
ClientSecretTruncated string `json:"client_secret_truncated"`
}
diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md
index 3671318a4769c..6ed1ed015cb5d 100644
--- a/docs/admin/security/audit-logs.md
+++ b/docs/admin/security/audit-logs.md
@@ -27,7 +27,7 @@ We track the following resources:
| NotificationTemplate
|
Field | Tracked |
---|---|
actions | true |
body_template | true |
enabled_by_default | true |
group | true |
id | false |
kind | true |
method | true |
name | true |
title_template | true |
Field | Tracked |
---|---|
id | false |
notifier_paused | true |
Field | Tracked |
---|---|
client_id_issued_at | false |
client_secret_expires_at | true |
client_type | true |
client_uri | true |
contacts | true |
created_at | false |
dynamically_registered | true |
grant_types | true |
icon | true |
id | false |
jwks | true |
jwks_uri | true |
logo_uri | true |
name | true |
policy_uri | true |
redirect_uris | true |
registration_access_token | true |
registration_client_uri | true |
response_types | true |
scope | true |
software_id | true |
software_version | true |
token_endpoint_auth_method | true |
tos_uri | true |
updated_at | false |
user_id | true |
Field | Tracked |
---|---|
app_id | false |
created_at | false |
display_secret | false |
hashed_secret | false |
id | false |
last_used_at | false |
secret_prefix | false |
Field | Tracked |
---|---|
app_id | false |
app_owner_user_id | false |
created_at | false |
display_secret | false |
hashed_secret | false |
id | false |
last_used_at | false |
secret_prefix | false |
Field | Tracked |
---|---|
client_id | true |
created_at | false |
device_code_prefix | true |
expires_at | false |
id | false |
polling_interval | false |
resource_uri | true |
scope | true |
status | true |
user_code | true |
user_id | true |
verification_uri | true |
verification_uri_complete | true |
Field | Tracked |
---|---|
created_at | false |
deleted | true |
description | true |
display_name | true |
icon | true |
id | false |
is_default | true |
name | true |
updated_at | true |
Field | Tracked |
---|---|
assign_default | true |
field | true |
mapping | true |
+ onClose={() => { + ackFullNewSecret(); + setShowCodeExample(false); + }} + data-testid="dialog" + > +
Your new client secret is displayed below. Make sure to copy it now; you will not be able to see it again.
+ Use this curl command to exchange your client + credentials for an access token: +
++ Client secrets are used to authenticate your application with + the Coder API. +
+ } + onClick={handleCreateSecret} + disabled={createSecretMutation.isPending} + variant="contained" + > ++ OAuth2 applications you've granted access to your account +
++ No OAuth2 applications have been authorized. +
+Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: