Merge branch 'main' of github.com:/coder/coder into dk/prebuilds

This commit is contained in:
Danny Kopping
2025-02-24 16:20:38 +00:00
69 changed files with 1805 additions and 340 deletions

28
coderd/apidoc/docs.go generated
View File

@ -6167,6 +6167,31 @@ const docTemplate = `{
}
}
},
"/users/oauth2/github/device": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Get Github device auth.",
"operationId": "get-github-device-auth",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ExternalAuthDevice"
}
}
}
}
},
"/users/oidc/callback": {
"get": {
"security": [
@ -12504,6 +12529,9 @@ const docTemplate = `{
"client_secret": {
"type": "string"
},
"device_flow": {
"type": "boolean"
},
"enterprise_base_url": {
"type": "string"
}

View File

@ -5449,6 +5449,27 @@
}
}
},
"/users/oauth2/github/device": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Users"],
"summary": "Get Github device auth.",
"operationId": "get-github-device-auth",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ExternalAuthDevice"
}
}
}
}
},
"/users/oidc/callback": {
"get": {
"security": [
@ -11244,6 +11265,9 @@
"client_secret": {
"type": "string"
},
"device_flow": {
"type": "boolean"
},
"enterprise_base_url": {
"type": "string"
}

View File

@ -1109,6 +1109,7 @@ func New(options *Options) *API {
r.Post("/validate-password", api.validateUserPassword)
r.Post("/otp/change-password", api.postChangePasswordWithOneTimePasscode)
r.Route("/oauth2", func(r chi.Router) {
r.Get("/github/device", api.userOAuth2GithubDevice)
r.Route("/github", func(r chi.Router) {
r.Use(
httpmw.ExtractOAuth2(options.GithubOAuth2Config, options.HTTPClient, nil),

View File

@ -30,8 +30,8 @@ func New() *Set {
// These will be updated when coderd is initialized.
entitlements: codersdk.Entitlements{
Features: map[codersdk.FeatureName]codersdk.Feature{},
Warnings: nil,
Errors: nil,
Warnings: []string{},
Errors: []string{},
HasLicense: false,
Trial: false,
RequireTelemetry: false,
@ -39,13 +39,21 @@ func New() *Set {
},
right2Update: make(chan struct{}, 1),
}
// Ensure all features are present in the entitlements. Our frontend
// expects this.
for _, featureName := range codersdk.FeatureNames {
s.entitlements.AddFeature(featureName, codersdk.Feature{
Entitlement: codersdk.EntitlementNotEntitled,
Enabled: false,
})
}
s.right2Update <- struct{}{} // one token, serialized updates
return s
}
// ErrLicenseRequiresTelemetry is an error returned by a fetch passed to Update to indicate that the
// fetched license cannot be used because it requires telemetry.
var ErrLicenseRequiresTelemetry = xerrors.New("License requires telemetry but telemetry is disabled")
var ErrLicenseRequiresTelemetry = xerrors.New(codersdk.LicenseTelemetryRequiredErrorText)
func (l *Set) Update(ctx context.Context, fetch func(context.Context) (codersdk.Entitlements, error)) error {
select {

View File

@ -154,6 +154,7 @@ func ResourceNotFound(rw http.ResponseWriter) {
func Forbidden(rw http.ResponseWriter) {
Write(context.Background(), rw, http.StatusForbidden, codersdk.Response{
Message: "Forbidden.",
Detail: "You don't have permission to view this content. If you believe this is a mistake, please contact your administrator or try signing in with different credentials.",
})
}

View File

@ -167,9 +167,16 @@ func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, authURLOp
oauthToken, err := config.Exchange(ctx, code)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error exchanging Oauth code.",
Detail: err.Error(),
errorCode := http.StatusInternalServerError
detail := err.Error()
if detail == "authorization_pending" {
// In the device flow, the token may not be immediately
// available. This is expected, and the client will retry.
errorCode = http.StatusBadRequest
}
httpapi.Write(ctx, rw, errorCode, codersdk.Response{
Message: "Failed exchanging Oauth code.",
Detail: detail,
})
return
}

View File

@ -210,7 +210,7 @@ func (api *API) postTestNotification(rw http.ResponseWriter, r *http.Request) {
return
}
httpapi.Write(ctx, rw, http.StatusOK, nil)
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Get user notification preferences

View File

@ -283,10 +283,11 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
Permissions(map[string][]policy.Action{
// Reduced permission set on dormant workspaces. No build, ssh, or exec
ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop},
// Users cannot do create/update/delete on themselves, but they
// can read their own details.
ResourceUser.Type: {policy.ActionRead, policy.ActionReadPersonal, policy.ActionUpdatePersonal},
// Can read their own organization member record
ResourceOrganizationMember.Type: {policy.ActionRead},
// Users can create provisioner daemons scoped to themselves.
ResourceProvisionerDaemon.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionRead, policy.ActionUpdate},
})...,
@ -423,12 +424,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
ResourceAssignOrgRole.Type: {policy.ActionRead},
}),
},
User: []Permission{
{
ResourceType: ResourceOrganizationMember.Type,
Action: policy.ActionRead,
},
},
User: []Permission{},
}
},
orgAuditor: func(organizationID uuid.UUID) Role {
@ -439,6 +435,12 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
Org: map[string][]Permission{
organizationID.String(): Permissions(map[string][]policy.Action{
ResourceAuditLog.Type: {policy.ActionRead},
// Allow auditors to see the resources that audit logs reflect.
ResourceTemplate.Type: {policy.ActionRead, policy.ActionViewInsights},
ResourceGroup.Type: {policy.ActionRead},
ResourceGroupMember.Type: {policy.ActionRead},
ResourceOrganization.Type: {policy.ActionRead},
ResourceOrganizationMember.Type: {policy.ActionRead},
}),
},
User: []Permission{},
@ -458,6 +460,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
organizationID.String(): Permissions(map[string][]policy.Action{
// Assign, remove, and read roles in the organization.
ResourceAssignOrgRole.Type: {policy.ActionAssign, policy.ActionDelete, policy.ActionRead},
ResourceOrganization.Type: {policy.ActionRead},
ResourceOrganizationMember.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
ResourceGroup.Type: ResourceGroup.AvailableActions(),
ResourceGroupMember.Type: ResourceGroupMember.AvailableActions(),
@ -479,10 +482,15 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
ResourceFile.Type: {policy.ActionCreate, policy.ActionRead},
ResourceWorkspace.Type: {policy.ActionRead},
// Assigning template perms requires this permission.
ResourceOrganization.Type: {policy.ActionRead},
ResourceOrganizationMember.Type: {policy.ActionRead},
ResourceGroup.Type: {policy.ActionRead},
ResourceGroupMember.Type: {policy.ActionRead},
ResourceProvisionerJobs.Type: {policy.ActionRead},
// Since templates have to correlate with provisioners,
// the ability to create templates and provisioners has
// a lot of overlap.
ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
ResourceProvisionerJobs.Type: {policy.ActionRead},
}),
},
User: []Permission{},

View File

@ -217,20 +217,20 @@ func TestRolePermissions(t *testing.T) {
},
{
Name: "Templates",
Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete, policy.ActionViewInsights},
Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete},
Resource: rbac.ResourceTemplate.WithID(templateID).InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, templateAdmin, orgTemplateAdmin},
false: {setOtherOrg, orgAuditor, orgUserAdmin, memberMe, orgMemberMe, userAdmin},
false: {setOtherOrg, orgUserAdmin, orgAuditor, memberMe, orgMemberMe, userAdmin},
},
},
{
Name: "ReadTemplates",
Actions: []policy.Action{policy.ActionRead},
Actions: []policy.Action{policy.ActionRead, policy.ActionViewInsights},
Resource: rbac.ResourceTemplate.InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, templateAdmin, orgTemplateAdmin},
false: {setOtherOrg, orgAuditor, orgUserAdmin, memberMe, userAdmin, orgMemberMe},
true: {owner, orgAuditor, orgAdmin, templateAdmin, orgTemplateAdmin},
false: {setOtherOrg, orgUserAdmin, memberMe, userAdmin, orgMemberMe},
},
},
{
@ -377,8 +377,8 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionRead},
Resource: rbac.ResourceOrganizationMember.WithID(currentUser).InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, userAdmin, orgMemberMe, templateAdmin, orgUserAdmin, orgTemplateAdmin},
false: {memberMe, setOtherOrg, orgAuditor},
true: {owner, orgAuditor, orgAdmin, userAdmin, orgMemberMe, templateAdmin, orgUserAdmin, orgTemplateAdmin},
false: {memberMe, setOtherOrg},
},
},
{
@ -404,7 +404,7 @@ func TestRolePermissions(t *testing.T) {
}),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, userAdmin, orgUserAdmin},
false: {setOtherOrg, memberMe, orgMemberMe, templateAdmin, orgTemplateAdmin, orgAuditor, groupMemberMe},
false: {setOtherOrg, memberMe, orgMemberMe, templateAdmin, orgTemplateAdmin, groupMemberMe, orgAuditor},
},
},
{
@ -416,8 +416,8 @@ func TestRolePermissions(t *testing.T) {
},
}),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, groupMemberMe},
false: {setOtherOrg, memberMe, orgMemberMe, orgAuditor},
true: {owner, orgAdmin, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, groupMemberMe, orgAuditor},
false: {setOtherOrg, memberMe, orgMemberMe},
},
},
{
@ -425,8 +425,8 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionRead},
Resource: rbac.ResourceGroupMember.WithID(currentUser).InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgMemberMe, groupMemberMe},
false: {setOtherOrg, memberMe, orgAuditor},
true: {owner, orgAuditor, orgAdmin, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgMemberMe, groupMemberMe},
false: {setOtherOrg, memberMe},
},
},
{
@ -434,8 +434,8 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionRead},
Resource: rbac.ResourceGroupMember.WithID(adminID).InOrg(orgID).WithOwner(adminID.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin},
false: {setOtherOrg, memberMe, orgAuditor, orgMemberMe, groupMemberMe},
true: {owner, orgAuditor, orgAdmin, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin},
false: {setOtherOrg, memberMe, orgMemberMe, groupMemberMe},
},
},
{
@ -534,8 +534,8 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete},
Resource: rbac.ResourceProvisionerDaemon.InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, templateAdmin, orgAdmin},
false: {setOtherOrg, orgTemplateAdmin, orgUserAdmin, memberMe, orgMemberMe, userAdmin, orgAuditor},
true: {owner, templateAdmin, orgAdmin, orgTemplateAdmin},
false: {setOtherOrg, orgAuditor, orgUserAdmin, memberMe, orgMemberMe, userAdmin},
},
},
{
@ -552,8 +552,8 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete},
Resource: rbac.ResourceProvisionerDaemon.WithOwner(currentUser.String()).InOrg(orgID),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, templateAdmin, orgMemberMe, orgAdmin},
false: {setOtherOrg, memberMe, userAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
true: {owner, templateAdmin, orgTemplateAdmin, orgMemberMe, orgAdmin},
false: {setOtherOrg, memberMe, userAdmin, orgUserAdmin, orgAuditor},
},
},
{

View File

@ -748,12 +748,32 @@ type GithubOAuth2Config struct {
ListOrganizationMemberships func(ctx context.Context, client *http.Client) ([]*github.Membership, error)
TeamMembership func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error)
DeviceFlowEnabled bool
ExchangeDeviceCode func(ctx context.Context, deviceCode string) (*oauth2.Token, error)
AuthorizeDevice func(ctx context.Context) (*codersdk.ExternalAuthDevice, error)
AllowSignups bool
AllowEveryone bool
AllowOrganizations []string
AllowTeams []GithubOAuth2Team
}
func (c *GithubOAuth2Config) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
if !c.DeviceFlowEnabled {
return c.OAuth2Config.Exchange(ctx, code, opts...)
}
return c.ExchangeDeviceCode(ctx, code)
}
func (c *GithubOAuth2Config) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
if !c.DeviceFlowEnabled {
return c.OAuth2Config.AuthCodeURL(state, opts...)
}
// This is an absolute path in the Coder app. The device flow is orchestrated
// by the Coder frontend, so we need to redirect the user to the device flow page.
return "/login/device?state=" + state
}
// @Summary Get authentication methods
// @ID get-authentication-methods
// @Security CoderSessionToken
@ -786,6 +806,53 @@ func (api *API) userAuthMethods(rw http.ResponseWriter, r *http.Request) {
})
}
// @Summary Get Github device auth.
// @ID get-github-device-auth
// @Security CoderSessionToken
// @Produce json
// @Tags Users
// @Success 200 {object} codersdk.ExternalAuthDevice
// @Router /users/oauth2/github/device [get]
func (api *API) userOAuth2GithubDevice(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.APIKey](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionLogin,
})
)
aReq.Old = database.APIKey{}
defer commitAudit()
if api.GithubOAuth2Config == nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Github OAuth2 is not enabled.",
})
return
}
if !api.GithubOAuth2Config.DeviceFlowEnabled {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Device flow is not enabled for Github OAuth2.",
})
return
}
deviceAuth, err := api.GithubOAuth2Config.AuthorizeDevice(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to authorize device.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, deviceAuth)
}
// @Summary OAuth 2.0 GitHub Callback
// @ID oauth-20-github-callback
// @Security CoderSessionToken
@ -1016,7 +1083,14 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
}
redirect = uriFromURL(redirect)
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
if api.GithubOAuth2Config.DeviceFlowEnabled {
// In the device flow, the redirect is handled client-side.
httpapi.Write(ctx, rw, http.StatusOK, codersdk.OAuth2DeviceFlowCallbackResponse{
RedirectURL: redirect,
})
} else {
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
}
}
type OIDCConfig struct {

View File

@ -22,6 +22,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"golang.org/x/xerrors"
"cdr.dev/slog"
@ -882,6 +883,92 @@ func TestUserOAuth2Github(t *testing.T) {
require.Equal(t, user.ID, userID, "user_id is different, a new user was likely created")
require.Equal(t, user.Email, newEmail)
})
t.Run("DeviceFlow", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
GithubOAuth2Config: &coderd.GithubOAuth2Config{
OAuth2Config: &testutil.OAuth2Config{},
AllowOrganizations: []string{"coder"},
AllowSignups: true,
ListOrganizationMemberships: func(_ context.Context, _ *http.Client) ([]*github.Membership, error) {
return []*github.Membership{{
State: &stateActive,
Organization: &github.Organization{
Login: github.String("coder"),
},
}}, nil
},
AuthenticatedUser: func(_ context.Context, _ *http.Client) (*github.User, error) {
return &github.User{
ID: github.Int64(100),
Login: github.String("testuser"),
Name: github.String("The Right Honorable Sir Test McUser"),
}, nil
},
ListEmails: func(_ context.Context, _ *http.Client) ([]*github.UserEmail, error) {
return []*github.UserEmail{{
Email: github.String("testuser@coder.com"),
Verified: github.Bool(true),
Primary: github.Bool(true),
}}, nil
},
DeviceFlowEnabled: true,
ExchangeDeviceCode: func(_ context.Context, _ string) (*oauth2.Token, error) {
return &oauth2.Token{
AccessToken: "access_token",
RefreshToken: "refresh_token",
Expiry: time.Now().Add(time.Hour),
}, nil
},
AuthorizeDevice: func(_ context.Context) (*codersdk.ExternalAuthDevice, error) {
return &codersdk.ExternalAuthDevice{
DeviceCode: "device_code",
UserCode: "user_code",
}, nil
},
},
})
client.HTTPClient.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
// Ensure that we redirect to the device login page when the user is not logged in.
oauthURL, err := client.URL.Parse("/api/v2/users/oauth2/github/callback")
require.NoError(t, err)
req, err := http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil)
require.NoError(t, err)
res, err := client.HTTPClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, res.StatusCode)
location, err := res.Location()
require.NoError(t, err)
require.Equal(t, "/login/device", location.Path)
query := location.Query()
require.NotEmpty(t, query.Get("state"))
// Ensure that we return a JSON response when the code is successfully exchanged.
oauthURL, err = client.URL.Parse("/api/v2/users/oauth2/github/callback?code=hey&state=somestate")
require.NoError(t, err)
req, err = http.NewRequestWithContext(context.Background(), "GET", oauthURL.String(), nil)
req.AddCookie(&http.Cookie{
Name: "oauth_state",
Value: "somestate",
})
require.NoError(t, err)
res, err = client.HTTPClient.Do(req)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
var resp codersdk.OAuth2DeviceFlowCallbackResponse
require.NoError(t, json.NewDecoder(res.Body).Decode(&resp))
require.Equal(t, "/", resp.RedirectURL)
})
}
// nolint:bodyclose