Skip to content

feat: standardize OAuth2 endpoints and implement token revocation #18809

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
chore: add OAuth2 device flow test scripts
Change-Id: Ic232851727e683ab3d8b7ce970c505588da2f827
Signed-off-by: Thomas Kosiewski <tk@coder.com>
  • Loading branch information
ThomasK33 committed Jul 22, 2025
commit 9dfcc8aa3baa0c7575f31ea3c2fc1014164bf7ce
26 changes: 16 additions & 10 deletions .claude/scripts/format.sh
Original file line number Diff line number Diff line change
Expand Up @@ -101,30 +101,36 @@ fi
# Get the file extension to determine the appropriate formatter
file_ext="${file_path##*.}"

# Helper function to run formatter and handle errors
run_formatter() {
local target="$1"
local file_type="$2"

if ! make FILE="$file_path" "$target"; then
echo "Error: Failed to format $file_type file: $file_path" >&2
exit 2
fi
echo "✓ Formatted $file_type file: $file_path"
}
# Change to the project root directory (where the Makefile is located)
cd "$(dirname "$0")/../.."

# Call the appropriate Makefile target based on file extension
case "$file_ext" in
go)
make fmt/go FILE="$file_path"
echo "✓ Formatted Go file: $file_path"
run_formatter "fmt/go" "Go"
;;
js | jsx | ts | tsx)
make fmt/ts FILE="$file_path"
echo "✓ Formatted TypeScript/JavaScript file: $file_path"
run_formatter "fmt/ts" "TypeScript/JavaScript"
;;
tf | tfvars)
make fmt/terraform FILE="$file_path"
echo "✓ Formatted Terraform file: $file_path"
run_formatter "fmt/terraform" "Terraform"
;;
sh)
make fmt/shfmt FILE="$file_path"
echo "✓ Formatted shell script: $file_path"
run_formatter "fmt/shfmt" "shell script"
;;
md)
make fmt/markdown FILE="$file_path"
echo "✓ Formatted Markdown file: $file_path"
run_formatter "fmt/markdown" "Markdown"
;;
*)
echo "No formatter available for file extension: $file_ext"
Expand Down
2 changes: 2 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions coderd/audit/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type Auditable interface {
database.NotificationsSettings |
database.OAuth2ProviderApp |
database.OAuth2ProviderAppSecret |
database.OAuth2ProviderDeviceCode |
database.PrebuildsSettings |
database.CustomRole |
database.AuditableOrganizationMember |
Expand Down
8 changes: 8 additions & 0 deletions coderd/audit/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ func ResourceTarget[T Auditable](tgt T) string {
return typed.Name
case database.OAuth2ProviderAppSecret:
return typed.DisplaySecret
case database.OAuth2ProviderDeviceCode:
return typed.UserCode
case database.CustomRole:
return typed.Name
case database.AuditableOrganizationMember:
Expand Down Expand Up @@ -179,6 +181,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID {
return typed.ID
case database.OAuth2ProviderAppSecret:
return typed.ID
case database.OAuth2ProviderDeviceCode:
return typed.ID
case database.CustomRole:
return typed.ID
case database.AuditableOrganizationMember:
Expand Down Expand Up @@ -232,6 +236,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType {
return database.ResourceTypeOauth2ProviderApp
case database.OAuth2ProviderAppSecret:
return database.ResourceTypeOauth2ProviderAppSecret
case database.OAuth2ProviderDeviceCode:
return database.ResourceTypeOauth2ProviderDeviceCode
case database.CustomRole:
return database.ResourceTypeCustomRole
case database.AuditableOrganizationMember:
Expand Down Expand Up @@ -288,6 +294,8 @@ func ResourceRequiresOrgID[T Auditable]() bool {
return false
case database.OAuth2ProviderAppSecret:
return false
case database.OAuth2ProviderDeviceCode:
return false
case database.CustomRole:
return true
case database.AuditableOrganizationMember:
Expand Down
2 changes: 1 addition & 1 deletion coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -993,7 +993,7 @@ func New(options *Options) *API {
r.Route("/device", func(r chi.Router) {
r.Post("/", api.postOAuth2DeviceAuthorization()) // RFC 8628 compliant endpoint
r.Route("/verify", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Use(apiKeyMiddlewareRedirect)
r.Get("/", api.getOAuth2DeviceVerification())
r.Post("/", api.postOAuth2DeviceVerification())
})
Expand Down
55 changes: 32 additions & 23 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ var (
rbac.ResourceProvisionerJobs.Type: {policy.ActionRead, policy.ActionUpdate, policy.ActionCreate},
rbac.ResourceOauth2App.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
rbac.ResourceOauth2AppSecret.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
rbac.ResourceOauth2AppCodeToken.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
}),
Org: map[string][]rbac.Permission{},
User: []rbac.Permission{},
Expand Down Expand Up @@ -1346,6 +1347,14 @@ func (q *querier) CleanTailnetTunnels(ctx context.Context) error {
return q.db.CleanTailnetTunnels(ctx)
}

func (q *querier) ConsumeOAuth2ProviderAppCodeByPrefix(ctx context.Context, secretPrefix []byte) (database.OAuth2ProviderAppCode, error) {
return updateWithReturn(q.log, q.auth, q.db.GetOAuth2ProviderAppCodeByPrefix, q.db.ConsumeOAuth2ProviderAppCodeByPrefix)(ctx, secretPrefix)
}

func (q *querier) ConsumeOAuth2ProviderDeviceCodeByPrefix(ctx context.Context, deviceCodePrefix string) (database.OAuth2ProviderDeviceCode, error) {
return updateWithReturn(q.log, q.auth, q.db.GetOAuth2ProviderDeviceCodeByPrefix, q.db.ConsumeOAuth2ProviderDeviceCodeByPrefix)(ctx, deviceCodePrefix)
}

func (q *querier) CountAuditLogs(ctx context.Context, arg database.CountAuditLogsParams) (int64, error) {
// Shortcut if the user is an owner. The SQL filter is noticeable,
// and this is an easy win for owners. Which is the common case.
Expand Down Expand Up @@ -1560,27 +1569,30 @@ func (q *querier) DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx context.Contex
return q.db.DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx, arg)
}

func (q *querier) DeleteOldAuditLogConnectionEvents(ctx context.Context, threshold database.DeleteOldAuditLogConnectionEventsParams) error {
// `ResourceSystem` is deprecated, but it doesn't make sense to add
// `policy.ActionDelete` to `ResourceAuditLog`, since this is the one and
// only time we'll be deleting from the audit log.
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil {
return err
}
return q.db.DeleteOldAuditLogConnectionEvents(ctx, threshold)
}

func (q *querier) DeleteOAuth2ProviderDeviceCodeByID(ctx context.Context, id uuid.UUID) error {
// Fetch the device code first to check authorization
deviceCode, err := q.db.GetOAuth2ProviderDeviceCodeByID(ctx, id)
if err != nil {
return err
return xerrors.Errorf("get oauth2 provider device code: %w", err)
}
if err := q.authorizeContext(ctx, policy.ActionDelete, deviceCode); err != nil {
return err
return xerrors.Errorf("authorize oauth2 provider device code deletion: %w", err)
}

return q.db.DeleteOAuth2ProviderDeviceCodeByID(ctx, id)
if err := q.db.DeleteOAuth2ProviderDeviceCodeByID(ctx, id); err != nil {
return xerrors.Errorf("delete oauth2 provider device code: %w", err)
}
return nil
}

func (q *querier) DeleteOldAuditLogConnectionEvents(ctx context.Context, threshold database.DeleteOldAuditLogConnectionEventsParams) error {
// `ResourceSystem` is deprecated, but it doesn't make sense to add
// `policy.ActionDelete` to `ResourceAuditLog`, since this is the one and
// only time we'll be deleting from the audit log.
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil {
return err
}
return q.db.DeleteOldAuditLogConnectionEvents(ctx, threshold)
}

func (q *querier) DeleteOldNotificationMessages(ctx context.Context) error {
Expand Down Expand Up @@ -2367,8 +2379,8 @@ func (q *querier) GetOAuth2ProviderDeviceCodeByUserCode(ctx context.Context, use
}

func (q *querier) GetOAuth2ProviderDeviceCodesByClientID(ctx context.Context, clientID uuid.UUID) ([]database.OAuth2ProviderDeviceCode, error) {
// This requires access to read the OAuth2 app
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOauth2App); err != nil {
// This requires access to read OAuth2 app code tokens
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOauth2AppCodeToken); err != nil {
return []database.OAuth2ProviderDeviceCode{}, err
}
return q.db.GetOAuth2ProviderDeviceCodesByClientID(ctx, clientID)
Expand Down Expand Up @@ -3810,8 +3822,8 @@ func (q *querier) InsertOAuth2ProviderAppToken(ctx context.Context, arg database
}

func (q *querier) InsertOAuth2ProviderDeviceCode(ctx context.Context, arg database.InsertOAuth2ProviderDeviceCodeParams) (database.OAuth2ProviderDeviceCode, error) {
// Creating device codes requires OAuth2 app access
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceOauth2App); err != nil {
// Creating device codes requires OAuth2 app code token creation access
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceOauth2AppCodeToken); err != nil {
return database.OAuth2ProviderDeviceCode{}, err
}
return q.db.InsertOAuth2ProviderDeviceCode(ctx, arg)
Expand Down Expand Up @@ -4490,13 +4502,10 @@ func (q *querier) UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg dat
}

func (q *querier) UpdateOAuth2ProviderDeviceCodeAuthorization(ctx context.Context, arg database.UpdateOAuth2ProviderDeviceCodeAuthorizationParams) (database.OAuth2ProviderDeviceCode, error) {
// Verify the user is authenticated for device code authorization
_, ok := ActorFromContext(ctx)
if !ok {
return database.OAuth2ProviderDeviceCode{}, ErrNoActor
fetch := func(ctx context.Context, arg database.UpdateOAuth2ProviderDeviceCodeAuthorizationParams) (database.OAuth2ProviderDeviceCode, error) {
return q.db.GetOAuth2ProviderDeviceCodeByID(ctx, arg.ID)
}

return q.db.UpdateOAuth2ProviderDeviceCodeAuthorization(ctx, arg)
return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateOAuth2ProviderDeviceCodeAuthorization)(ctx, arg)
}

func (q *querier) UpdateOrganization(ctx context.Context, arg database.UpdateOrganizationParams) (database.Organization, error) {
Expand Down
122 changes: 122 additions & 0 deletions coderd/database/dbauthz/dbauthz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5537,6 +5537,19 @@ func (s *MethodTestSuite) TestOAuth2ProviderAppCodes() {
UserID: user.ID,
}).Asserts(rbac.ResourceOauth2AppCodeToken.WithOwner(user.ID.String()), policy.ActionDelete)
}))
s.Run("ConsumeOAuth2ProviderAppCodeByPrefix", s.Subtest(func(db database.Store, check *expects) {
user := dbgen.User(s.T(), db, database.User{})
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
// Use unique prefix to avoid test isolation issues
uniquePrefix := fmt.Sprintf("prefix-%s-%d", s.T().Name(), time.Now().UnixNano())
code := dbgen.OAuth2ProviderAppCode(s.T(), db, database.OAuth2ProviderAppCode{
SecretPrefix: []byte(uniquePrefix),
UserID: user.ID,
AppID: app.ID,
ExpiresAt: time.Now().Add(24 * time.Hour), // Extended expiry for test stability
})
check.Args(code.SecretPrefix).Asserts(code, policy.ActionUpdate).Returns(code)
}))
}

func (s *MethodTestSuite) TestOAuth2ProviderAppTokens() {
Expand Down Expand Up @@ -5612,6 +5625,115 @@ func (s *MethodTestSuite) TestOAuth2ProviderAppTokens() {
}))
}

func (s *MethodTestSuite) TestOAuth2ProviderDeviceCodes() {
s.Run("InsertOAuth2ProviderDeviceCode", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
check.Args(database.InsertOAuth2ProviderDeviceCodeParams{
ClientID: app.ID,
DeviceCodePrefix: "testpref",
DeviceCodeHash: []byte("hash"),
UserCode: "TEST1234",
VerificationUri: "http://example.com/device",
}).Asserts(rbac.ResourceOauth2AppCodeToken, policy.ActionCreate)
}))
s.Run("GetOAuth2ProviderDeviceCodeByID", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
deviceCode, err := db.InsertOAuth2ProviderDeviceCode(context.Background(), database.InsertOAuth2ProviderDeviceCodeParams{
ClientID: app.ID,
DeviceCodePrefix: "testpref",
UserCode: "TEST1234",
VerificationUri: "http://example.com/device",
})
require.NoError(s.T(), err)
check.Args(deviceCode.ID).Asserts(deviceCode, policy.ActionRead).Returns(deviceCode)
}))
s.Run("GetOAuth2ProviderDeviceCodeByPrefix", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
deviceCode, err := db.InsertOAuth2ProviderDeviceCode(context.Background(), database.InsertOAuth2ProviderDeviceCodeParams{
ClientID: app.ID,
DeviceCodePrefix: "testpref",
UserCode: "TEST1234",
VerificationUri: "http://example.com/device",
})
require.NoError(s.T(), err)
check.Args(deviceCode.DeviceCodePrefix).Asserts(deviceCode, policy.ActionRead).Returns(deviceCode)
}))
s.Run("GetOAuth2ProviderDeviceCodeByUserCode", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
deviceCode, err := db.InsertOAuth2ProviderDeviceCode(context.Background(), database.InsertOAuth2ProviderDeviceCodeParams{
ClientID: app.ID,
DeviceCodePrefix: "testpref",
UserCode: "TEST1234",
VerificationUri: "http://example.com/device",
})
require.NoError(s.T(), err)
check.Args(deviceCode.UserCode).Asserts(deviceCode, policy.ActionRead).Returns(deviceCode)
}))
s.Run("GetOAuth2ProviderDeviceCodesByClientID", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
deviceCode, err := db.InsertOAuth2ProviderDeviceCode(context.Background(), database.InsertOAuth2ProviderDeviceCodeParams{
ClientID: app.ID,
DeviceCodePrefix: "testpref",
UserCode: "TEST1234",
VerificationUri: "http://example.com/device",
})
require.NoError(s.T(), err)
check.Args(app.ID).Asserts(rbac.ResourceOauth2AppCodeToken, policy.ActionRead).Returns([]database.OAuth2ProviderDeviceCode{deviceCode})
}))
s.Run("ConsumeOAuth2ProviderDeviceCodeByPrefix", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
user := dbgen.User(s.T(), db, database.User{})
// Use unique identifiers to avoid test isolation issues
// Device code prefix must be exactly 8 characters
uniquePrefix := fmt.Sprintf("t%07d", time.Now().UnixNano()%10000000)
uniqueUserCode := fmt.Sprintf("USER%04d", time.Now().UnixNano()%10000)
// Create device code using dbgen (now available!)
deviceCode := dbgen.OAuth2ProviderDeviceCode(s.T(), db, database.OAuth2ProviderDeviceCode{
DeviceCodePrefix: uniquePrefix,
UserCode: uniqueUserCode,
ClientID: app.ID,
ExpiresAt: time.Now().Add(24 * time.Hour), // Extended expiry for test stability
})
// Authorize the device code so it can be consumed
deviceCode, err := db.UpdateOAuth2ProviderDeviceCodeAuthorization(s.T().Context(), database.UpdateOAuth2ProviderDeviceCodeAuthorizationParams{
ID: deviceCode.ID,
UserID: uuid.NullUUID{UUID: user.ID, Valid: true},
Status: database.OAuth2DeviceStatusAuthorized,
})
require.NoError(s.T(), err)
require.Equal(s.T(), database.OAuth2DeviceStatusAuthorized, deviceCode.Status)
check.Args(uniquePrefix).Asserts(deviceCode, policy.ActionUpdate).Returns(deviceCode)
}))
s.Run("UpdateOAuth2ProviderDeviceCodeAuthorization", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
user := dbgen.User(s.T(), db, database.User{})
// Create device code using dbgen
deviceCode := dbgen.OAuth2ProviderDeviceCode(s.T(), db, database.OAuth2ProviderDeviceCode{
ClientID: app.ID,
})
require.Equal(s.T(), database.OAuth2DeviceStatusPending, deviceCode.Status)
check.Args(database.UpdateOAuth2ProviderDeviceCodeAuthorizationParams{
ID: deviceCode.ID,
UserID: uuid.NullUUID{UUID: user.ID, Valid: true},
Status: database.OAuth2DeviceStatusAuthorized,
}).Asserts(deviceCode, policy.ActionUpdate)
}))
s.Run("DeleteOAuth2ProviderDeviceCodeByID", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
deviceCode, err := db.InsertOAuth2ProviderDeviceCode(context.Background(), database.InsertOAuth2ProviderDeviceCodeParams{
ClientID: app.ID,
DeviceCodePrefix: "testpref",
UserCode: "TEST1234",
VerificationUri: "http://example.com/device",
})
require.NoError(s.T(), err)
check.Args(deviceCode.ID).Asserts(deviceCode, policy.ActionDelete)
}))
s.Run("DeleteExpiredOAuth2ProviderDeviceCodes", s.Subtest(func(db database.Store, check *expects) {
check.Args().Asserts(rbac.ResourceSystem, policy.ActionDelete)
}))
}

func (s *MethodTestSuite) TestResourcesMonitor() {
createAgent := func(t *testing.T, db database.Store) (database.WorkspaceAgent, database.WorkspaceTable) {
t.Helper()
Expand Down
Loading
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy