feat: make OAuth2 provider not enterprise-only (#12732)

This commit is contained in:
Asher 2024-03-25 11:52:22 -08:00 committed by GitHub
parent 60f335113c
commit 40e5ad5499
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 92 additions and 168 deletions

View File

@ -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
@ -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 {

View File

@ -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{
@ -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)
})
}
@ -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,
@ -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,
@ -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,
@ -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,
@ -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,

View File

@ -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"
)
@ -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)
@ -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)
@ -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)
@ -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")
@ -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)
@ -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")
@ -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.
@ -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
@ -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
}

View File

@ -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"
)
@ -74,7 +73,6 @@ var FeatureNames = []FeatureName{
FeatureExternalTokenEncryption,
FeatureWorkspaceBatchActions,
FeatureAccessControl,
FeatureOAuth2Provider,
FeatureControlSharedPorts,
}
@ -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), "_", " "))
}

View File

@ -153,16 +153,6 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
SessionTokenFunc: nil, // Default behavior
PostAuthAdditionalHeadersFunc: options.PostAuthAdditionalHeadersFunc,
})
// Same as above but it redirects to the login page.
apiKeyMiddlewareRedirect := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: options.Database,
OAuth2Configs: oauthConfigs,
RedirectToLogin: true,
DisableSessionExpiryRefresh: options.DeploymentValues.DisableSessionExpiryRefresh.Value(),
Optional: false,
SessionTokenFunc: nil, // Default behavior
PostAuthAdditionalHeadersFunc: options.PostAuthAdditionalHeadersFunc,
})
apiKeyMiddlewareOptional := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: options.Database,
OAuth2Configs: oauthConfigs,
@ -178,33 +168,6 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
return nil, xerrors.Errorf("failed to get deployment ID: %w", err)
}
api.AGPL.RootHandler.Group(func(r chi.Router) {
// OAuth2 linking routes do not make sense under the /api/v2 path.
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())
})
})
})
api.AGPL.RefreshEntitlements = func(ctx context.Context) error {
return api.refreshEntitlements(ctx)
}
@ -365,33 +328,6 @@ 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)
})
})
})
})
})
r.Route("/integrations", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
@ -596,7 +532,6 @@ 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,

View File

@ -2051,7 +2051,6 @@ export type FeatureName =
| "external_token_encryption"
| "high_availability"
| "multiple_external_auth"
| "oauth2_provider"
| "scim"
| "template_rbac"
| "user_limit"
@ -2069,7 +2068,6 @@ export const FeatureNames: FeatureName[] = [
"external_token_encryption",
"high_availability",
"multiple_external_auth",
"oauth2_provider",
"scim",
"template_rbac",
"user_limit",

View File

@ -2,12 +2,10 @@ import type { FC } from "react";
import { Helmet } from "react-helmet-async";
import { useQuery } from "react-query";
import { getApps } from "api/queries/oauth2";
import { useDashboard } from "modules/dashboard/useDashboard";
import { pageTitle } from "utils/page";
import OAuth2AppsSettingsPageView from "./OAuth2AppsSettingsPageView";
const OAuth2AppsSettingsPage: FC = () => {
const { entitlements } = useDashboard();
const appsQuery = useQuery(getApps());
return (
@ -19,9 +17,6 @@ const OAuth2AppsSettingsPage: FC = () => {
apps={appsQuery.data}
isLoading={appsQuery.isLoading}
error={appsQuery.error}
isEntitled={
entitlements.features.oauth2_provider.entitlement !== "not_entitled"
}
/>
</>
);

View File

@ -23,21 +23,13 @@ export const Error: Story = {
},
};
export const Unentitled: Story = {
export const Apps: 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

@ -14,12 +14,6 @@ import type * as TypesGen from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/AvatarData/AvatarData";
import {
Badges,
DisabledBadge,
EnterpriseBadge,
EntitledBadge,
} from "components/Badges/Badges";
import { Stack } from "components/Stack/Stack";
import { TableLoader } from "components/TableLoader/TableLoader";
import { useClickableTableRow } from "hooks/useClickableTableRow";
@ -27,14 +21,12 @@ import { Header } from "../Header";
type OAuth2AppsSettingsProps = {
apps?: TypesGen.OAuth2ProviderApp[];
isEntitled: boolean;
isLoading: boolean;
error: unknown;
};
const OAuth2AppsSettingsPageView: FC<OAuth2AppsSettingsProps> = ({
apps,
isEntitled,
isLoading,
error,
}) => {
@ -50,10 +42,6 @@ const OAuth2AppsSettingsPageView: FC<OAuth2AppsSettingsProps> = ({
title="OAuth2 Applications"
description="Configure applications to use Coder as an OAuth2 provider."
/>
<Badges>
{isEntitled ? <EntitledBadge /> : <DisabledBadge />}
<EnterpriseBadge />
</Badges>
</div>
<Button