feat: add OAuth2 applications (#11197)

* Add database tables for OAuth2 applications

These are applications that will be able to use OAuth2 to get an API key
from Coder.

* Add endpoints for managing OAuth2 applications

These let you add, update, and remove OAuth2 applications.

* Add frontend for managing OAuth2 applications
This commit is contained in:
Asher 2023-12-21 12:38:42 -09:00 committed by GitHub
parent e044d3b752
commit 5cfa34b31e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 4281 additions and 1 deletions

357
coderd/apidoc/docs.go generated
View File

@ -1304,6 +1304,282 @@ const docTemplate = `{
}
}
},
"/oauth2-provider/apps": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Get OAuth2 applications.",
"operationId": "get-oauth2-applications",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.OAuth2ProviderApp"
}
}
}
}
},
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Create OAuth2 application.",
"operationId": "create-oauth2-application",
"parameters": [
{
"description": "The OAuth2 application to create.",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.PostOAuth2ProviderAppRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.OAuth2ProviderApp"
}
}
}
}
},
"/oauth2-provider/apps/{app}": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Get OAuth2 application.",
"operationId": "get-oauth2-application",
"parameters": [
{
"type": "string",
"description": "App ID",
"name": "app",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.OAuth2ProviderApp"
}
}
}
},
"put": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Update OAuth2 application.",
"operationId": "update-oauth2-application",
"parameters": [
{
"type": "string",
"description": "App ID",
"name": "app",
"in": "path",
"required": true
},
{
"description": "Update an OAuth2 application.",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.PutOAuth2ProviderAppRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.OAuth2ProviderApp"
}
}
}
},
"delete": {
"security": [
{
"CoderSessionToken": []
}
],
"tags": [
"Enterprise"
],
"summary": "Delete OAuth2 application.",
"operationId": "delete-oauth2-application",
"parameters": [
{
"type": "string",
"description": "App ID",
"name": "app",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/oauth2-provider/apps/{app}/secrets": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Get OAuth2 application secrets.",
"operationId": "get-oauth2-application-secrets",
"parameters": [
{
"type": "string",
"description": "App ID",
"name": "app",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.OAuth2ProviderAppSecret"
}
}
}
}
},
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Create OAuth2 application secret.",
"operationId": "create-oauth2-application-secret",
"parameters": [
{
"type": "string",
"description": "App ID",
"name": "app",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.OAuth2ProviderAppSecretFull"
}
}
}
}
}
},
"/oauth2-provider/apps/{app}/secrets/{secretID}": {
"delete": {
"security": [
{
"CoderSessionToken": []
}
],
"tags": [
"Enterprise"
],
"summary": "Delete OAuth2 application secret.",
"operationId": "delete-oauth2-application-secret",
"parameters": [
{
"type": "string",
"description": "App ID",
"name": "app",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Secret ID",
"name": "secretID",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/organizations": {
"post": {
"security": [
@ -9413,6 +9689,51 @@ const docTemplate = `{
}
}
},
"codersdk.OAuth2ProviderApp": {
"type": "object",
"properties": {
"callback_url": {
"type": "string"
},
"icon": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
}
}
},
"codersdk.OAuth2ProviderAppSecret": {
"type": "object",
"properties": {
"client_secret_truncated": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"last_used_at": {
"type": "string"
}
}
},
"codersdk.OAuth2ProviderAppSecretFull": {
"type": "object",
"properties": {
"client_secret_full": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
}
}
},
"codersdk.OAuthConversionResponse": {
"type": "object",
"properties": {
@ -9653,6 +9974,24 @@ const docTemplate = `{
}
}
},
"codersdk.PostOAuth2ProviderAppRequest": {
"type": "object",
"required": [
"callback_url",
"name"
],
"properties": {
"callback_url": {
"type": "string"
},
"icon": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"codersdk.PprofConfig": {
"type": "object",
"properties": {
@ -9932,6 +10271,24 @@ const docTemplate = `{
}
}
},
"codersdk.PutOAuth2ProviderAppRequest": {
"type": "object",
"required": [
"callback_url",
"name"
],
"properties": {
"callback_url": {
"type": "string"
},
"icon": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"codersdk.RBACResource": {
"type": "string",
"enum": [

View File

@ -1122,6 +1122,250 @@
}
}
},
"/oauth2-provider/apps": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Get OAuth2 applications.",
"operationId": "get-oauth2-applications",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.OAuth2ProviderApp"
}
}
}
}
},
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Create OAuth2 application.",
"operationId": "create-oauth2-application",
"parameters": [
{
"description": "The OAuth2 application to create.",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.PostOAuth2ProviderAppRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.OAuth2ProviderApp"
}
}
}
}
},
"/oauth2-provider/apps/{app}": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Get OAuth2 application.",
"operationId": "get-oauth2-application",
"parameters": [
{
"type": "string",
"description": "App ID",
"name": "app",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.OAuth2ProviderApp"
}
}
}
},
"put": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Update OAuth2 application.",
"operationId": "update-oauth2-application",
"parameters": [
{
"type": "string",
"description": "App ID",
"name": "app",
"in": "path",
"required": true
},
{
"description": "Update an OAuth2 application.",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.PutOAuth2ProviderAppRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.OAuth2ProviderApp"
}
}
}
},
"delete": {
"security": [
{
"CoderSessionToken": []
}
],
"tags": ["Enterprise"],
"summary": "Delete OAuth2 application.",
"operationId": "delete-oauth2-application",
"parameters": [
{
"type": "string",
"description": "App ID",
"name": "app",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/oauth2-provider/apps/{app}/secrets": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Get OAuth2 application secrets.",
"operationId": "get-oauth2-application-secrets",
"parameters": [
{
"type": "string",
"description": "App ID",
"name": "app",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.OAuth2ProviderAppSecret"
}
}
}
}
},
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Create OAuth2 application secret.",
"operationId": "create-oauth2-application-secret",
"parameters": [
{
"type": "string",
"description": "App ID",
"name": "app",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.OAuth2ProviderAppSecretFull"
}
}
}
}
}
},
"/oauth2-provider/apps/{app}/secrets/{secretID}": {
"delete": {
"security": [
{
"CoderSessionToken": []
}
],
"tags": ["Enterprise"],
"summary": "Delete OAuth2 application secret.",
"operationId": "delete-oauth2-application-secret",
"parameters": [
{
"type": "string",
"description": "App ID",
"name": "app",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Secret ID",
"name": "secretID",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/organizations": {
"post": {
"security": [
@ -8442,6 +8686,51 @@
}
}
},
"codersdk.OAuth2ProviderApp": {
"type": "object",
"properties": {
"callback_url": {
"type": "string"
},
"icon": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
}
}
},
"codersdk.OAuth2ProviderAppSecret": {
"type": "object",
"properties": {
"client_secret_truncated": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"last_used_at": {
"type": "string"
}
}
},
"codersdk.OAuth2ProviderAppSecretFull": {
"type": "object",
"properties": {
"client_secret_full": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
}
}
},
"codersdk.OAuthConversionResponse": {
"type": "object",
"properties": {
@ -8672,6 +8961,21 @@
}
}
},
"codersdk.PostOAuth2ProviderAppRequest": {
"type": "object",
"required": ["callback_url", "name"],
"properties": {
"callback_url": {
"type": "string"
},
"icon": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"codersdk.PprofConfig": {
"type": "object",
"properties": {
@ -8928,6 +9232,21 @@
}
}
},
"codersdk.PutOAuth2ProviderAppRequest": {
"type": "object",
"required": ["callback_url", "name"],
"properties": {
"callback_url": {
"type": "string"
},
"icon": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"codersdk.RBACResource": {
"type": "string",
"enum": [

View File

@ -225,6 +225,23 @@ func templateVersionParameterOptions(rawOptions json.RawMessage) ([]codersdk.Tem
return options, nil
}
func OAuth2ProviderApp(dbApp database.OAuth2ProviderApp) codersdk.OAuth2ProviderApp {
return codersdk.OAuth2ProviderApp{
ID: dbApp.ID,
Name: dbApp.Name,
CallbackURL: dbApp.CallbackURL,
Icon: dbApp.Icon,
}
}
func OAuth2ProviderApps(dbApps []database.OAuth2ProviderApp) []codersdk.OAuth2ProviderApp {
apps := []codersdk.OAuth2ProviderApp{}
for _, dbApp := range dbApps {
apps = append(apps, OAuth2ProviderApp(dbApp))
}
return apps
}
func convertDisplayApps(apps []database.DisplayApp) []codersdk.DisplayApp {
dapps := make([]codersdk.DisplayApp, 0, len(apps))
for _, app := range apps {

View File

@ -805,6 +805,20 @@ func (q *querier) DeleteLicense(ctx context.Context, id int32) (int32, error) {
return id, nil
}
func (q *querier) DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error {
if err := q.authorizeContext(ctx, rbac.ActionDelete, rbac.ResourceOAuth2ProviderApp); err != nil {
return err
}
return q.db.DeleteOAuth2ProviderAppByID(ctx, id)
}
func (q *querier) DeleteOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) error {
if err := q.authorizeContext(ctx, rbac.ActionDelete, rbac.ResourceOAuth2ProviderAppSecret); err != nil {
return err
}
return q.db.DeleteOAuth2ProviderAppSecretByID(ctx, id)
}
func (q *querier) DeleteOldProvisionerDaemons(ctx context.Context) error {
if err := q.authorizeContext(ctx, rbac.ActionDelete, rbac.ResourceSystem); err != nil {
return err
@ -1131,6 +1145,34 @@ func (q *querier) GetLogoURL(ctx context.Context) (string, error) {
return q.db.GetLogoURL(ctx)
}
func (q *querier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) {
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceOAuth2ProviderApp); err != nil {
return database.OAuth2ProviderApp{}, err
}
return q.db.GetOAuth2ProviderAppByID(ctx, id)
}
func (q *querier) GetOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderAppSecret, error) {
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceOAuth2ProviderAppSecret); err != nil {
return database.OAuth2ProviderAppSecret{}, err
}
return q.db.GetOAuth2ProviderAppSecretByID(ctx, id)
}
func (q *querier) GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, appID uuid.UUID) ([]database.OAuth2ProviderAppSecret, error) {
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceOAuth2ProviderAppSecret); err != nil {
return []database.OAuth2ProviderAppSecret{}, err
}
return q.db.GetOAuth2ProviderAppSecretsByAppID(ctx, appID)
}
func (q *querier) GetOAuth2ProviderApps(ctx context.Context) ([]database.OAuth2ProviderApp, error) {
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceOAuth2ProviderApp); err != nil {
return []database.OAuth2ProviderApp{}, err
}
return q.db.GetOAuth2ProviderApps(ctx)
}
func (q *querier) GetOAuthSigningKey(ctx context.Context) (string, error) {
if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceSystem); err != nil {
return "", err
@ -2145,6 +2187,20 @@ func (q *querier) InsertMissingGroups(ctx context.Context, arg database.InsertMi
return q.db.InsertMissingGroups(ctx, arg)
}
func (q *querier) InsertOAuth2ProviderApp(ctx context.Context, arg database.InsertOAuth2ProviderAppParams) (database.OAuth2ProviderApp, error) {
if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceOAuth2ProviderApp); err != nil {
return database.OAuth2ProviderApp{}, err
}
return q.db.InsertOAuth2ProviderApp(ctx, arg)
}
func (q *querier) InsertOAuth2ProviderAppSecret(ctx context.Context, arg database.InsertOAuth2ProviderAppSecretParams) (database.OAuth2ProviderAppSecret, error) {
if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceOAuth2ProviderAppSecret); err != nil {
return database.OAuth2ProviderAppSecret{}, err
}
return q.db.InsertOAuth2ProviderAppSecret(ctx, arg)
}
func (q *querier) InsertOrganization(ctx context.Context, arg database.InsertOrganizationParams) (database.Organization, error) {
return insert(q.log, q.auth, rbac.ResourceOrganization, q.db.InsertOrganization)(ctx, arg)
}
@ -2500,6 +2556,20 @@ func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemb
return q.db.UpdateMemberRoles(ctx, arg)
}
func (q *querier) UpdateOAuth2ProviderAppByID(ctx context.Context, arg database.UpdateOAuth2ProviderAppByIDParams) (database.OAuth2ProviderApp, error) {
if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceOAuth2ProviderApp); err != nil {
return database.OAuth2ProviderApp{}, err
}
return q.db.UpdateOAuth2ProviderAppByID(ctx, arg)
}
func (q *querier) UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg database.UpdateOAuth2ProviderAppSecretByIDParams) (database.OAuth2ProviderAppSecret, error) {
if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceOAuth2ProviderAppSecret); err != nil {
return database.OAuth2ProviderAppSecret{}, err
}
return q.db.UpdateOAuth2ProviderAppSecretByID(ctx, arg)
}
func (q *querier) UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg database.UpdateProvisionerDaemonLastSeenAtParams) error {
if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceProvisionerDaemon); err != nil {
return err

View File

@ -2200,3 +2200,86 @@ func (s *MethodTestSuite) TestSystemFunctions() {
check.Args(uuid.New()).Asserts(rbac.ResourceSystem, rbac.ActionRead)
}))
}
func (s *MethodTestSuite) TestOAuth2ProviderApps() {
s.Run("GetOAuth2ProviderApps", s.Subtest(func(db database.Store, check *expects) {
apps := []database.OAuth2ProviderApp{
dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{Name: "first"}),
dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{Name: "last"}),
}
check.Args().Asserts(rbac.ResourceOAuth2ProviderApp, rbac.ActionRead).Returns(apps)
}))
s.Run("GetOAuth2ProviderAppByID", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
check.Args(app.ID).Asserts(rbac.ResourceOAuth2ProviderApp, rbac.ActionRead).Returns(app)
}))
s.Run("InsertOAuth2ProviderApp", s.Subtest(func(db database.Store, check *expects) {
check.Args(database.InsertOAuth2ProviderAppParams{}).Asserts(rbac.ResourceOAuth2ProviderApp, rbac.ActionCreate)
}))
s.Run("UpdateOAuth2ProviderAppByID", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
app.Name = "my-new-name"
app.UpdatedAt = time.Now()
check.Args(database.UpdateOAuth2ProviderAppByIDParams{
ID: app.ID,
Name: app.Name,
CallbackURL: app.CallbackURL,
UpdatedAt: app.UpdatedAt,
}).Asserts(rbac.ResourceOAuth2ProviderApp, rbac.ActionUpdate).Returns(app)
}))
s.Run("DeleteOAuth2ProviderAppByID", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
check.Args(app.ID).Asserts(rbac.ResourceOAuth2ProviderApp, rbac.ActionDelete)
}))
}
func (s *MethodTestSuite) TestOAuth2ProviderAppSecrets() {
s.Run("GetOAuth2ProviderAppSecretsByAppID", s.Subtest(func(db database.Store, check *expects) {
app1 := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
app2 := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
secrets := []database.OAuth2ProviderAppSecret{
dbgen.OAuth2ProviderAppSecret(s.T(), db, database.OAuth2ProviderAppSecret{
AppID: app1.ID,
CreatedAt: time.Now().Add(-time.Hour), // For ordering.
}),
dbgen.OAuth2ProviderAppSecret(s.T(), db, database.OAuth2ProviderAppSecret{
AppID: app1.ID,
}),
}
_ = dbgen.OAuth2ProviderAppSecret(s.T(), db, database.OAuth2ProviderAppSecret{
AppID: app2.ID,
})
check.Args(app1.ID).Asserts(rbac.ResourceOAuth2ProviderAppSecret, rbac.ActionRead).Returns(secrets)
}))
s.Run("GetOAuth2ProviderAppSecretByID", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
secret := dbgen.OAuth2ProviderAppSecret(s.T(), db, database.OAuth2ProviderAppSecret{
AppID: app.ID,
})
check.Args(secret.ID).Asserts(rbac.ResourceOAuth2ProviderAppSecret, rbac.ActionRead).Returns(secret)
}))
s.Run("InsertOAuth2ProviderAppSecret", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
check.Args(database.InsertOAuth2ProviderAppSecretParams{
AppID: app.ID,
}).Asserts(rbac.ResourceOAuth2ProviderAppSecret, rbac.ActionCreate)
}))
s.Run("UpdateOAuth2ProviderAppSecretByID", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
secret := dbgen.OAuth2ProviderAppSecret(s.T(), db, database.OAuth2ProviderAppSecret{
AppID: app.ID,
})
secret.LastUsedAt = sql.NullTime{Time: time.Now(), Valid: true}
check.Args(database.UpdateOAuth2ProviderAppSecretByIDParams{
ID: secret.ID,
LastUsedAt: secret.LastUsedAt,
}).Asserts(rbac.ResourceOAuth2ProviderAppSecret, rbac.ActionUpdate).Returns(secret)
}))
s.Run("DeleteOAuth2ProviderAppSecretByID", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
secret := dbgen.OAuth2ProviderAppSecret(s.T(), db, database.OAuth2ProviderAppSecret{
AppID: app.ID,
})
check.Args(secret.ID).Asserts(rbac.ResourceOAuth2ProviderAppSecret, rbac.ActionDelete)
}))
}

View File

@ -676,6 +676,31 @@ func WorkspaceAgentStat(t testing.TB, db database.Store, orig database.Workspace
return scheme
}
func OAuth2ProviderApp(t testing.TB, db database.Store, seed database.OAuth2ProviderApp) database.OAuth2ProviderApp {
app, err := db.InsertOAuth2ProviderApp(genCtx, database.InsertOAuth2ProviderAppParams{
ID: takeFirst(seed.ID, uuid.New()),
Name: takeFirst(seed.Name, namesgenerator.GetRandomName(1)),
CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()),
UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()),
Icon: takeFirst(seed.Icon, ""),
CallbackURL: takeFirst(seed.CallbackURL, "http://localhost"),
})
require.NoError(t, err, "insert oauth2 app")
return app
}
func OAuth2ProviderAppSecret(t testing.TB, db database.Store, seed database.OAuth2ProviderAppSecret) database.OAuth2ProviderAppSecret {
app, err := db.InsertOAuth2ProviderAppSecret(genCtx, database.InsertOAuth2ProviderAppSecretParams{
ID: takeFirst(seed.ID, uuid.New()),
CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()),
HashedSecret: takeFirstSlice(seed.HashedSecret, []byte("hashed-secret")),
DisplaySecret: takeFirst(seed.DisplaySecret, "secret"),
AppID: takeFirst(seed.AppID, uuid.New()),
})
require.NoError(t, err, "insert oauth2 app secret")
return app
}
func must[V any](v V, err error) V {
if err != nil {
panic(err)

View File

@ -130,6 +130,8 @@ type data struct {
groupMembers []database.GroupMember
groups []database.Group
licenses []database.License
oauth2ProviderApps []database.OAuth2ProviderApp
oauth2ProviderAppSecrets []database.OAuth2ProviderAppSecret
parameterSchemas []database.ParameterSchema
provisionerDaemons []database.ProvisionerDaemon
provisionerJobLogs []database.ProvisionerJobLog
@ -1144,6 +1146,43 @@ func (q *FakeQuerier) DeleteLicense(_ context.Context, id int32) (int32, error)
return 0, sql.ErrNoRows
}
func (q *FakeQuerier) DeleteOAuth2ProviderAppByID(_ context.Context, id uuid.UUID) error {
q.mutex.Lock()
defer q.mutex.Unlock()
for index, app := range q.oauth2ProviderApps {
if app.ID == id {
q.oauth2ProviderApps[index] = q.oauth2ProviderApps[len(q.oauth2ProviderApps)-1]
q.oauth2ProviderApps = q.oauth2ProviderApps[:len(q.oauth2ProviderApps)-1]
secrets := []database.OAuth2ProviderAppSecret{}
for _, secret := range q.oauth2ProviderAppSecrets {
if secret.AppID != id {
secrets = append(secrets, secret)
}
}
q.oauth2ProviderAppSecrets = secrets
return nil
}
}
return sql.ErrNoRows
}
func (q *FakeQuerier) DeleteOAuth2ProviderAppSecretByID(_ context.Context, id uuid.UUID) error {
q.mutex.Lock()
defer q.mutex.Unlock()
for index, secret := range q.oauth2ProviderAppSecrets {
if secret.ID == id {
q.oauth2ProviderAppSecrets[index] = q.oauth2ProviderAppSecrets[len(q.oauth2ProviderAppSecrets)-1]
q.oauth2ProviderAppSecrets = q.oauth2ProviderAppSecrets[:len(q.oauth2ProviderAppSecrets)-1]
return nil
}
}
return sql.ErrNoRows
}
func (q *FakeQuerier) DeleteOldProvisionerDaemons(_ context.Context) error {
q.mutex.Lock()
defer q.mutex.Unlock()
@ -2004,6 +2043,68 @@ func (q *FakeQuerier) GetLogoURL(_ context.Context) (string, error) {
return q.logoURL, nil
}
func (q *FakeQuerier) GetOAuth2ProviderAppByID(_ context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
for _, app := range q.oauth2ProviderApps {
if app.ID == id {
return app, nil
}
}
return database.OAuth2ProviderApp{}, sql.ErrNoRows
}
func (q *FakeQuerier) GetOAuth2ProviderAppSecretByID(_ context.Context, id uuid.UUID) (database.OAuth2ProviderAppSecret, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
for _, secret := range q.oauth2ProviderAppSecrets {
if secret.ID == id {
return secret, nil
}
}
return database.OAuth2ProviderAppSecret{}, sql.ErrNoRows
}
func (q *FakeQuerier) GetOAuth2ProviderAppSecretsByAppID(_ context.Context, appID uuid.UUID) ([]database.OAuth2ProviderAppSecret, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
for _, app := range q.oauth2ProviderApps {
if app.ID == appID {
secrets := []database.OAuth2ProviderAppSecret{}
for _, secret := range q.oauth2ProviderAppSecrets {
if secret.AppID == appID {
secrets = append(secrets, secret)
}
}
slices.SortFunc(secrets, func(a, b database.OAuth2ProviderAppSecret) int {
if a.CreatedAt.Before(b.CreatedAt) {
return -1
} else if a.CreatedAt.Equal(b.CreatedAt) {
return 0
}
return 1
})
return secrets, nil
}
}
return []database.OAuth2ProviderAppSecret{}, sql.ErrNoRows
}
func (q *FakeQuerier) GetOAuth2ProviderApps(_ context.Context) ([]database.OAuth2ProviderApp, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
slices.SortFunc(q.oauth2ProviderApps, func(a, b database.OAuth2ProviderApp) int {
return slice.Ascending(a.Name, b.Name)
})
return q.oauth2ProviderApps, nil
}
func (q *FakeQuerier) GetOAuthSigningKey(_ context.Context) (string, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@ -4946,6 +5047,61 @@ func (q *FakeQuerier) InsertMissingGroups(_ context.Context, arg database.Insert
return newGroups, nil
}
func (q *FakeQuerier) InsertOAuth2ProviderApp(_ context.Context, arg database.InsertOAuth2ProviderAppParams) (database.OAuth2ProviderApp, error) {
err := validateDatabaseType(arg)
if err != nil {
return database.OAuth2ProviderApp{}, err
}
q.mutex.Lock()
defer q.mutex.Unlock()
for _, app := range q.oauth2ProviderApps {
if app.Name == arg.Name {
return database.OAuth2ProviderApp{}, errDuplicateKey
}
}
//nolint:gosimple // Go wants database.OAuth2ProviderApp(arg), but we cannot be sure the structs will remain identical.
app := database.OAuth2ProviderApp{
ID: arg.ID,
CreatedAt: arg.CreatedAt,
UpdatedAt: arg.UpdatedAt,
Name: arg.Name,
Icon: arg.Icon,
CallbackURL: arg.CallbackURL,
}
q.oauth2ProviderApps = append(q.oauth2ProviderApps, app)
return app, nil
}
func (q *FakeQuerier) InsertOAuth2ProviderAppSecret(_ context.Context, arg database.InsertOAuth2ProviderAppSecretParams) (database.OAuth2ProviderAppSecret, error) {
err := validateDatabaseType(arg)
if err != nil {
return database.OAuth2ProviderAppSecret{}, err
}
q.mutex.Lock()
defer q.mutex.Unlock()
for _, app := range q.oauth2ProviderApps {
if app.ID == arg.AppID {
secret := database.OAuth2ProviderAppSecret{
ID: arg.ID,
CreatedAt: arg.CreatedAt,
HashedSecret: arg.HashedSecret,
DisplaySecret: arg.DisplaySecret,
AppID: arg.AppID,
}
q.oauth2ProviderAppSecrets = append(q.oauth2ProviderAppSecrets, secret)
return secret, nil
}
}
return database.OAuth2ProviderAppSecret{}, sql.ErrNoRows
}
func (q *FakeQuerier) InsertOrganization(_ context.Context, arg database.InsertOrganizationParams) (database.Organization, error) {
if err := validateDatabaseType(arg); err != nil {
return database.Organization{}, err
@ -5947,6 +6103,64 @@ func (q *FakeQuerier) UpdateMemberRoles(_ context.Context, arg database.UpdateMe
return database.OrganizationMember{}, sql.ErrNoRows
}
func (q *FakeQuerier) UpdateOAuth2ProviderAppByID(_ context.Context, arg database.UpdateOAuth2ProviderAppByIDParams) (database.OAuth2ProviderApp, error) {
err := validateDatabaseType(arg)
if err != nil {
return database.OAuth2ProviderApp{}, err
}
q.mutex.Lock()
defer q.mutex.Unlock()
for _, app := range q.oauth2ProviderApps {
if app.Name == arg.Name && app.ID != arg.ID {
return database.OAuth2ProviderApp{}, errDuplicateKey
}
}
for index, app := range q.oauth2ProviderApps {
if app.ID == arg.ID {
newApp := database.OAuth2ProviderApp{
ID: arg.ID,
CreatedAt: app.CreatedAt,
UpdatedAt: arg.UpdatedAt,
Name: arg.Name,
Icon: arg.Icon,
CallbackURL: arg.CallbackURL,
}
q.oauth2ProviderApps[index] = newApp
return newApp, nil
}
}
return database.OAuth2ProviderApp{}, sql.ErrNoRows
}
func (q *FakeQuerier) UpdateOAuth2ProviderAppSecretByID(_ context.Context, arg database.UpdateOAuth2ProviderAppSecretByIDParams) (database.OAuth2ProviderAppSecret, error) {
err := validateDatabaseType(arg)
if err != nil {
return database.OAuth2ProviderAppSecret{}, err
}
q.mutex.Lock()
defer q.mutex.Unlock()
for index, secret := range q.oauth2ProviderAppSecrets {
if secret.ID == arg.ID {
newSecret := database.OAuth2ProviderAppSecret{
ID: arg.ID,
CreatedAt: secret.CreatedAt,
HashedSecret: secret.HashedSecret,
DisplaySecret: secret.DisplaySecret,
AppID: secret.AppID,
LastUsedAt: arg.LastUsedAt,
}
q.oauth2ProviderAppSecrets[index] = newSecret
return newSecret, nil
}
}
return database.OAuth2ProviderAppSecret{}, sql.ErrNoRows
}
func (q *FakeQuerier) UpdateProvisionerDaemonLastSeenAt(_ context.Context, arg database.UpdateProvisionerDaemonLastSeenAtParams) error {
err := validateDatabaseType(arg)
if err != nil {

View File

@ -218,6 +218,20 @@ func (m metricsStore) DeleteLicense(ctx context.Context, id int32) (int32, error
return licenseID, err
}
func (m metricsStore) DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error {
start := time.Now()
r0 := m.s.DeleteOAuth2ProviderAppByID(ctx, id)
m.queryLatencies.WithLabelValues("DeleteOAuth2ProviderAppByID").Observe(time.Since(start).Seconds())
return r0
}
func (m metricsStore) DeleteOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) error {
start := time.Now()
r0 := m.s.DeleteOAuth2ProviderAppSecretByID(ctx, id)
m.queryLatencies.WithLabelValues("DeleteOAuth2ProviderAppSecretByID").Observe(time.Since(start).Seconds())
return r0
}
func (m metricsStore) DeleteOldProvisionerDaemons(ctx context.Context) error {
start := time.Now()
r0 := m.s.DeleteOldProvisionerDaemons(ctx)
@ -566,6 +580,34 @@ func (m metricsStore) GetLogoURL(ctx context.Context) (string, error) {
return url, err
}
func (m metricsStore) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) {
start := time.Now()
r0, r1 := m.s.GetOAuth2ProviderAppByID(ctx, id)
m.queryLatencies.WithLabelValues("GetOAuth2ProviderAppByID").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) GetOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderAppSecret, error) {
start := time.Now()
r0, r1 := m.s.GetOAuth2ProviderAppSecretByID(ctx, id)
m.queryLatencies.WithLabelValues("GetOAuth2ProviderAppSecretByID").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, appID uuid.UUID) ([]database.OAuth2ProviderAppSecret, error) {
start := time.Now()
r0, r1 := m.s.GetOAuth2ProviderAppSecretsByAppID(ctx, appID)
m.queryLatencies.WithLabelValues("GetOAuth2ProviderAppSecretsByAppID").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) GetOAuth2ProviderApps(ctx context.Context) ([]database.OAuth2ProviderApp, error) {
start := time.Now()
r0, r1 := m.s.GetOAuth2ProviderApps(ctx)
m.queryLatencies.WithLabelValues("GetOAuth2ProviderApps").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) GetOAuthSigningKey(ctx context.Context) (string, error) {
start := time.Now()
r0, r1 := m.s.GetOAuthSigningKey(ctx)
@ -1334,6 +1376,20 @@ func (m metricsStore) InsertMissingGroups(ctx context.Context, arg database.Inse
return r0, r1
}
func (m metricsStore) InsertOAuth2ProviderApp(ctx context.Context, arg database.InsertOAuth2ProviderAppParams) (database.OAuth2ProviderApp, error) {
start := time.Now()
r0, r1 := m.s.InsertOAuth2ProviderApp(ctx, arg)
m.queryLatencies.WithLabelValues("InsertOAuth2ProviderApp").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) InsertOAuth2ProviderAppSecret(ctx context.Context, arg database.InsertOAuth2ProviderAppSecretParams) (database.OAuth2ProviderAppSecret, error) {
start := time.Now()
r0, r1 := m.s.InsertOAuth2ProviderAppSecret(ctx, arg)
m.queryLatencies.WithLabelValues("InsertOAuth2ProviderAppSecret").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) InsertOrganization(ctx context.Context, arg database.InsertOrganizationParams) (database.Organization, error) {
start := time.Now()
organization, err := m.s.InsertOrganization(ctx, arg)
@ -1593,6 +1649,20 @@ func (m metricsStore) UpdateMemberRoles(ctx context.Context, arg database.Update
return member, err
}
func (m metricsStore) UpdateOAuth2ProviderAppByID(ctx context.Context, arg database.UpdateOAuth2ProviderAppByIDParams) (database.OAuth2ProviderApp, error) {
start := time.Now()
r0, r1 := m.s.UpdateOAuth2ProviderAppByID(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateOAuth2ProviderAppByID").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg database.UpdateOAuth2ProviderAppSecretByIDParams) (database.OAuth2ProviderAppSecret, error) {
start := time.Now()
r0, r1 := m.s.UpdateOAuth2ProviderAppSecretByID(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateOAuth2ProviderAppSecretByID").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg database.UpdateProvisionerDaemonLastSeenAtParams) error {
start := time.Now()
r0 := m.s.UpdateProvisionerDaemonLastSeenAt(ctx, arg)

View File

@ -323,6 +323,34 @@ func (mr *MockStoreMockRecorder) DeleteLicense(arg0, arg1 interface{}) *gomock.C
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteLicense", reflect.TypeOf((*MockStore)(nil).DeleteLicense), arg0, arg1)
}
// DeleteOAuth2ProviderAppByID mocks base method.
func (m *MockStore) DeleteOAuth2ProviderAppByID(arg0 context.Context, arg1 uuid.UUID) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteOAuth2ProviderAppByID", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteOAuth2ProviderAppByID indicates an expected call of DeleteOAuth2ProviderAppByID.
func (mr *MockStoreMockRecorder) DeleteOAuth2ProviderAppByID(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOAuth2ProviderAppByID", reflect.TypeOf((*MockStore)(nil).DeleteOAuth2ProviderAppByID), arg0, arg1)
}
// DeleteOAuth2ProviderAppSecretByID mocks base method.
func (m *MockStore) DeleteOAuth2ProviderAppSecretByID(arg0 context.Context, arg1 uuid.UUID) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteOAuth2ProviderAppSecretByID", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteOAuth2ProviderAppSecretByID indicates an expected call of DeleteOAuth2ProviderAppSecretByID.
func (mr *MockStoreMockRecorder) DeleteOAuth2ProviderAppSecretByID(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOAuth2ProviderAppSecretByID", reflect.TypeOf((*MockStore)(nil).DeleteOAuth2ProviderAppSecretByID), arg0, arg1)
}
// DeleteOldProvisionerDaemons mocks base method.
func (m *MockStore) DeleteOldProvisionerDaemons(arg0 context.Context) error {
m.ctrl.T.Helper()
@ -1113,6 +1141,66 @@ func (mr *MockStoreMockRecorder) GetLogoURL(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLogoURL", reflect.TypeOf((*MockStore)(nil).GetLogoURL), arg0)
}
// GetOAuth2ProviderAppByID mocks base method.
func (m *MockStore) GetOAuth2ProviderAppByID(arg0 context.Context, arg1 uuid.UUID) (database.OAuth2ProviderApp, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOAuth2ProviderAppByID", arg0, arg1)
ret0, _ := ret[0].(database.OAuth2ProviderApp)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetOAuth2ProviderAppByID indicates an expected call of GetOAuth2ProviderAppByID.
func (mr *MockStoreMockRecorder) GetOAuth2ProviderAppByID(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuth2ProviderAppByID", reflect.TypeOf((*MockStore)(nil).GetOAuth2ProviderAppByID), arg0, arg1)
}
// GetOAuth2ProviderAppSecretByID mocks base method.
func (m *MockStore) GetOAuth2ProviderAppSecretByID(arg0 context.Context, arg1 uuid.UUID) (database.OAuth2ProviderAppSecret, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOAuth2ProviderAppSecretByID", arg0, arg1)
ret0, _ := ret[0].(database.OAuth2ProviderAppSecret)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetOAuth2ProviderAppSecretByID indicates an expected call of GetOAuth2ProviderAppSecretByID.
func (mr *MockStoreMockRecorder) GetOAuth2ProviderAppSecretByID(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuth2ProviderAppSecretByID", reflect.TypeOf((*MockStore)(nil).GetOAuth2ProviderAppSecretByID), arg0, arg1)
}
// GetOAuth2ProviderAppSecretsByAppID mocks base method.
func (m *MockStore) GetOAuth2ProviderAppSecretsByAppID(arg0 context.Context, arg1 uuid.UUID) ([]database.OAuth2ProviderAppSecret, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOAuth2ProviderAppSecretsByAppID", arg0, arg1)
ret0, _ := ret[0].([]database.OAuth2ProviderAppSecret)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetOAuth2ProviderAppSecretsByAppID indicates an expected call of GetOAuth2ProviderAppSecretsByAppID.
func (mr *MockStoreMockRecorder) GetOAuth2ProviderAppSecretsByAppID(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuth2ProviderAppSecretsByAppID", reflect.TypeOf((*MockStore)(nil).GetOAuth2ProviderAppSecretsByAppID), arg0, arg1)
}
// GetOAuth2ProviderApps mocks base method.
func (m *MockStore) GetOAuth2ProviderApps(arg0 context.Context) ([]database.OAuth2ProviderApp, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOAuth2ProviderApps", arg0)
ret0, _ := ret[0].([]database.OAuth2ProviderApp)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetOAuth2ProviderApps indicates an expected call of GetOAuth2ProviderApps.
func (mr *MockStoreMockRecorder) GetOAuth2ProviderApps(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuth2ProviderApps", reflect.TypeOf((*MockStore)(nil).GetOAuth2ProviderApps), arg0)
}
// GetOAuthSigningKey mocks base method.
func (m *MockStore) GetOAuthSigningKey(arg0 context.Context) (string, error) {
m.ctrl.T.Helper()
@ -2803,6 +2891,36 @@ func (mr *MockStoreMockRecorder) InsertMissingGroups(arg0, arg1 interface{}) *go
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertMissingGroups", reflect.TypeOf((*MockStore)(nil).InsertMissingGroups), arg0, arg1)
}
// InsertOAuth2ProviderApp mocks base method.
func (m *MockStore) InsertOAuth2ProviderApp(arg0 context.Context, arg1 database.InsertOAuth2ProviderAppParams) (database.OAuth2ProviderApp, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InsertOAuth2ProviderApp", arg0, arg1)
ret0, _ := ret[0].(database.OAuth2ProviderApp)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// InsertOAuth2ProviderApp indicates an expected call of InsertOAuth2ProviderApp.
func (mr *MockStoreMockRecorder) InsertOAuth2ProviderApp(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertOAuth2ProviderApp", reflect.TypeOf((*MockStore)(nil).InsertOAuth2ProviderApp), arg0, arg1)
}
// InsertOAuth2ProviderAppSecret mocks base method.
func (m *MockStore) InsertOAuth2ProviderAppSecret(arg0 context.Context, arg1 database.InsertOAuth2ProviderAppSecretParams) (database.OAuth2ProviderAppSecret, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InsertOAuth2ProviderAppSecret", arg0, arg1)
ret0, _ := ret[0].(database.OAuth2ProviderAppSecret)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// InsertOAuth2ProviderAppSecret indicates an expected call of InsertOAuth2ProviderAppSecret.
func (mr *MockStoreMockRecorder) InsertOAuth2ProviderAppSecret(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertOAuth2ProviderAppSecret", reflect.TypeOf((*MockStore)(nil).InsertOAuth2ProviderAppSecret), arg0, arg1)
}
// InsertOrganization mocks base method.
func (m *MockStore) InsertOrganization(arg0 context.Context, arg1 database.InsertOrganizationParams) (database.Organization, error) {
m.ctrl.T.Helper()
@ -3362,6 +3480,36 @@ func (mr *MockStoreMockRecorder) UpdateMemberRoles(arg0, arg1 interface{}) *gomo
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateMemberRoles", reflect.TypeOf((*MockStore)(nil).UpdateMemberRoles), arg0, arg1)
}
// UpdateOAuth2ProviderAppByID mocks base method.
func (m *MockStore) UpdateOAuth2ProviderAppByID(arg0 context.Context, arg1 database.UpdateOAuth2ProviderAppByIDParams) (database.OAuth2ProviderApp, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateOAuth2ProviderAppByID", arg0, arg1)
ret0, _ := ret[0].(database.OAuth2ProviderApp)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateOAuth2ProviderAppByID indicates an expected call of UpdateOAuth2ProviderAppByID.
func (mr *MockStoreMockRecorder) UpdateOAuth2ProviderAppByID(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOAuth2ProviderAppByID", reflect.TypeOf((*MockStore)(nil).UpdateOAuth2ProviderAppByID), arg0, arg1)
}
// UpdateOAuth2ProviderAppSecretByID mocks base method.
func (m *MockStore) UpdateOAuth2ProviderAppSecretByID(arg0 context.Context, arg1 database.UpdateOAuth2ProviderAppSecretByIDParams) (database.OAuth2ProviderAppSecret, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateOAuth2ProviderAppSecretByID", arg0, arg1)
ret0, _ := ret[0].(database.OAuth2ProviderAppSecret)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateOAuth2ProviderAppSecretByID indicates an expected call of UpdateOAuth2ProviderAppSecretByID.
func (mr *MockStoreMockRecorder) UpdateOAuth2ProviderAppSecretByID(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOAuth2ProviderAppSecretByID", reflect.TypeOf((*MockStore)(nil).UpdateOAuth2ProviderAppSecretByID), arg0, arg1)
}
// UpdateProvisionerDaemonLastSeenAt mocks base method.
func (m *MockStore) UpdateProvisionerDaemonLastSeenAt(arg0 context.Context, arg1 database.UpdateProvisionerDaemonLastSeenAtParams) error {
m.ctrl.T.Helper()

View File

@ -458,6 +458,28 @@ CREATE SEQUENCE licenses_id_seq
ALTER SEQUENCE licenses_id_seq OWNED BY licenses.id;
CREATE TABLE oauth2_provider_app_secrets (
id uuid NOT NULL,
created_at timestamp with time zone NOT NULL,
last_used_at timestamp with time zone,
hashed_secret bytea NOT NULL,
display_secret text NOT NULL,
app_id uuid NOT NULL
);
COMMENT ON COLUMN oauth2_provider_app_secrets.display_secret IS 'The tail end of the original secret so secrets can be differentiated.';
CREATE TABLE oauth2_provider_apps (
id uuid NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
name character varying(64) NOT NULL,
icon character varying(256) NOT NULL,
callback_url text NOT NULL
);
COMMENT ON TABLE oauth2_provider_apps IS 'A table used to configure apps that can use Coder as an OAuth2 provider, the reverse of what we are calling external authentication.';
CREATE TABLE organization_members (
user_id uuid NOT NULL,
organization_id uuid NOT NULL,
@ -1270,6 +1292,18 @@ ALTER TABLE ONLY licenses
ALTER TABLE ONLY licenses
ADD CONSTRAINT licenses_pkey PRIMARY KEY (id);
ALTER TABLE ONLY oauth2_provider_app_secrets
ADD CONSTRAINT oauth2_provider_app_secrets_app_id_hashed_secret_key UNIQUE (app_id, hashed_secret);
ALTER TABLE ONLY oauth2_provider_app_secrets
ADD CONSTRAINT oauth2_provider_app_secrets_pkey PRIMARY KEY (id);
ALTER TABLE ONLY oauth2_provider_apps
ADD CONSTRAINT oauth2_provider_apps_name_key UNIQUE (name);
ALTER TABLE ONLY oauth2_provider_apps
ADD CONSTRAINT oauth2_provider_apps_pkey PRIMARY KEY (id);
ALTER TABLE ONLY organization_members
ADD CONSTRAINT organization_members_pkey PRIMARY KEY (organization_id, user_id);
@ -1496,6 +1530,9 @@ ALTER TABLE ONLY group_members
ALTER TABLE ONLY groups
ADD CONSTRAINT groups_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ALTER TABLE ONLY oauth2_provider_app_secrets
ADD CONSTRAINT oauth2_provider_app_secrets_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE;
ALTER TABLE ONLY organization_members
ADD CONSTRAINT organization_members_organization_id_uuid_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;

View File

@ -13,6 +13,7 @@ const (
ForeignKeyGroupMembersGroupID ForeignKeyConstraint = "group_members_group_id_fkey" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE;
ForeignKeyGroupMembersUserID ForeignKeyConstraint = "group_members_user_id_fkey" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyGroupsOrganizationID ForeignKeyConstraint = "groups_organization_id_fkey" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ForeignKeyOauth2ProviderAppSecretsAppID ForeignKeyConstraint = "oauth2_provider_app_secrets_app_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE;
ForeignKeyOrganizationMembersOrganizationIDUUID ForeignKeyConstraint = "organization_members_organization_id_uuid_fkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_organization_id_uuid_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ForeignKeyOrganizationMembersUserIDUUID ForeignKeyConstraint = "organization_members_user_id_uuid_fkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyParameterSchemasJobID ForeignKeyConstraint = "parameter_schemas_job_id_fkey" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE;

View File

@ -0,0 +1,2 @@
DROP TABLE oauth2_provider_app_secrets;
DROP TABLE oauth2_provider_apps;

View File

@ -0,0 +1,25 @@
CREATE TABLE oauth2_provider_apps (
id uuid NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
name varchar(64) NOT NULL,
icon varchar(256) NOT NULL,
callback_url text NOT NULL,
PRIMARY KEY (id),
UNIQUE(name)
);
COMMENT ON TABLE oauth2_provider_apps IS 'A table used to configure apps that can use Coder as an OAuth2 provider, the reverse of what we are calling external authentication.';
CREATE TABLE oauth2_provider_app_secrets (
id uuid NOT NULL,
created_at timestamp with time zone NOT NULL,
last_used_at timestamp with time zone NULL,
hashed_secret bytea NOT NULL,
display_secret text NOT NULL,
app_id uuid NOT NULL REFERENCES oauth2_provider_apps (id) ON DELETE CASCADE,
PRIMARY KEY (id),
UNIQUE(app_id, hashed_secret)
);
COMMENT ON COLUMN oauth2_provider_app_secrets.display_secret IS 'The tail end of the original secret so secrets can be differentiated.';

View File

@ -0,0 +1,21 @@
INSERT INTO oauth2_provider_apps
(id, created_at, updated_at, name, icon, callback_url)
VALUES (
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
'2023-06-15 10:23:54+00',
'2023-06-15 10:23:54+00',
'oauth2-app',
'/some/icon.svg',
'http://coder.com/oauth2/callback'
);
INSERT INTO oauth2_provider_app_secrets
(id, created_at, last_used_at, hashed_secret, display_secret, app_id)
VALUES (
'b0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
'2023-06-15 10:25:33+00',
'2023-12-15 11:40:20+00',
CAST('abcdefg' AS bytea),
'fg',
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'
);

View File

@ -1788,6 +1788,26 @@ type License struct {
UUID uuid.UUID `db:"uuid" json:"uuid"`
}
// A table used to configure apps that can use Coder as an OAuth2 provider, the reverse of what we are calling external authentication.
type OAuth2ProviderApp struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Name string `db:"name" json:"name"`
Icon string `db:"icon" json:"icon"`
CallbackURL string `db:"callback_url" json:"callback_url"`
}
type OAuth2ProviderAppSecret struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
LastUsedAt sql.NullTime `db:"last_used_at" json:"last_used_at"`
HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"`
// The tail end of the original secret so secrets can be differentiated.
DisplaySecret string `db:"display_secret" json:"display_secret"`
AppID uuid.UUID `db:"app_id" json:"app_id"`
}
type Organization struct {
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`

View File

@ -57,6 +57,8 @@ type sqlcQuerier interface {
DeleteGroupMemberFromGroup(ctx context.Context, arg DeleteGroupMemberFromGroupParams) error
DeleteGroupMembersByOrgAndUser(ctx context.Context, arg DeleteGroupMembersByOrgAndUserParams) error
DeleteLicense(ctx context.Context, id int32) (int32, error)
DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error
DeleteOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) error
// Delete provisioner daemons that have been created at least a week ago
// and have not connected to coderd since a week.
// A provisioner daemon with "zeroed" last_seen_at column indicates possible
@ -122,6 +124,10 @@ type sqlcQuerier interface {
GetLicenseByID(ctx context.Context, id int32) (License, error)
GetLicenses(ctx context.Context) ([]License, error)
GetLogoURL(ctx context.Context) (string, error)
GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error)
GetOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppSecret, error)
GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, appID uuid.UUID) ([]OAuth2ProviderAppSecret, error)
GetOAuth2ProviderApps(ctx context.Context) ([]OAuth2ProviderApp, error)
GetOAuthSigningKey(ctx context.Context) (string, error)
GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error)
GetOrganizationByName(ctx context.Context, name string) (Organization, error)
@ -275,6 +281,8 @@ type sqlcQuerier interface {
// values for avatar, display name, and quota allowance (all zero values).
// If the name conflicts, do nothing.
InsertMissingGroups(ctx context.Context, arg InsertMissingGroupsParams) ([]Group, error)
InsertOAuth2ProviderApp(ctx context.Context, arg InsertOAuth2ProviderAppParams) (OAuth2ProviderApp, error)
InsertOAuth2ProviderAppSecret(ctx context.Context, arg InsertOAuth2ProviderAppSecretParams) (OAuth2ProviderAppSecret, error)
InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error)
InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error)
InsertProvisionerJob(ctx context.Context, arg InsertProvisionerJobParams) (ProvisionerJob, error)
@ -318,6 +326,8 @@ type sqlcQuerier interface {
UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error)
UpdateInactiveUsersToDormant(ctx context.Context, arg UpdateInactiveUsersToDormantParams) ([]UpdateInactiveUsersToDormantRow, error)
UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error)
UpdateOAuth2ProviderAppByID(ctx context.Context, arg UpdateOAuth2ProviderAppByIDParams) (OAuth2ProviderApp, error)
UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg UpdateOAuth2ProviderAppSecretByIDParams) (OAuth2ProviderAppSecret, error)
UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg UpdateProvisionerDaemonLastSeenAtParams) error
UpdateProvisionerJobByID(ctx context.Context, arg UpdateProvisionerJobByIDParams) error
UpdateProvisionerJobWithCancelByID(ctx context.Context, arg UpdateProvisionerJobWithCancelByIDParams) error

View File

@ -2610,6 +2610,282 @@ func (q *sqlQuerier) TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock i
return pg_try_advisory_xact_lock, err
}
const deleteOAuth2ProviderAppByID = `-- name: DeleteOAuth2ProviderAppByID :exec
DELETE FROM oauth2_provider_apps WHERE id = $1
`
func (q *sqlQuerier) DeleteOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteOAuth2ProviderAppByID, id)
return err
}
const deleteOAuth2ProviderAppSecretByID = `-- name: DeleteOAuth2ProviderAppSecretByID :exec
DELETE FROM oauth2_provider_app_secrets WHERE id = $1
`
func (q *sqlQuerier) DeleteOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteOAuth2ProviderAppSecretByID, id)
return err
}
const getOAuth2ProviderAppByID = `-- name: GetOAuth2ProviderAppByID :one
SELECT id, created_at, updated_at, name, icon, callback_url FROM oauth2_provider_apps WHERE id = $1
`
func (q *sqlQuerier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) {
row := q.db.QueryRowContext(ctx, getOAuth2ProviderAppByID, id)
var i OAuth2ProviderApp
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Name,
&i.Icon,
&i.CallbackURL,
)
return i, err
}
const getOAuth2ProviderAppSecretByID = `-- name: GetOAuth2ProviderAppSecretByID :one
SELECT id, created_at, last_used_at, hashed_secret, display_secret, app_id FROM oauth2_provider_app_secrets WHERE id = $1
`
func (q *sqlQuerier) GetOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppSecret, error) {
row := q.db.QueryRowContext(ctx, getOAuth2ProviderAppSecretByID, id)
var i OAuth2ProviderAppSecret
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.LastUsedAt,
&i.HashedSecret,
&i.DisplaySecret,
&i.AppID,
)
return i, err
}
const getOAuth2ProviderAppSecretsByAppID = `-- name: GetOAuth2ProviderAppSecretsByAppID :many
SELECT id, created_at, last_used_at, hashed_secret, display_secret, app_id FROM oauth2_provider_app_secrets WHERE app_id = $1 ORDER BY (created_at, id) ASC
`
func (q *sqlQuerier) GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, appID uuid.UUID) ([]OAuth2ProviderAppSecret, error) {
rows, err := q.db.QueryContext(ctx, getOAuth2ProviderAppSecretsByAppID, appID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []OAuth2ProviderAppSecret
for rows.Next() {
var i OAuth2ProviderAppSecret
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.LastUsedAt,
&i.HashedSecret,
&i.DisplaySecret,
&i.AppID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getOAuth2ProviderApps = `-- name: GetOAuth2ProviderApps :many
SELECT id, created_at, updated_at, name, icon, callback_url FROM oauth2_provider_apps ORDER BY (name, id) ASC
`
func (q *sqlQuerier) GetOAuth2ProviderApps(ctx context.Context) ([]OAuth2ProviderApp, error) {
rows, err := q.db.QueryContext(ctx, getOAuth2ProviderApps)
if err != nil {
return nil, err
}
defer rows.Close()
var items []OAuth2ProviderApp
for rows.Next() {
var i OAuth2ProviderApp
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Name,
&i.Icon,
&i.CallbackURL,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const insertOAuth2ProviderApp = `-- name: InsertOAuth2ProviderApp :one
INSERT INTO oauth2_provider_apps (
id,
created_at,
updated_at,
name,
icon,
callback_url
) VALUES(
$1,
$2,
$3,
$4,
$5,
$6
) RETURNING id, created_at, updated_at, name, icon, callback_url
`
type InsertOAuth2ProviderAppParams struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Name string `db:"name" json:"name"`
Icon string `db:"icon" json:"icon"`
CallbackURL string `db:"callback_url" json:"callback_url"`
}
func (q *sqlQuerier) InsertOAuth2ProviderApp(ctx context.Context, arg InsertOAuth2ProviderAppParams) (OAuth2ProviderApp, error) {
row := q.db.QueryRowContext(ctx, insertOAuth2ProviderApp,
arg.ID,
arg.CreatedAt,
arg.UpdatedAt,
arg.Name,
arg.Icon,
arg.CallbackURL,
)
var i OAuth2ProviderApp
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Name,
&i.Icon,
&i.CallbackURL,
)
return i, err
}
const insertOAuth2ProviderAppSecret = `-- name: InsertOAuth2ProviderAppSecret :one
INSERT INTO oauth2_provider_app_secrets (
id,
created_at,
hashed_secret,
display_secret,
app_id
) VALUES(
$1,
$2,
$3,
$4,
$5
) RETURNING id, created_at, last_used_at, hashed_secret, display_secret, app_id
`
type InsertOAuth2ProviderAppSecretParams struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"`
DisplaySecret string `db:"display_secret" json:"display_secret"`
AppID uuid.UUID `db:"app_id" json:"app_id"`
}
func (q *sqlQuerier) InsertOAuth2ProviderAppSecret(ctx context.Context, arg InsertOAuth2ProviderAppSecretParams) (OAuth2ProviderAppSecret, error) {
row := q.db.QueryRowContext(ctx, insertOAuth2ProviderAppSecret,
arg.ID,
arg.CreatedAt,
arg.HashedSecret,
arg.DisplaySecret,
arg.AppID,
)
var i OAuth2ProviderAppSecret
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.LastUsedAt,
&i.HashedSecret,
&i.DisplaySecret,
&i.AppID,
)
return i, err
}
const updateOAuth2ProviderAppByID = `-- name: UpdateOAuth2ProviderAppByID :one
UPDATE oauth2_provider_apps SET
updated_at = $2,
name = $3,
icon = $4,
callback_url = $5
WHERE id = $1 RETURNING id, created_at, updated_at, name, icon, callback_url
`
type UpdateOAuth2ProviderAppByIDParams struct {
ID uuid.UUID `db:"id" json:"id"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Name string `db:"name" json:"name"`
Icon string `db:"icon" json:"icon"`
CallbackURL string `db:"callback_url" json:"callback_url"`
}
func (q *sqlQuerier) UpdateOAuth2ProviderAppByID(ctx context.Context, arg UpdateOAuth2ProviderAppByIDParams) (OAuth2ProviderApp, error) {
row := q.db.QueryRowContext(ctx, updateOAuth2ProviderAppByID,
arg.ID,
arg.UpdatedAt,
arg.Name,
arg.Icon,
arg.CallbackURL,
)
var i OAuth2ProviderApp
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Name,
&i.Icon,
&i.CallbackURL,
)
return i, err
}
const updateOAuth2ProviderAppSecretByID = `-- name: UpdateOAuth2ProviderAppSecretByID :one
UPDATE oauth2_provider_app_secrets SET
last_used_at = $2
WHERE id = $1 RETURNING id, created_at, last_used_at, hashed_secret, display_secret, app_id
`
type UpdateOAuth2ProviderAppSecretByIDParams struct {
ID uuid.UUID `db:"id" json:"id"`
LastUsedAt sql.NullTime `db:"last_used_at" json:"last_used_at"`
}
func (q *sqlQuerier) UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg UpdateOAuth2ProviderAppSecretByIDParams) (OAuth2ProviderAppSecret, error) {
row := q.db.QueryRowContext(ctx, updateOAuth2ProviderAppSecretByID, arg.ID, arg.LastUsedAt)
var i OAuth2ProviderAppSecret
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.LastUsedAt,
&i.HashedSecret,
&i.DisplaySecret,
&i.AppID,
)
return i, err
}
const getOrganizationIDsByMemberIDs = `-- name: GetOrganizationIDsByMemberIDs :many
SELECT
user_id, array_agg(organization_id) :: uuid [ ] AS "organization_IDs"

View File

@ -0,0 +1,62 @@
-- name: GetOAuth2ProviderApps :many
SELECT * FROM oauth2_provider_apps ORDER BY (name, id) ASC;
-- name: GetOAuth2ProviderAppByID :one
SELECT * FROM oauth2_provider_apps WHERE id = $1;
-- name: InsertOAuth2ProviderApp :one
INSERT INTO oauth2_provider_apps (
id,
created_at,
updated_at,
name,
icon,
callback_url
) VALUES(
$1,
$2,
$3,
$4,
$5,
$6
) RETURNING *;
-- name: UpdateOAuth2ProviderAppByID :one
UPDATE oauth2_provider_apps SET
updated_at = $2,
name = $3,
icon = $4,
callback_url = $5
WHERE id = $1 RETURNING *;
-- name: DeleteOAuth2ProviderAppByID :exec
DELETE FROM oauth2_provider_apps WHERE id = $1;
-- name: GetOAuth2ProviderAppSecretByID :one
SELECT * FROM oauth2_provider_app_secrets WHERE id = $1;
-- name: GetOAuth2ProviderAppSecretsByAppID :many
SELECT * FROM oauth2_provider_app_secrets WHERE app_id = $1 ORDER BY (created_at, id) ASC;
-- name: InsertOAuth2ProviderAppSecret :one
INSERT INTO oauth2_provider_app_secrets (
id,
created_at,
hashed_secret,
display_secret,
app_id
) VALUES(
$1,
$2,
$3,
$4,
$5
) RETURNING *;
-- name: UpdateOAuth2ProviderAppSecretByID :one
UPDATE oauth2_provider_app_secrets SET
last_used_at = $2
WHERE id = $1 RETURNING *;
-- name: DeleteOAuth2ProviderAppSecretByID :exec
DELETE FROM oauth2_provider_app_secrets WHERE id = $1;

View File

@ -80,6 +80,9 @@ overrides:
template_ids: TemplateIDs
active_user_ids: ActiveUserIDs
display_app_ssh_helper: DisplayAppSSHHelper
oauth2_provider_app: OAuth2ProviderApp
oauth2_provider_app_secret: OAuth2ProviderAppSecret
callback_url: CallbackURL
sql:
- schema: "./dump.sql"

View File

@ -21,6 +21,10 @@ const (
UniqueGroupsPkey UniqueConstraint = "groups_pkey" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_pkey PRIMARY KEY (id);
UniqueLicensesJWTKey UniqueConstraint = "licenses_jwt_key" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt);
UniqueLicensesPkey UniqueConstraint = "licenses_pkey" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_pkey PRIMARY KEY (id);
UniqueOauth2ProviderAppSecretsAppIDHashedSecretKey UniqueConstraint = "oauth2_provider_app_secrets_app_id_hashed_secret_key" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_app_id_hashed_secret_key UNIQUE (app_id, hashed_secret);
UniqueOauth2ProviderAppSecretsPkey UniqueConstraint = "oauth2_provider_app_secrets_pkey" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_pkey PRIMARY KEY (id);
UniqueOauth2ProviderAppsNameKey UniqueConstraint = "oauth2_provider_apps_name_key" // ALTER TABLE ONLY oauth2_provider_apps ADD CONSTRAINT oauth2_provider_apps_name_key UNIQUE (name);
UniqueOauth2ProviderAppsPkey UniqueConstraint = "oauth2_provider_apps_pkey" // ALTER TABLE ONLY oauth2_provider_apps ADD CONSTRAINT oauth2_provider_apps_pkey PRIMARY KEY (id);
UniqueOrganizationMembersPkey UniqueConstraint = "organization_members_pkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_pkey PRIMARY KEY (organization_id, user_id);
UniqueOrganizationsPkey UniqueConstraint = "organizations_pkey" // ALTER TABLE ONLY organizations ADD CONSTRAINT organizations_pkey PRIMARY KEY (id);
UniqueParameterSchemasJobIDNameKey UniqueConstraint = "parameter_schemas_job_id_name_key" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_job_id_name_key UNIQUE (job_id, name);

View File

@ -46,7 +46,7 @@ func init() {
valid := NameValid(str)
return valid == nil
}
for _, tag := range []string{"username", "template_name", "workspace_name"} {
for _, tag := range []string{"username", "template_name", "workspace_name", "oauth2_app_name"} {
err := Validate.RegisterValidation(tag, nameValidator)
if err != nil {
panic(err)

View File

@ -8,6 +8,7 @@ import (
"golang.org/x/oauth2"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand"
@ -178,3 +179,96 @@ func ExtractOAuth2(config OAuth2Config, client *http.Client, authURLOpts map[str
})
}
}
type (
oauth2ProviderAppParamContextKey struct{}
oauth2ProviderAppSecretParamContextKey struct{}
)
// OAuth2ProviderApp returns the OAuth2 app from the ExtractOAuth2ProviderAppParam handler.
func OAuth2ProviderApp(r *http.Request) database.OAuth2ProviderApp {
app, ok := r.Context().Value(oauth2ProviderAppParamContextKey{}).(database.OAuth2ProviderApp)
if !ok {
panic("developer error: oauth2 app param middleware not provided")
}
return app
}
// ExtractOAuth2ProviderApp grabs an OAuth2 app from the "app" URL parameter. This
// middleware requires the API key middleware higher in the call stack for
// authentication.
func ExtractOAuth2ProviderApp(db database.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
appID, ok := ParseUUIDParam(rw, r, "app")
if !ok {
return
}
app, err := db.GetOAuth2ProviderAppByID(ctx, appID)
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching OAuth2 app.",
Detail: err.Error(),
})
return
}
ctx = context.WithValue(ctx, oauth2ProviderAppParamContextKey{}, app)
next.ServeHTTP(rw, r.WithContext(ctx))
})
}
}
// OAuth2ProviderAppSecret returns the OAuth2 app secret from the
// ExtractOAuth2ProviderAppSecretParam handler.
func OAuth2ProviderAppSecret(r *http.Request) database.OAuth2ProviderAppSecret {
app, ok := r.Context().Value(oauth2ProviderAppSecretParamContextKey{}).(database.OAuth2ProviderAppSecret)
if !ok {
panic("developer error: oauth2 app secret param middleware not provided")
}
return app
}
// ExtractOAuth2ProviderAppSecret grabs an OAuth2 app secret from the "app" and
// "secret" URL parameters. This middleware requires the ExtractOAuth2ProviderApp
// middleware higher in the stack
func ExtractOAuth2ProviderAppSecret(db database.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
secretID, ok := ParseUUIDParam(rw, r, "secretID")
if !ok {
return
}
app := OAuth2ProviderApp(r)
secret, err := db.GetOAuth2ProviderAppSecretByID(ctx, secretID)
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching OAuth2 app secret.",
Detail: err.Error(),
})
return
}
// If the user can read the secret they can probably also read the app it
// belongs to and they can read this app as well, so it seems safe to give
// them a more helpful message than a 404 on mismatches.
if app.ID != secret.AppID {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "App ID does not match secret app ID.",
})
return
}
ctx = context.WithValue(ctx, oauth2ProviderAppSecretParamContextKey{}, secret)
next.ServeHTTP(rw, r.WithContext(ctx))
})
}
}

View File

@ -198,6 +198,22 @@ var (
ResourceTemplateInsights = Object{
Type: "template_insights",
}
// ResourceOAuth2ProviderApp CRUD.
// create/delete = Make or delete an OAuth2 app.
// update = Update the properties of the OAuth2 app.
// read = Read OAuth2 apps.
ResourceOAuth2ProviderApp = Object{
Type: "oauth2_app",
}
// ResourceOAuth2ProviderAppSecrets CRUD.
// create/delete = Make or delete an OAuth2 app secret.
// update = Update last used date.
// read = Read OAuth2 app hashed or truncated secret.
ResourceOAuth2ProviderAppSecret = Object{
Type: "oauth2_app_secrets",
}
)
// ResourceUserObject is a helper function to create a user object for authz checks.

View File

@ -11,6 +11,8 @@ func AllResources() []Object {
ResourceFile,
ResourceGroup,
ResourceLicense,
ResourceOAuth2ProviderApp,
ResourceOAuth2ProviderAppSecret,
ResourceOrgRoleAssignment,
ResourceOrganization,
ResourceOrganizationMember,

View File

@ -50,6 +50,7 @@ const (
FeatureExternalTokenEncryption FeatureName = "external_token_encryption"
FeatureWorkspaceBatchActions FeatureName = "workspace_batch_actions"
FeatureAccessControl FeatureName = "access_control"
FeatureOAuth2Provider FeatureName = "oauth2_provider"
)
// FeatureNames must be kept in-sync with the Feature enum above.
@ -69,6 +70,7 @@ var FeatureNames = []FeatureName{
FeatureExternalTokenEncryption,
FeatureWorkspaceBatchActions,
FeatureAccessControl,
FeatureOAuth2Provider,
}
// Humanize returns the feature name in a human-readable format.
@ -78,6 +80,8 @@ func (n FeatureName) Humanize() string {
return "Template RBAC"
case FeatureSCIM:
return "SCIM"
case FeatureOAuth2Provider:
return "OAuth Provider"
default:
return strings.Title(strings.ReplaceAll(string(n), "_", " "))
}

158
codersdk/oauth2.go Normal file
View File

@ -0,0 +1,158 @@
package codersdk
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/google/uuid"
)
type OAuth2ProviderApp struct {
ID uuid.UUID `json:"id" format:"uuid"`
Name string `json:"name"`
CallbackURL string `json:"callback_url"`
Icon string `json:"icon"`
}
// OAuth2ProviderApps returns the applications configured to authenticate using
// Coder as an OAuth2 provider.
func (c *Client) OAuth2ProviderApps(ctx context.Context) ([]OAuth2ProviderApp, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/oauth2-provider/apps", nil)
if err != nil {
return []OAuth2ProviderApp{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return []OAuth2ProviderApp{}, ReadBodyAsError(res)
}
var apps []OAuth2ProviderApp
return apps, json.NewDecoder(res.Body).Decode(&apps)
}
// OAuth2ProviderApp returns an application configured to authenticate using
// Coder as an OAuth2 provider.
func (c *Client) OAuth2ProviderApp(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s", id), nil)
if err != nil {
return OAuth2ProviderApp{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return OAuth2ProviderApp{}, ReadBodyAsError(res)
}
var apps OAuth2ProviderApp
return apps, json.NewDecoder(res.Body).Decode(&apps)
}
type PostOAuth2ProviderAppRequest struct {
Name string `json:"name" validate:"required,oauth2_app_name"`
CallbackURL string `json:"callback_url" validate:"required,http_url"`
Icon string `json:"icon" validate:"omitempty"`
}
// PostOAuth2ProviderApp adds an application that can authenticate using Coder
// as an OAuth2 provider.
func (c *Client) PostOAuth2ProviderApp(ctx context.Context, app PostOAuth2ProviderAppRequest) (OAuth2ProviderApp, error) {
res, err := c.Request(ctx, http.MethodPost, "/api/v2/oauth2-provider/apps", app)
if err != nil {
return OAuth2ProviderApp{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return OAuth2ProviderApp{}, ReadBodyAsError(res)
}
var resp OAuth2ProviderApp
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
type PutOAuth2ProviderAppRequest struct {
Name string `json:"name" validate:"required,oauth2_app_name"`
CallbackURL string `json:"callback_url" validate:"required,http_url"`
Icon string `json:"icon" validate:"omitempty"`
}
// PutOAuth2ProviderApp updates an application that can authenticate using Coder
// as an OAuth2 provider.
func (c *Client) PutOAuth2ProviderApp(ctx context.Context, id uuid.UUID, app PutOAuth2ProviderAppRequest) (OAuth2ProviderApp, error) {
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s", id), app)
if err != nil {
return OAuth2ProviderApp{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return OAuth2ProviderApp{}, ReadBodyAsError(res)
}
var resp OAuth2ProviderApp
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// DeleteOAuth2ProviderApp deletes an application, also invalidating any tokens
// that were generated from it.
func (c *Client) DeleteOAuth2ProviderApp(ctx context.Context, id uuid.UUID) error {
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s", id), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
type OAuth2ProviderAppSecretFull struct {
ID uuid.UUID `json:"id" format:"uuid"`
ClientSecretFull string `json:"client_secret_full"`
}
type OAuth2ProviderAppSecret struct {
ID uuid.UUID `json:"id" format:"uuid"`
LastUsedAt NullTime `json:"last_used_at"`
ClientSecretTruncated string `json:"client_secret_truncated"`
}
// OAuth2ProviderAppSecrets returns the truncated secrets for an OAuth2
// application.
func (c *Client) OAuth2ProviderAppSecrets(ctx context.Context, appID uuid.UUID) ([]OAuth2ProviderAppSecret, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/secrets", appID), nil)
if err != nil {
return []OAuth2ProviderAppSecret{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return []OAuth2ProviderAppSecret{}, ReadBodyAsError(res)
}
var resp []OAuth2ProviderAppSecret
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// PostOAuth2ProviderAppSecret creates a new secret for an OAuth2 application.
// This is the only time the full secret will be revealed.
func (c *Client) PostOAuth2ProviderAppSecret(ctx context.Context, appID uuid.UUID) (OAuth2ProviderAppSecretFull, error) {
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/secrets", appID), nil)
if err != nil {
return OAuth2ProviderAppSecretFull{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return OAuth2ProviderAppSecretFull{}, ReadBodyAsError(res)
}
var resp OAuth2ProviderAppSecretFull
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// DeleteOAuth2ProviderAppSecret deletes a secret from an OAuth2 application,
// also invalidating any tokens that generated from it.
func (c *Client) DeleteOAuth2ProviderAppSecret(ctx context.Context, appID uuid.UUID, secretID uuid.UUID) error {
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/secrets/%s", appID, secretID), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}

346
docs/api/enterprise.md generated
View File

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

90
docs/api/schemas.md generated
View File

@ -3539,6 +3539,60 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `client_secret` | string | false | | |
| `enterprise_base_url` | string | false | | |
## codersdk.OAuth2ProviderApp
```json
{
"callback_url": "string",
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| -------------- | ------ | -------- | ------------ | ----------- |
| `callback_url` | string | false | | |
| `icon` | string | false | | |
| `id` | string | false | | |
| `name` | string | false | | |
## codersdk.OAuth2ProviderAppSecret
```json
{
"client_secret_truncated": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_used_at": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------------------- | ------ | -------- | ------------ | ----------- |
| `client_secret_truncated` | string | false | | |
| `id` | string | false | | |
| `last_used_at` | string | false | | |
## codersdk.OAuth2ProviderAppSecretFull
```json
{
"client_secret_full": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| -------------------- | ------ | -------- | ------------ | ----------- |
| `client_secret_full` | string | false | | |
| `id` | string | false | | |
## codersdk.OAuthConversionResponse
```json
@ -3756,6 +3810,24 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `name` | string | true | | |
| `regenerate_token` | boolean | false | | |
## codersdk.PostOAuth2ProviderAppRequest
```json
{
"callback_url": "string",
"icon": "string",
"name": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| -------------- | ------ | -------- | ------------ | ----------- |
| `callback_url` | string | true | | |
| `icon` | string | false | | |
| `name` | string | true | | |
## codersdk.PprofConfig
```json
@ -4035,6 +4107,24 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| ---------- | ------ | -------- | ------------ | ----------- |
| `deadline` | string | true | | |
## codersdk.PutOAuth2ProviderAppRequest
```json
{
"callback_url": "string",
"icon": "string",
"name": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| -------------- | ------ | -------- | ------------ | ----------- |
| `callback_url` | string | true | | |
| `icon` | string | false | | |
| `name` | string | true | | |
## codersdk.RBACResource
```json

View File

@ -311,6 +311,33 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
r.Get("/", api.userQuietHoursSchedule)
r.Put("/", api.putUserQuietHoursSchedule)
})
r.Route("/oauth2-provider", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
api.oAuth2ProviderMiddleware,
)
r.Route("/apps", func(r chi.Router) {
r.Get("/", api.oAuth2ProviderApps)
r.Post("/", api.postOAuth2ProviderApp)
r.Route("/{app}", func(r chi.Router) {
r.Use(httpmw.ExtractOAuth2ProviderApp(options.Database))
r.Get("/", api.oAuth2ProviderApp)
r.Put("/", api.putOAuth2ProviderApp)
r.Delete("/", api.deleteOAuth2ProviderApp)
r.Route("/secrets", func(r chi.Router) {
r.Get("/", api.oAuth2ProviderAppSecrets)
r.Post("/", api.postOAuth2ProviderAppSecret)
r.Route("/{secretID}", func(r chi.Router) {
r.Use(httpmw.ExtractOAuth2ProviderAppSecret(options.Database))
r.Delete("/", api.deleteOAuth2ProviderAppSecret)
})
})
})
})
})
})
if len(options.SCIMAPIKey) != 0 {
@ -487,6 +514,7 @@ func (api *API) updateEntitlements(ctx context.Context) error {
codersdk.FeatureBrowserOnly: api.BrowserOnly,
codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0,
codersdk.FeatureMultipleExternalAuth: len(api.ExternalAuthConfigs) > 1,
codersdk.FeatureOAuth2Provider: true,
codersdk.FeatureTemplateRBAC: api.RBAC,
codersdk.FeatureExternalTokenEncryption: len(api.ExternalTokenEncryption) > 0,
codersdk.FeatureExternalProvisionerDaemons: true,

255
enterprise/coderd/oauth2.go Normal file
View File

@ -0,0 +1,255 @@
package coderd
import (
"crypto/sha256"
"net/http"
"github.com/google/uuid"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand"
)
func (api *API) oAuth2ProviderMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if !buildinfo.IsDev() {
httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{
Message: "OAuth2 provider is under development.",
})
return
}
api.entitlementsMu.RLock()
entitled := api.entitlements.Features[codersdk.FeatureOAuth2Provider].Entitlement != codersdk.EntitlementNotEntitled
api.entitlementsMu.RUnlock()
if !entitled {
httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{
Message: "OAuth2 provider is an Enterprise feature. Contact sales!",
})
return
}
next.ServeHTTP(rw, r)
})
}
// @Summary Get OAuth2 applications.
// @ID get-oauth2-applications
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Success 200 {array} codersdk.OAuth2ProviderApp
// @Router /oauth2-provider/apps [get]
func (api *API) oAuth2ProviderApps(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
dbApps, err := api.Database.GetOAuth2ProviderApps(ctx)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApps(dbApps))
}
// @Summary Get OAuth2 application.
// @ID get-oauth2-application
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Param app path string true "App ID"
// @Success 200 {object} codersdk.OAuth2ProviderApp
// @Router /oauth2-provider/apps/{app} [get]
func (*API) oAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
app := httpmw.OAuth2ProviderApp(r)
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApp(app))
}
// @Summary Create OAuth2 application.
// @ID create-oauth2-application
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Enterprise
// @Param request body codersdk.PostOAuth2ProviderAppRequest true "The OAuth2 application to create."
// @Success 200 {object} codersdk.OAuth2ProviderApp
// @Router /oauth2-provider/apps [post]
func (api *API) postOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req codersdk.PostOAuth2ProviderAppRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
app, err := api.Database.InsertOAuth2ProviderApp(ctx, database.InsertOAuth2ProviderAppParams{
ID: uuid.New(),
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
Name: req.Name,
Icon: req.Icon,
CallbackURL: req.CallbackURL,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error creating OAuth2 application.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.OAuth2ProviderApp(app))
}
// @Summary Update OAuth2 application.
// @ID update-oauth2-application
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Enterprise
// @Param app path string true "App ID"
// @Param request body codersdk.PutOAuth2ProviderAppRequest true "Update an OAuth2 application."
// @Success 200 {object} codersdk.OAuth2ProviderApp
// @Router /oauth2-provider/apps/{app} [put]
func (api *API) putOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
app := httpmw.OAuth2ProviderApp(r)
var req codersdk.PutOAuth2ProviderAppRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
app, err := api.Database.UpdateOAuth2ProviderAppByID(ctx, database.UpdateOAuth2ProviderAppByIDParams{
ID: app.ID,
UpdatedAt: dbtime.Now(),
Name: req.Name,
Icon: req.Icon,
CallbackURL: req.CallbackURL,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error creating OAuth2 application.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApp(app))
}
// @Summary Delete OAuth2 application.
// @ID delete-oauth2-application
// @Security CoderSessionToken
// @Tags Enterprise
// @Param app path string true "App ID"
// @Success 204
// @Router /oauth2-provider/apps/{app} [delete]
func (api *API) deleteOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
app := httpmw.OAuth2ProviderApp(r)
err := api.Database.DeleteOAuth2ProviderAppByID(ctx, app.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error deleting OAuth2 application.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
}
// @Summary Get OAuth2 application secrets.
// @ID get-oauth2-application-secrets
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Param app path string true "App ID"
// @Success 200 {array} codersdk.OAuth2ProviderAppSecret
// @Router /oauth2-provider/apps/{app}/secrets [get]
func (api *API) oAuth2ProviderAppSecrets(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
app := httpmw.OAuth2ProviderApp(r)
dbSecrets, err := api.Database.GetOAuth2ProviderAppSecretsByAppID(ctx, app.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error getting OAuth2 client secrets.",
Detail: err.Error(),
})
return
}
secrets := []codersdk.OAuth2ProviderAppSecret{}
for _, secret := range dbSecrets {
secrets = append(secrets, codersdk.OAuth2ProviderAppSecret{
ID: secret.ID,
LastUsedAt: codersdk.NullTime{NullTime: secret.LastUsedAt},
ClientSecretTruncated: secret.DisplaySecret,
})
}
httpapi.Write(ctx, rw, http.StatusOK, secrets)
}
// @Summary Create OAuth2 application secret.
// @ID create-oauth2-application-secret
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Param app path string true "App ID"
// @Success 200 {array} codersdk.OAuth2ProviderAppSecretFull
// @Router /oauth2-provider/apps/{app}/secrets [post]
func (api *API) postOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
app := httpmw.OAuth2ProviderApp(r)
// 40 characters matches the length of GitHub's client secrets.
rawSecret, err := cryptorand.String(40)
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to generate OAuth2 client secret.",
})
return
}
hashed := sha256.Sum256([]byte(rawSecret))
secret, err := api.Database.InsertOAuth2ProviderAppSecret(ctx, database.InsertOAuth2ProviderAppSecretParams{
ID: uuid.New(),
CreatedAt: dbtime.Now(),
HashedSecret: hashed[:],
// DisplaySecret is the last six characters of the original unhashed secret.
// This is done so they can be differentiated and it matches how GitHub
// displays their client secrets.
DisplaySecret: rawSecret[len(rawSecret)-6:],
AppID: app.ID,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error creating OAuth2 client secret.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.OAuth2ProviderAppSecretFull{
ID: secret.ID,
ClientSecretFull: rawSecret,
})
}
// @Summary Delete OAuth2 application secret.
// @ID delete-oauth2-application-secret
// @Security CoderSessionToken
// @Tags Enterprise
// @Param app path string true "App ID"
// @Param secretID path string true "Secret ID"
// @Success 204
// @Router /oauth2-provider/apps/{app}/secrets/{secretID} [delete]
func (api *API) deleteOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
secret := httpmw.OAuth2ProviderAppSecret(r)
err := api.Database.DeleteOAuth2ProviderAppSecretByID(ctx, secret.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error deleting OAuth2 client secret.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
}

View File

@ -0,0 +1,367 @@
package coderd_test
import (
"strconv"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/testutil"
)
func TestOAuthApps(t *testing.T) {
t.Parallel()
t.Run("Validation", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureOAuth2Provider: 1,
},
}})
ctx := testutil.Context(t, testutil.WaitLong)
tests := []struct {
name string
req codersdk.PostOAuth2ProviderAppRequest
}{
{
name: "NameMissing",
req: codersdk.PostOAuth2ProviderAppRequest{
CallbackURL: "http://localhost:3000",
},
},
{
name: "NameSpaces",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "foo bar",
CallbackURL: "http://localhost:3000",
},
},
{
name: "NameTooLong",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "too loooooooooooooooooooooooooong",
CallbackURL: "http://localhost:3000",
},
},
{
name: "NameTaken",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "taken",
CallbackURL: "http://localhost:3000",
},
},
{
name: "URLMissing",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "foo",
},
},
{
name: "URLLocalhostNoScheme",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "foo",
CallbackURL: "localhost:3000",
},
},
{
name: "URLNoScheme",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "foo",
CallbackURL: "coder.com",
},
},
{
name: "URLNoColon",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "foo",
CallbackURL: "http//coder",
},
},
{
name: "URLJustBar",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "foo",
CallbackURL: "bar",
},
},
{
name: "URLPathOnly",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "foo",
CallbackURL: "/bar/baz/qux",
},
},
{
name: "URLJustHttp",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "foo",
CallbackURL: "http",
},
},
{
name: "URLNoHost",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "foo",
CallbackURL: "http://",
},
},
{
name: "URLSpaces",
req: codersdk.PostOAuth2ProviderAppRequest{
Name: "foo",
CallbackURL: "bar baz qux",
},
},
}
// Generate an application for testing name conflicts.
req := codersdk.PostOAuth2ProviderAppRequest{
Name: "taken",
CallbackURL: "http://coder.com",
}
//nolint:gocritic // OAauth2 app management requires owner permission.
_, err := client.PostOAuth2ProviderApp(ctx, req)
require.NoError(t, err)
// Generate an application for testing PUTs.
req = codersdk.PostOAuth2ProviderAppRequest{
Name: "quark",
CallbackURL: "http://coder.com",
}
//nolint:gocritic // OAauth2 app management requires owner permission.
existingApp, err := client.PostOAuth2ProviderApp(ctx, req)
require.NoError(t, err)
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
//nolint:gocritic // OAauth2 app management requires owner permission.
_, err := client.PostOAuth2ProviderApp(ctx, test.req)
require.Error(t, err)
//nolint:gocritic // OAauth2 app management requires owner permission.
_, err = client.PutOAuth2ProviderApp(ctx, existingApp.ID, codersdk.PutOAuth2ProviderAppRequest{
Name: test.req.Name,
CallbackURL: test.req.CallbackURL,
})
require.Error(t, err)
})
}
})
t.Run("DeleteNonExisting", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureOAuth2Provider: 1,
},
}})
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // OAauth2 app management requires owner permission.
_, err := client.OAuth2ProviderApp(ctx, uuid.New())
require.Error(t, err)
})
t.Run("OK", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureOAuth2Provider: 1,
},
}})
ctx := testutil.Context(t, testutil.WaitLong)
// No apps yet.
//nolint:gocritic // OAauth2 app management requires owner permission.
apps, err := client.OAuth2ProviderApps(ctx)
require.NoError(t, err)
require.Len(t, apps, 0)
// Should be able to add apps.
expected := []codersdk.OAuth2ProviderApp{}
for i := 0; i < 5; i++ {
postReq := codersdk.PostOAuth2ProviderAppRequest{
Name: "foo-" + strconv.Itoa(i),
CallbackURL: "http://" + strconv.Itoa(i) + ".localhost:3000",
}
//nolint:gocritic // OAauth2 app management requires owner permission.
app, err := client.PostOAuth2ProviderApp(ctx, postReq)
require.NoError(t, err)
require.Equal(t, postReq.Name, app.Name)
require.Equal(t, postReq.CallbackURL, app.CallbackURL)
expected = append(expected, app)
}
// Should get all the apps now.
//nolint:gocritic // OAauth2 app management requires owner permission.
apps, err = client.OAuth2ProviderApps(ctx)
require.NoError(t, err)
require.Len(t, apps, 5)
require.Equal(t, expected, apps)
// Should be able to keep the same name when updating.
req := codersdk.PutOAuth2ProviderAppRequest{
Name: expected[0].Name,
CallbackURL: "http://coder.com",
Icon: "test",
}
//nolint:gocritic // OAauth2 app management requires owner permission.
newApp, err := client.PutOAuth2ProviderApp(ctx, expected[0].ID, req)
require.NoError(t, err)
require.Equal(t, req.Name, newApp.Name)
require.Equal(t, req.CallbackURL, newApp.CallbackURL)
require.Equal(t, req.Icon, newApp.Icon)
require.Equal(t, expected[0].ID, newApp.ID)
// Should be able to update name.
req = codersdk.PutOAuth2ProviderAppRequest{
Name: "new-foo",
CallbackURL: "http://coder.com",
Icon: "test",
}
//nolint:gocritic // OAauth2 app management requires owner permission.
newApp, err = client.PutOAuth2ProviderApp(ctx, expected[0].ID, req)
require.NoError(t, err)
require.Equal(t, req.Name, newApp.Name)
require.Equal(t, req.CallbackURL, newApp.CallbackURL)
require.Equal(t, req.Icon, newApp.Icon)
require.Equal(t, expected[0].ID, newApp.ID)
// Should be able to get a single app.
//nolint:gocritic // OAauth2 app management requires owner permission.
got, err := client.OAuth2ProviderApp(ctx, expected[0].ID)
require.NoError(t, err)
require.Equal(t, newApp, got)
// Should be able to delete an app.
//nolint:gocritic // OAauth2 app management requires owner permission.
err = client.DeleteOAuth2ProviderApp(ctx, expected[0].ID)
require.NoError(t, err)
// Should show the new count.
//nolint:gocritic // OAauth2 app management requires owner permission.
newApps, err := client.OAuth2ProviderApps(ctx)
require.NoError(t, err)
require.Len(t, newApps, 4)
require.Equal(t, expected[1:], newApps)
})
}
func TestOAuthAppSecrets(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureOAuth2Provider: 1,
},
}})
ctx := testutil.Context(t, testutil.WaitLong)
// Make some apps.
//nolint:gocritic // OAauth2 app management requires owner permission.
app1, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "razzle-dazzle",
CallbackURL: "http://localhost",
})
require.NoError(t, err)
//nolint:gocritic // OAauth2 app management requires owner permission.
app2, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "razzle-dazzle-the-sequel",
CallbackURL: "http://localhost",
})
require.NoError(t, err)
t.Run("DeleteNonExisting", func(t *testing.T) {
t.Parallel()
// Should not be able to create secrets for a non-existent app.
//nolint:gocritic // OAauth2 app management requires owner permission.
_, err = client.OAuth2ProviderAppSecrets(ctx, uuid.New())
require.Error(t, err)
// Should not be able to delete non-existing secrets when there is no app.
//nolint:gocritic // OAauth2 app management requires owner permission.
err = client.DeleteOAuth2ProviderAppSecret(ctx, uuid.New(), uuid.New())
require.Error(t, err)
// Should not be able to delete non-existing secrets when the app exists.
//nolint:gocritic // OAauth2 app management requires owner permission.
err = client.DeleteOAuth2ProviderAppSecret(ctx, app1.ID, uuid.New())
require.Error(t, err)
// Should not be able to delete an existing secret with the wrong app ID.
//nolint:gocritic // OAauth2 app management requires owner permission.
secret, err := client.PostOAuth2ProviderAppSecret(ctx, app2.ID)
require.NoError(t, err)
//nolint:gocritic // OAauth2 app management requires owner permission.
err = client.DeleteOAuth2ProviderAppSecret(ctx, app1.ID, secret.ID)
require.Error(t, err)
})
t.Run("OK", func(t *testing.T) {
t.Parallel()
// No secrets yet.
//nolint:gocritic // OAauth2 app management requires owner permission.
secrets, err := client.OAuth2ProviderAppSecrets(ctx, app1.ID)
require.NoError(t, err)
require.Len(t, secrets, 0)
// Should be able to create secrets.
for i := 0; i < 5; i++ {
//nolint:gocritic // OAauth2 app management requires owner permission.
secret, err := client.PostOAuth2ProviderAppSecret(ctx, app1.ID)
require.NoError(t, err)
require.NotEmpty(t, secret.ClientSecretFull)
require.True(t, len(secret.ClientSecretFull) > 6)
//nolint:gocritic // OAauth2 app management requires owner permission.
_, err = client.PostOAuth2ProviderAppSecret(ctx, app2.ID)
require.NoError(t, err)
}
// Should get secrets now, but only for the one app.
//nolint:gocritic // OAauth2 app management requires owner permission.
secrets, err = client.OAuth2ProviderAppSecrets(ctx, app1.ID)
require.NoError(t, err)
require.Len(t, secrets, 5)
for _, secret := range secrets {
require.Len(t, secret.ClientSecretTruncated, 6)
}
// Should be able to delete a secret.
//nolint:gocritic // OAauth2 app management requires owner permission.
err = client.DeleteOAuth2ProviderAppSecret(ctx, app1.ID, secrets[0].ID)
require.NoError(t, err)
secrets, err = client.OAuth2ProviderAppSecrets(ctx, app1.ID)
require.NoError(t, err)
require.Len(t, secrets, 4)
// No secrets once the app is deleted.
//nolint:gocritic // OAauth2 app management requires owner permission.
err = client.DeleteOAuth2ProviderApp(ctx, app1.ID)
require.NoError(t, err)
//nolint:gocritic // OAauth2 app management requires owner permission.
_, err = client.OAuth2ProviderAppSecrets(ctx, app1.ID)
require.Error(t, err)
})
}

View File

@ -120,6 +120,24 @@ const ExternalAuthSettingsPage = lazy(
"./pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPage"
),
);
const OAuth2AppsSettingsPage = lazy(
() =>
import(
"./pages/DeploySettingsPage/OAuth2AppsSettingsPage/OAuth2AppsSettingsPage"
),
);
const EditOAuth2AppPage = lazy(
() =>
import(
"./pages/DeploySettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPage"
),
);
const CreateOAuth2AppPage = lazy(
() =>
import(
"./pages/DeploySettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPage"
),
);
const NetworkSettingsPage = lazy(
() =>
import(
@ -315,6 +333,16 @@ export const AppRouter: FC = () => {
path="external-auth"
element={<ExternalAuthSettingsPage />}
/>
<Route path="oauth2-provider">
<Route index element={<NotFoundPage />} />
<Route path="apps">
<Route index element={<OAuth2AppsSettingsPage />} />
<Route path="add" element={<CreateOAuth2AppPage />} />
<Route path=":appId" element={<EditOAuth2AppPage />} />
</Route>
</Route>
<Route
path="workspace-proxies"
element={<WorkspaceProxyPage />}

View File

@ -960,6 +960,61 @@ export const unlinkExternalAuthProvider = async (
return resp.data;
};
export const getOAuth2ProviderApps = async (): Promise<
TypesGen.OAuth2ProviderApp[]
> => {
const resp = await axios.get(`/api/v2/oauth2-provider/apps`);
return resp.data;
};
export const getOAuth2ProviderApp = async (
id: string,
): Promise<TypesGen.OAuth2ProviderApp> => {
const resp = await axios.get(`/api/v2/oauth2-provider/apps/${id}`);
return resp.data;
};
export const postOAuth2ProviderApp = async (
data: TypesGen.PostOAuth2ProviderAppRequest,
): Promise<TypesGen.OAuth2ProviderApp> => {
const response = await axios.post(`/api/v2/oauth2-provider/apps`, data);
return response.data;
};
export const putOAuth2ProviderApp = async (
id: string,
data: TypesGen.PutOAuth2ProviderAppRequest,
): Promise<TypesGen.OAuth2ProviderApp> => {
const response = await axios.put(`/api/v2/oauth2-provider/apps/${id}`, data);
return response.data;
};
export const deleteOAuth2ProviderApp = async (id: string): Promise<void> => {
await axios.delete(`/api/v2/oauth2-provider/apps/${id}`);
};
export const getOAuth2ProviderAppSecrets = async (
id: string,
): Promise<TypesGen.OAuth2ProviderAppSecret[]> => {
const resp = await axios.get(`/api/v2/oauth2-provider/apps/${id}/secrets`);
return resp.data;
};
export const postOAuth2ProviderAppSecret = async (
id: string,
): Promise<TypesGen.OAuth2ProviderAppSecretFull> => {
const resp = await axios.post(`/api/v2/oauth2-provider/apps/${id}/secrets`);
return resp.data;
};
export const deleteOAuth2ProviderAppSecret = async (
appId: string,
secretId: string,
): Promise<void> => {
await axios.delete(
`/api/v2/oauth2-provider/apps/${appId}/secrets/${secretId}`,
);
};
export const getAuditLogs = async (
options: TypesGen.AuditLogsRequest,
): Promise<TypesGen.AuditLogResponse> => {

View File

@ -0,0 +1,93 @@
import type { QueryClient } from "react-query";
import * as API from "api/api";
import type * as TypesGen from "api/typesGenerated";
const appsKey = ["oauth2-provider", "apps"];
const appKey = (id: string) => appsKey.concat(id);
const appSecretsKey = (id: string) => appKey(id).concat("secrets");
export const getApps = () => {
return {
queryKey: appsKey,
queryFn: () => API.getOAuth2ProviderApps(),
};
};
export const getApp = (id: string) => {
return {
queryKey: appKey(id),
queryFn: () => API.getOAuth2ProviderApp(id),
};
};
export const postApp = (queryClient: QueryClient) => {
return {
mutationFn: API.postOAuth2ProviderApp,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: appsKey,
});
},
};
};
export const putApp = (queryClient: QueryClient) => {
return {
mutationFn: ({
id,
req,
}: {
id: string;
req: TypesGen.PutOAuth2ProviderAppRequest;
}) => API.putOAuth2ProviderApp(id, req),
onSuccess: async (app: TypesGen.OAuth2ProviderApp) => {
await queryClient.invalidateQueries({
queryKey: appKey(app.id),
});
},
};
};
export const deleteApp = (queryClient: QueryClient) => {
return {
mutationFn: API.deleteOAuth2ProviderApp,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: appsKey,
});
},
};
};
export const getAppSecrets = (id: string) => {
return {
queryKey: appSecretsKey(id),
queryFn: () => API.getOAuth2ProviderAppSecrets(id),
};
};
export const postAppSecret = (queryClient: QueryClient) => {
return {
mutationFn: API.postOAuth2ProviderAppSecret,
onSuccess: async (
_: TypesGen.OAuth2ProviderAppSecretFull,
appId: string,
) => {
await queryClient.invalidateQueries({
queryKey: appSecretsKey(appId),
});
},
};
};
export const deleteAppSecret = (queryClient: QueryClient) => {
return {
mutationFn: ({ appId, secretId }: { appId: string; secretId: string }) =>
API.deleteOAuth2ProviderAppSecret(appId, secretId),
onSuccess: async (_: void, { appId }: { appId: string }) => {
await queryClient.invalidateQueries({
queryKey: appSecretsKey(appId),
});
},
};
};

View File

@ -660,6 +660,27 @@ export interface OAuth2GithubConfig {
readonly enterprise_base_url: string;
}
// From codersdk/oauth2.go
export interface OAuth2ProviderApp {
readonly id: string;
readonly name: string;
readonly callback_url: string;
readonly icon: string;
}
// From codersdk/oauth2.go
export interface OAuth2ProviderAppSecret {
readonly id: string;
readonly last_used_at?: string;
readonly client_secret_truncated: string;
}
// From codersdk/oauth2.go
export interface OAuth2ProviderAppSecretFull {
readonly id: string;
readonly client_secret_full: string;
}
// From codersdk/users.go
export interface OAuthConversionResponse {
readonly state_string: string;
@ -750,6 +771,13 @@ export interface PatchWorkspaceProxy {
readonly regenerate_token: boolean;
}
// From codersdk/oauth2.go
export interface PostOAuth2ProviderAppRequest {
readonly name: string;
readonly callback_url: string;
readonly icon: string;
}
// From codersdk/deployment.go
export interface PprofConfig {
readonly enable: boolean;
@ -823,6 +851,13 @@ export interface PutExtendWorkspaceRequest {
readonly deadline: string;
}
// From codersdk/oauth2.go
export interface PutOAuth2ProviderAppRequest {
readonly name: string;
readonly callback_url: string;
readonly icon: string;
}
// From codersdk/deployment.go
export interface RateLimitConfig {
readonly disable_all: boolean;
@ -1799,6 +1834,7 @@ export type FeatureName =
| "external_token_encryption"
| "high_availability"
| "multiple_external_auth"
| "oauth2_provider"
| "scim"
| "template_rbac"
| "user_limit"
@ -1815,6 +1851,7 @@ export const FeatureNames: FeatureName[] = [
"external_token_encryption",
"high_availability",
"multiple_external_auth",
"oauth2_provider",
"scim",
"template_rbac",
"user_limit",

View File

@ -7,6 +7,7 @@ import Globe from "@mui/icons-material/PublicOutlined";
import HubOutlinedIcon from "@mui/icons-material/HubOutlined";
import VpnKeyOutlined from "@mui/icons-material/VpnKeyOutlined";
import MonitorHeartOutlined from "@mui/icons-material/MonitorHeartOutlined";
// import Token from "@mui/icons-material/Token";
import { type FC } from "react";
import { useDashboard } from "components/Dashboard/DashboardProvider";
import { GitIcon } from "components/Icons/GitIcon";
@ -35,6 +36,10 @@ export const Sidebar: FC = () => {
<SidebarNavItem href="external-auth" icon={GitIcon}>
External Authentication
</SidebarNavItem>
{/* Not exposing this yet since token exchange is not finished yet.
<SidebarNavItem href="oauth2-provider/apps" icon={Token}>
OAuth2 Applications
</SidebarNavItem>*/}
<SidebarNavItem href="network" icon={Globe}>
Network
</SidebarNavItem>

View File

@ -0,0 +1,40 @@
import { useMutation, useQueryClient } from "react-query";
import { postApp } from "api/queries/oauth2";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { FC } from "react";
import { useNavigate } from "react-router-dom";
import { CreateOAuth2AppPageView } from "./CreateOAuth2AppPageView";
import { pageTitle } from "utils/page";
import { Helmet } from "react-helmet-async";
const CreateOAuth2AppPage: FC = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const postAppMutation = useMutation(postApp(queryClient));
return (
<>
<Helmet>
<title>{pageTitle("New OAuth2 Application")}</title>
</Helmet>
<CreateOAuth2AppPageView
isUpdating={postAppMutation.isLoading}
error={postAppMutation.error}
createApp={async (req) => {
try {
const app = await postAppMutation.mutateAsync(req);
displaySuccess(
`Successfully added the OAuth2 application "${app.name}".`,
);
navigate(`/deployment/oauth2-provider/apps/${app.id}?created=true`);
} catch (ignore) {
displayError("Failed to create OAuth2 application");
}
}}
/>
</>
);
};
export default CreateOAuth2AppPage;

View File

@ -0,0 +1,45 @@
import type { Meta, StoryObj } from "@storybook/react";
import { mockApiError } from "testHelpers/entities";
import { CreateOAuth2AppPageView } from "./CreateOAuth2AppPageView";
const meta: Meta = {
title: "pages/DeploySettingsPage/CreateOAuth2AppPageView",
component: CreateOAuth2AppPageView,
};
export default meta;
type Story = StoryObj<typeof CreateOAuth2AppPageView>;
export const Updating: Story = {
args: {
isUpdating: true,
},
};
export const Error: Story = {
args: {
error: mockApiError({
message: "Validation failed",
validations: [
{
field: "name",
detail: "name error",
},
{
field: "callback_url",
detail: "url error",
},
{
field: "icon",
detail: "icon error",
},
],
}),
},
};
export const Default: Story = {
args: {
// Nothing.
},
};

View File

@ -0,0 +1,52 @@
import Button from "@mui/material/Button";
import KeyboardArrowLeft from "@mui/icons-material/KeyboardArrowLeft";
import { type FC } from "react";
import { Link } from "react-router-dom";
import type * as TypesGen from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Header } from "components/DeploySettingsLayout/Header";
import { Stack } from "components/Stack/Stack";
import { OAuth2AppForm } from "./OAuth2AppForm";
type CreateOAuth2AppProps = {
isUpdating: boolean;
createApp: (req: TypesGen.PostOAuth2ProviderAppRequest) => void;
error?: unknown;
};
export const CreateOAuth2AppPageView: FC<CreateOAuth2AppProps> = ({
isUpdating,
createApp,
error,
}) => {
return (
<>
<Stack
alignItems="baseline"
direction="row"
justifyContent="space-between"
>
<Header
title="Add an OAuth2 application"
description="Configure an application to use Coder as an OAuth2 provider."
/>
<Button
component={Link}
startIcon={<KeyboardArrowLeft />}
to="/deployment/oauth2-provider/apps"
>
All OAuth2 Applications
</Button>
</Stack>
<Stack>
{error ? <ErrorAlert error={error} /> : undefined}
<OAuth2AppForm
onSubmit={createApp}
isUpdating={isUpdating}
error={error}
/>
</Stack>
</>
);
};

View File

@ -0,0 +1,105 @@
import { useMutation, useQuery, useQueryClient } from "react-query";
import type * as TypesGen from "api/typesGenerated";
import * as oauth2 from "api/queries/oauth2";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { FC, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { EditOAuth2AppPageView } from "./EditOAuth2AppPageView";
import { pageTitle } from "utils/page";
import { Helmet } from "react-helmet-async";
const EditOAuth2AppPage: FC = () => {
const navigate = useNavigate();
const { appId } = useParams() as { appId: string };
// When a new secret is created it is returned with the full secret. This is
// the only time it will be visible. The secret list only returns a truncated
// version of the secret (for differentiation purposes). Once the user
// acknowledges the secret we will clear it from the state.
const [fullNewSecret, setFullNewSecret] =
useState<TypesGen.OAuth2ProviderAppSecretFull>();
const queryClient = useQueryClient();
const appQuery = useQuery(oauth2.getApp(appId));
const putAppMutation = useMutation(oauth2.putApp(queryClient));
const deleteAppMutation = useMutation(oauth2.deleteApp(queryClient));
const secretsQuery = useQuery(oauth2.getAppSecrets(appId));
const postSecretMutation = useMutation(oauth2.postAppSecret(queryClient));
const deleteSecretMutation = useMutation(oauth2.deleteAppSecret(queryClient));
return (
<>
<Helmet>
<title>{pageTitle("Edit OAuth2 Application")}</title>
</Helmet>
<EditOAuth2AppPageView
app={appQuery.data}
secrets={secretsQuery.data}
isLoadingApp={appQuery.isLoading}
isLoadingSecrets={secretsQuery.isLoading}
mutatingResource={{
updateApp: putAppMutation.isLoading,
deleteApp: deleteAppMutation.isLoading,
createSecret: postSecretMutation.isLoading,
deleteSecret: deleteSecretMutation.isLoading,
}}
fullNewSecret={fullNewSecret}
ackFullNewSecret={() => setFullNewSecret(undefined)}
error={
appQuery.error ||
putAppMutation.error ||
deleteAppMutation.error ||
secretsQuery.error ||
postSecretMutation.error ||
deleteSecretMutation.error
}
updateApp={async (req) => {
try {
await putAppMutation.mutateAsync({ id: appId, req });
// REVIEW: Maybe it is better to stay on the same page?
displaySuccess(
`Successfully updated the OAuth2 application "${req.name}".`,
);
navigate("/deployment/oauth2-provider/apps?updated=true");
} catch (ignore) {
displayError("Failed to update OAuth2 application");
}
}}
deleteApp={async (name) => {
try {
await deleteAppMutation.mutateAsync(appId);
displaySuccess(
`You have successfully deleted the OAuth2 application "${name}"`,
);
navigate("/deployment/oauth2-provider/apps?deleted=true");
} catch (error) {
displayError("Failed to delete OAuth2 application");
}
}}
generateAppSecret={async () => {
try {
const secret = await postSecretMutation.mutateAsync(appId);
displaySuccess("Successfully generated OAuth2 client secret");
setFullNewSecret(secret);
} catch (ignore) {
displayError("Failed to generate OAuth2 client secret");
}
}}
deleteAppSecret={async (secretId: string) => {
try {
await deleteSecretMutation.mutateAsync({ appId, secretId });
displaySuccess("Successfully deleted an OAuth2 client secret");
if (fullNewSecret?.id === secretId) {
setFullNewSecret(undefined);
}
} catch (ignore) {
displayError("Failed to delete OAuth2 client secret");
}
}}
/>
</>
);
};
export default EditOAuth2AppPage;

View File

@ -0,0 +1,83 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
MockOAuth2ProviderApps,
MockOAuth2ProviderAppSecrets,
mockApiError,
} from "testHelpers/entities";
import { EditOAuth2AppPageView } from "./EditOAuth2AppPageView";
const meta: Meta = {
title: "pages/DeploySettingsPage/EditOAuth2AppPageView",
component: EditOAuth2AppPageView,
};
export default meta;
type Story = StoryObj<typeof EditOAuth2AppPageView>;
export const LoadingApp: Story = {
args: {
isLoadingApp: true,
mutatingResource: {
updateApp: false,
deleteApp: false,
createSecret: false,
deleteSecret: false,
},
},
};
export const LoadingSecrets: Story = {
args: {
app: MockOAuth2ProviderApps[0],
isLoadingSecrets: true,
mutatingResource: {
updateApp: false,
deleteApp: false,
createSecret: false,
deleteSecret: false,
},
},
};
export const Error: Story = {
args: {
app: MockOAuth2ProviderApps[0],
secrets: MockOAuth2ProviderAppSecrets,
mutatingResource: {
updateApp: false,
deleteApp: false,
createSecret: false,
deleteSecret: false,
},
error: mockApiError({
message: "Validation failed",
validations: [
{
field: "name",
detail: "name error",
},
{
field: "callback_url",
detail: "url error",
},
{
field: "icon",
detail: "icon error",
},
],
}),
},
};
export const Default: Story = {
args: {
app: MockOAuth2ProviderApps[0],
secrets: MockOAuth2ProviderAppSecrets,
mutatingResource: {
updateApp: false,
deleteApp: false,
createSecret: false,
deleteSecret: false,
},
},
};

View File

@ -0,0 +1,305 @@
import { useTheme } from "@emotion/react";
import CopyIcon from "@mui/icons-material/FileCopyOutlined";
import KeyboardArrowLeft from "@mui/icons-material/KeyboardArrowLeft";
import Divider from "@mui/material/Divider";
import LoadingButton from "@mui/lab/LoadingButton";
import Button from "@mui/material/Button";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import { type FC, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import type * as TypesGen from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { CodeExample } from "components/CodeExample/CodeExample";
import { CopyableValue } from "components/CopyableValue/CopyableValue";
import { Header } from "components/DeploySettingsLayout/Header";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
import { Loader } from "components/Loader/Loader";
import { Stack } from "components/Stack/Stack";
import { TableLoader } from "components/TableLoader/TableLoader";
import { createDayString } from "utils/createDayString";
import { OAuth2AppForm } from "./OAuth2AppForm";
export type MutatingResource = {
updateApp: boolean;
createSecret: boolean;
deleteApp: boolean;
deleteSecret: boolean;
};
type EditOAuth2AppProps = {
app?: TypesGen.OAuth2ProviderApp;
isLoadingApp: boolean;
isLoadingSecrets: boolean;
// mutatingResource indicates which resources, if any, are currently being
// mutated.
mutatingResource: MutatingResource;
updateApp: (req: TypesGen.PutOAuth2ProviderAppRequest) => void;
deleteApp: (name: string) => void;
generateAppSecret: () => void;
deleteAppSecret: (id: string) => void;
secrets?: readonly TypesGen.OAuth2ProviderAppSecret[];
fullNewSecret?: TypesGen.OAuth2ProviderAppSecretFull;
ackFullNewSecret: () => void;
error?: unknown;
};
export const EditOAuth2AppPageView: FC<EditOAuth2AppProps> = ({
app,
isLoadingApp,
isLoadingSecrets,
mutatingResource,
updateApp,
deleteApp,
generateAppSecret,
deleteAppSecret,
secrets,
fullNewSecret,
ackFullNewSecret,
error,
}) => {
const theme = useTheme();
const [searchParams] = useSearchParams();
const [showDelete, setShowDelete] = useState<boolean>(false);
return (
<>
<Stack
alignItems="baseline"
direction="row"
justifyContent="space-between"
>
<Header
title="Edit OAuth2 application"
description="Configure an application to use Coder as an OAuth2 provider."
/>
<Button
component={Link}
startIcon={<KeyboardArrowLeft />}
to="/deployment/oauth2-provider/apps"
>
All OAuth2 Applications
</Button>
</Stack>
{fullNewSecret && (
<ConfirmDialog
hideCancel
open={Boolean(fullNewSecret)}
onConfirm={ackFullNewSecret}
onClose={ackFullNewSecret}
title="OAuth2 client secret"
confirmText="OK"
description={
<>
<p>
Your new client secret is displayed below. Make sure to copy it
now; you will not be able to see it again.
</p>
<CodeExample
code={fullNewSecret.client_secret_full}
css={{
minHeight: "auto",
userSelect: "all",
width: "100%",
marginTop: 24,
}}
/>
</>
}
/>
)}
<Stack>
{searchParams.has("created") && (
<Alert severity="info" dismissible>
Your OAuth2 application has been created. Generate a client secret
below to start using your application.
</Alert>
)}
{error ? <ErrorAlert error={error} /> : undefined}
{isLoadingApp && <Loader />}
{!isLoadingApp && app && (
<>
<DeleteDialog
isOpen={showDelete}
confirmLoading={mutatingResource.deleteApp}
name={app.name}
entity="OAuth2 application"
onConfirm={() => deleteApp(app.name)}
onCancel={() => setShowDelete(false)}
/>
<h2 css={{ marginBottom: 0 }}>Client ID</h2>
<CopyableValue value={app.id}>
{app.id}{" "}
<CopyIcon
css={{
width: 16,
height: 16,
}}
/>
</CopyableValue>
<Divider css={{ borderColor: theme.palette.divider }} />
<OAuth2AppForm
app={app}
onSubmit={updateApp}
isUpdating={mutatingResource.updateApp}
error={error}
actions={
<Button
variant="outlined"
type="button"
color="error"
onClick={() => setShowDelete(true)}
>
Delete&hellip;
</Button>
}
/>
<Divider css={{ borderColor: theme.palette.divider }} />
<OAuth2AppSecretsTable
secrets={secrets}
generateAppSecret={generateAppSecret}
deleteAppSecret={deleteAppSecret}
isLoadingSecrets={isLoadingSecrets}
mutatingResource={mutatingResource}
/>
</>
)}
</Stack>
</>
);
};
type OAuth2AppSecretsTableProps = {
secrets?: readonly TypesGen.OAuth2ProviderAppSecret[];
generateAppSecret: () => void;
isLoadingSecrets: boolean;
mutatingResource: MutatingResource;
deleteAppSecret: (id: string) => void;
};
const OAuth2AppSecretsTable: FC<OAuth2AppSecretsTableProps> = ({
secrets,
generateAppSecret,
isLoadingSecrets,
mutatingResource,
deleteAppSecret,
}) => {
return (
<>
<Stack
alignItems="baseline"
direction="row"
justifyContent="space-between"
>
<h2>Client secrets</h2>
<LoadingButton
loading={mutatingResource.createSecret}
type="submit"
variant="contained"
onClick={generateAppSecret}
>
Generate secret
</LoadingButton>
</Stack>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="80%">Secret</TableCell>
<TableCell width="20%">Last Used</TableCell>
<TableCell width="1%" />
</TableRow>
</TableHead>
<TableBody>
{isLoadingSecrets && <TableLoader />}
{!isLoadingSecrets && (!secrets || secrets.length === 0) && (
<TableRow>
<TableCell colSpan={999}>
<div css={{ textAlign: "center" }}>
No client secrets have been generated.
</div>
</TableCell>
</TableRow>
)}
{!isLoadingSecrets &&
secrets?.map((secret) => (
<OAuth2SecretRow
key={secret.id}
secret={secret}
mutatingResource={mutatingResource}
deleteAppSecret={deleteAppSecret}
/>
))}
</TableBody>
</Table>
</TableContainer>
</>
);
};
type OAuth2SecretRowProps = {
secret: TypesGen.OAuth2ProviderAppSecret;
deleteAppSecret: (id: string) => void;
mutatingResource: MutatingResource;
};
const OAuth2SecretRow: FC<OAuth2SecretRowProps> = ({
secret,
deleteAppSecret,
mutatingResource,
}) => {
const [showDelete, setShowDelete] = useState<boolean>(false);
return (
<TableRow key={secret.id} data-testid={`secret-${secret.id}`}>
<TableCell>*****{secret.client_secret_truncated}</TableCell>
<TableCell data-chromatic="ignore">
{secret.last_used_at ? createDayString(secret.last_used_at) : "never"}
</TableCell>
<TableCell>
<ConfirmDialog
type="delete"
hideCancel={false}
open={showDelete}
onConfirm={() => deleteAppSecret(secret.id)}
onClose={() => setShowDelete(false)}
title="Delete OAuth2 client secret"
confirmLoading={mutatingResource.deleteSecret}
confirmText="Delete"
description={
<>
Deleting <strong>*****{secret.client_secret_truncated}</strong> is
irreversible and will revoke all the tokens generated by it. Are
you sure you want to proceed?
</>
}
/>
<Button
variant="outlined"
type="button"
color="error"
onClick={() => setShowDelete(true)}
>
Delete&hellip;
</Button>
</TableCell>
</TableRow>
);
};

View File

@ -0,0 +1,87 @@
import LoadingButton from "@mui/lab/LoadingButton";
import TextField from "@mui/material/TextField";
import { type FC, type ReactNode } from "react";
import { isApiValidationError, mapApiErrorToFieldErrors } from "api/errors";
import type * as TypesGen from "api/typesGenerated";
import { Stack } from "components/Stack/Stack";
type OAuth2AppFormProps = {
app?: TypesGen.OAuth2ProviderApp;
onSubmit: (data: {
name: string;
callback_url: string;
icon: string;
}) => void;
error?: unknown;
isUpdating: boolean;
actions?: ReactNode;
};
export const OAuth2AppForm: FC<OAuth2AppFormProps> = ({
app,
onSubmit,
error,
isUpdating,
actions,
}) => {
const apiValidationErrors = isApiValidationError(error)
? mapApiErrorToFieldErrors(error.response.data)
: undefined;
return (
<form
css={{ marginTop: 10 }}
onSubmit={(event) => {
event.preventDefault();
const formData = new FormData(event.target as HTMLFormElement);
onSubmit({
name: formData.get("name") as string,
callback_url: formData.get("callback_url") as string,
icon: formData.get("icon") as string,
});
}}
>
<Stack spacing={2.5}>
<TextField
name="name"
label="Application name"
defaultValue={app?.name}
error={Boolean(apiValidationErrors?.name)}
helperText={
apiValidationErrors?.name || "The name of your Coder app."
}
autoFocus
fullWidth
/>
<TextField
name="callback_url"
label="Callback URL"
defaultValue={app?.callback_url}
error={Boolean(apiValidationErrors?.callback_url)}
helperText={
apiValidationErrors?.callback_url ||
"The full URL to redirect to after a user authorizes an installation."
}
fullWidth
/>
<TextField
name="icon"
label="Application icon"
defaultValue={app?.icon}
error={Boolean(apiValidationErrors?.icon)}
helperText={
apiValidationErrors?.icon || "A full or relative URL to an icon."
}
fullWidth
/>
<Stack direction="row">
<LoadingButton loading={isUpdating} type="submit" variant="contained">
{app ? "Update application" : "Create application"}
</LoadingButton>
{actions}
</Stack>
</Stack>
</form>
);
};

View File

@ -0,0 +1,29 @@
import { useQuery } from "react-query";
import { getApps } from "api/queries/oauth2";
import { useDashboard } from "components/Dashboard/DashboardProvider";
import { FC } from "react";
import { Helmet } from "react-helmet-async";
import { pageTitle } from "utils/page";
import OAuth2AppsSettingsPageView from "./OAuth2AppsSettingsPageView";
const OAuth2AppsSettingsPage: FC = () => {
const { entitlements } = useDashboard();
const appsQuery = useQuery(getApps());
return (
<>
<Helmet>
<title>{pageTitle("OAuth2 Applications")}</title>
</Helmet>
<OAuth2AppsSettingsPageView
apps={appsQuery.data}
isLoading={appsQuery.isLoading}
isEntitled={
entitlements.features.oauth2_provider.entitlement !== "not_entitled"
}
/>
</>
);
};
export default OAuth2AppsSettingsPage;

View File

@ -0,0 +1,38 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MockOAuth2ProviderApps } from "testHelpers/entities";
import OAuth2AppsSettingsPageView from "./OAuth2AppsSettingsPageView";
const meta: Meta = {
title: "pages/DeploySettingsPage/OAuth2AppsSettingsPageView",
component: OAuth2AppsSettingsPageView,
};
export default meta;
type Story = StoryObj<typeof OAuth2AppsSettingsPageView>;
export const Loading: Story = {
args: {
isLoading: true,
},
};
export const Unentitled: Story = {
args: {
isLoading: false,
apps: MockOAuth2ProviderApps,
},
};
export const Entitled: Story = {
args: {
isLoading: false,
apps: MockOAuth2ProviderApps,
isEntitled: true,
},
};
export const Empty: Story = {
args: {
isLoading: false,
},
};

View File

@ -0,0 +1,132 @@
import { useTheme } from "@emotion/react";
import AddIcon from "@mui/icons-material/AddOutlined";
import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight";
import Button from "@mui/material/Button";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import { type FC } from "react";
import { Link, useNavigate } from "react-router-dom";
import type * as TypesGen from "api/typesGenerated";
import { AvatarData } from "components/AvatarData/AvatarData";
import { Avatar } from "components/Avatar/Avatar";
import {
Badges,
DisabledBadge,
EnterpriseBadge,
EntitledBadge,
} from "components/Badges/Badges";
import { Header } from "components/DeploySettingsLayout/Header";
import { TableLoader } from "components/TableLoader/TableLoader";
import { Stack } from "components/Stack/Stack";
import { useClickableTableRow } from "hooks/useClickableTableRow";
type OAuth2AppsSettingsProps = {
apps?: TypesGen.OAuth2ProviderApp[];
isEntitled: boolean;
isLoading: boolean;
};
const OAuth2AppsSettingsPageView: FC<OAuth2AppsSettingsProps> = ({
apps,
isEntitled,
isLoading,
}) => {
return (
<>
<Stack
alignItems="baseline"
direction="row"
justifyContent="space-between"
>
<div>
<Header
title="OAuth2 Applications"
description="Configure applications to use Coder as an OAuth2 provider."
/>
<Badges>
{isEntitled ? <EntitledBadge /> : <DisabledBadge />}
<EnterpriseBadge />
</Badges>
</div>
<Button
component={Link}
to="/deployment/oauth2-provider/apps/add"
startIcon={<AddIcon />}
>
Add application
</Button>
</Stack>
<TableContainer css={{ marginTop: 32 }}>
<Table>
<TableHead>
<TableRow>
<TableCell width="100%">Name</TableCell>
<TableCell width="1%" />
</TableRow>
</TableHead>
<TableBody>
{isLoading && <TableLoader />}
{!isLoading &&
apps?.map((app) => <OAuth2AppRow key={app.id} app={app} />)}
{!isLoading && (!apps || apps?.length === 0) && (
<TableRow>
<TableCell colSpan={999}>
<div css={{ textAlign: "center" }}>
No OAuth2 applications have been configured.
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</>
);
};
type OAuth2AppRowProps = {
app: TypesGen.OAuth2ProviderApp;
};
const OAuth2AppRow: FC<OAuth2AppRowProps> = ({ app }) => {
const theme = useTheme();
const navigate = useNavigate();
const clickableProps = useClickableTableRow({
onClick: () => navigate(`/deployment/oauth2-provider/apps/${app.id}`),
});
return (
<TableRow key={app.id} data-testid={`app-${app.id}`} {...clickableProps}>
<TableCell>
<AvatarData
title={app.name}
avatar={
Boolean(app.icon) && (
<Avatar src={app.icon} variant="square" fitImage />
)
}
/>
</TableCell>
<TableCell>
<div css={{ display: "flex", paddingLeft: 16 }}>
<KeyboardArrowRight
css={{
color: theme.palette.text.secondary,
width: 20,
height: 20,
}}
/>
</div>
</TableCell>
</TableRow>
);
};
export default OAuth2AppsSettingsPageView;

View File

@ -3214,3 +3214,25 @@ export const MockGithubAuthLink: TypesGen.ExternalAuthLink = {
authenticated: true,
validate_error: "",
};
export const MockOAuth2ProviderApps: TypesGen.OAuth2ProviderApp[] = [
{
id: "1",
name: "foo",
callback_url: "http://localhost:3001",
icon: "/icon/github.svg",
},
];
export const MockOAuth2ProviderAppSecrets: TypesGen.OAuth2ProviderAppSecret[] =
[
{
id: "1",
client_secret_truncated: "foo",
},
{
id: "1",
last_used_at: "2022-12-16T20:10:45.637452Z",
client_secret_truncated: "foo",
},
];