Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,34 @@ func New(options *Options) *API {
})
}

// OAuth2 linking routes do not make sense under the /api/v2 path. These are
// for an external application to use Coder as an OAuth2 provider, not for
// logging into Coder with an external OAuth2 provider.
r.Route("/oauth2", func(r chi.Router) {
r.Use(
api.oAuth2ProviderMiddleware,
// Fetch the app as system because in the /tokens route there will be no
// authenticated user.
httpmw.AsAuthzSystem(httpmw.ExtractOAuth2ProviderApp(options.Database)),
)
r.Route("/authorize", func(r chi.Router) {
r.Use(apiKeyMiddlewareRedirect)
r.Get("/", api.getOAuth2ProviderAppAuthorize())
})
r.Route("/tokens", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(apiKeyMiddleware)
// DELETE on /tokens is not part of the OAuth2 spec. It is our own
// route used to revoke permissions from an application. It is here for
// parity with POST on /tokens.
r.Delete("/", api.deleteOAuth2ProviderAppTokens())
})
// The POST /tokens endpoint will be called from an unauthorized client so
// we cannot require an API key.
r.Post("/", api.postOAuth2ProviderAppToken())
})
})

r.Route("/api/v2", func(r chi.Router) {
api.APIHandler = r

Expand Down Expand Up @@ -1098,6 +1126,34 @@ func New(options *Options) *API {
}
r.Method("GET", "/expvar", expvar.Handler()) // contains DERP metrics as well as cmdline and memstats
})
// Manage OAuth2 applications that can use Coder as an OAuth2 provider.
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 options.SwaggerEndpoint {
Expand Down
25 changes: 7 additions & 18 deletions enterprise/coderd/oauth2.go → coderd/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/identityprovider"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/identityprovider"
)

func (api *API) oAuth2ProviderMiddleware(next http.Handler) http.Handler {
func (*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{
Expand All @@ -26,17 +26,6 @@ func (api *API) oAuth2ProviderMiddleware(next http.Handler) http.Handler {
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)
})
}
Expand Down Expand Up @@ -111,7 +100,7 @@ func (api *API) oAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) {
func (api *API) postOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
auditor = api.AGPL.Auditor.Load()
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Expand Down Expand Up @@ -157,7 +146,7 @@ func (api *API) putOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
app = httpmw.OAuth2ProviderApp(r)
auditor = api.AGPL.Auditor.Load()
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Expand Down Expand Up @@ -200,7 +189,7 @@ func (api *API) deleteOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request)
var (
ctx = r.Context()
app = httpmw.OAuth2ProviderApp(r)
auditor = api.AGPL.Auditor.Load()
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Expand Down Expand Up @@ -263,7 +252,7 @@ func (api *API) postOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Requ
var (
ctx = r.Context()
app = httpmw.OAuth2ProviderApp(r)
auditor = api.AGPL.Auditor.Load()
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderAppSecret](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Expand Down Expand Up @@ -317,7 +306,7 @@ func (api *API) deleteOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Re
var (
ctx = r.Context()
secret = httpmw.OAuth2ProviderAppSecret(r)
auditor = api.AGPL.Auditor.Load()
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderAppSecret](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Expand Down
81 changes: 28 additions & 53 deletions enterprise/coderd/oauth2_test.go → coderd/oauth2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,10 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/identityprovider"
"github.com/coder/coder/v2/coderd/userpassword"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/identityprovider"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/testutil"
)

Expand All @@ -34,11 +32,8 @@ func TestOAuth2ProviderApps(t *testing.T) {
t.Run("Validation", func(t *testing.T) {
t.Parallel()

client, _ := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureOAuth2Provider: 1,
},
}})
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)

topCtx := testutil.Context(t, testutil.WaitLong)

Expand Down Expand Up @@ -178,11 +173,8 @@ func TestOAuth2ProviderApps(t *testing.T) {
t.Run("DeleteNonExisting", func(t *testing.T) {
t.Parallel()

client, owner := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureOAuth2Provider: 1,
},
}})
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
another, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)

ctx := testutil.Context(t, testutil.WaitLong)
Expand All @@ -194,11 +186,8 @@ func TestOAuth2ProviderApps(t *testing.T) {
t.Run("OK", func(t *testing.T) {
t.Parallel()

client, owner := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureOAuth2Provider: 1,
},
}})
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
another, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)

ctx := testutil.Context(t, testutil.WaitLong)
Expand Down Expand Up @@ -269,11 +258,8 @@ func TestOAuth2ProviderApps(t *testing.T) {

t.Run("ByUser", func(t *testing.T) {
t.Parallel()
client, owner := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureOAuth2Provider: 1,
},
}})
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
another, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
ctx := testutil.Context(t, testutil.WaitLong)
_ = generateApps(ctx, t, client, "by-user")
Expand All @@ -288,11 +274,8 @@ func TestOAuth2ProviderApps(t *testing.T) {
func TestOAuth2ProviderAppSecrets(t *testing.T) {
t.Parallel()

client, _ := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureOAuth2Provider: 1,
},
}})
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)

topCtx := testutil.Context(t, testutil.WaitLong)

Expand Down Expand Up @@ -383,17 +366,11 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
t.Parallel()

db, pubsub := dbtestutil.NewDB(t)
ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
Database: db,
Pubsub: pubsub,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureOAuth2Provider: 1,
},
},
ownerClient := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: pubsub,
})
owner := coderdtest.CreateFirstUser(t, ownerClient)
topCtx := testutil.Context(t, testutil.WaitLong)
apps := generateApps(topCtx, t, ownerClient, "token-exchange")

Expand Down Expand Up @@ -764,17 +741,11 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) {
topCtx := testutil.Context(t, testutil.WaitLong)

db, pubsub := dbtestutil.NewDB(t)
ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
Database: db,
Pubsub: pubsub,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureOAuth2Provider: 1,
},
},
ownerClient := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: pubsub,
})
owner := coderdtest.CreateFirstUser(t, ownerClient)
apps := generateApps(topCtx, t, ownerClient, "token-refresh")

//nolint:gocritic // OAauth2 app management requires owner permission.
Expand Down Expand Up @@ -935,11 +906,8 @@ type exchangeSetup struct {
func TestOAuth2ProviderRevoke(t *testing.T) {
t.Parallel()

client, owner := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureOAuth2Provider: 1,
},
}})
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)

tests := []struct {
name string
Expand Down Expand Up @@ -1138,3 +1106,10 @@ func authorizationFlow(ctx context.Context, client *codersdk.Client, cfg *oauth2
},
)
}

func must[T any](value T, err error) T {
if err != nil {
panic(err)
}
return value
}
4 changes: 0 additions & 4 deletions codersdk/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ const (
FeatureExternalTokenEncryption FeatureName = "external_token_encryption"
FeatureWorkspaceBatchActions FeatureName = "workspace_batch_actions"
FeatureAccessControl FeatureName = "access_control"
FeatureOAuth2Provider FeatureName = "oauth2_provider"
FeatureControlSharedPorts FeatureName = "control_shared_ports"
)

Expand All @@ -74,7 +73,6 @@ var FeatureNames = []FeatureName{
FeatureExternalTokenEncryption,
FeatureWorkspaceBatchActions,
FeatureAccessControl,
FeatureOAuth2Provider,
FeatureControlSharedPorts,
}

Expand All @@ -85,8 +83,6 @@ 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), "_", " "))
}
Expand Down
Loading