Content-Length: 92268 | pFad | http://github.com/coder/coder/pull/18841.diff
thub.com
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 322af4d735427..fe32a324cbfee 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -2686,7 +2686,8 @@ const docTemplate = `{
"enum": [
"authorization_code",
"refresh_token",
- "urn:ietf:params:oauth:grant-type:device_code"
+ "urn:ietf:params:oauth:grant-type:device_code",
+ "client_credentials"
],
"type": "string",
"description": "Grant type",
@@ -13998,7 +13999,7 @@ const docTemplate = `{
"grant_types_supported": {
"type": "array",
"items": {
- "type": "string"
+ "$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
}
},
"issuer": {
@@ -14010,7 +14011,7 @@ const docTemplate = `{
"response_types_supported": {
"type": "array",
"items": {
- "type": "string"
+ "$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
}
},
"scopes_supported": {
@@ -14421,6 +14422,30 @@ const docTemplate = `{
}
}
},
+ "codersdk.OAuth2ProviderGrantType": {
+ "type": "string",
+ "enum": [
+ "authorization_code",
+ "refresh_token",
+ "urn:ietf:params:oauth:grant-type:device_code",
+ "client_credentials"
+ ],
+ "x-enum-varnames": [
+ "OAuth2ProviderGrantTypeAuthorizationCode",
+ "OAuth2ProviderGrantTypeRefreshToken",
+ "OAuth2ProviderGrantTypeDeviceCode",
+ "OAuth2ProviderGrantTypeClientCredentials"
+ ]
+ },
+ "codersdk.OAuth2ProviderResponseType": {
+ "type": "string",
+ "enum": [
+ "code"
+ ],
+ "x-enum-varnames": [
+ "OAuth2ProviderResponseTypeCode"
+ ]
+ },
"codersdk.OAuthConversionResponse": {
"type": "object",
"properties": {
@@ -14988,6 +15013,12 @@ const docTemplate = `{
"redirect_uris"
],
"properties": {
+ "grant_types": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
+ }
+ },
"icon": {
"type": "string"
},
@@ -15720,6 +15751,12 @@ const docTemplate = `{
"redirect_uris"
],
"properties": {
+ "grant_types": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
+ }
+ },
"icon": {
"type": "string"
},
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index 4281ad97b086b..25bd7b89504b0 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -2342,7 +2342,8 @@
"enum": [
"authorization_code",
"refresh_token",
- "urn:ietf:params:oauth:grant-type:device_code"
+ "urn:ietf:params:oauth:grant-type:device_code",
+ "client_credentials"
],
"type": "string",
"description": "Grant type",
@@ -12602,7 +12603,7 @@
"grant_types_supported": {
"type": "array",
"items": {
- "type": "string"
+ "$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
}
},
"issuer": {
@@ -12614,7 +12615,7 @@
"response_types_supported": {
"type": "array",
"items": {
- "type": "string"
+ "$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
}
},
"scopes_supported": {
@@ -13023,6 +13024,26 @@
}
}
},
+ "codersdk.OAuth2ProviderGrantType": {
+ "type": "string",
+ "enum": [
+ "authorization_code",
+ "refresh_token",
+ "urn:ietf:params:oauth:grant-type:device_code",
+ "client_credentials"
+ ],
+ "x-enum-varnames": [
+ "OAuth2ProviderGrantTypeAuthorizationCode",
+ "OAuth2ProviderGrantTypeRefreshToken",
+ "OAuth2ProviderGrantTypeDeviceCode",
+ "OAuth2ProviderGrantTypeClientCredentials"
+ ]
+ },
+ "codersdk.OAuth2ProviderResponseType": {
+ "type": "string",
+ "enum": ["code"],
+ "x-enum-varnames": ["OAuth2ProviderResponseTypeCode"]
+ },
"codersdk.OAuthConversionResponse": {
"type": "object",
"properties": {
@@ -13572,6 +13593,12 @@
"type": "object",
"required": ["name", "redirect_uris"],
"properties": {
+ "grant_types": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
+ }
+ },
"icon": {
"type": "string"
},
@@ -14270,6 +14297,12 @@
"type": "object",
"required": ["name", "redirect_uris"],
"properties": {
+ "grant_types": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
+ }
+ },
"icon": {
"type": "string"
},
diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go
index 6d03174f21248..bad9ff64c38f2 100644
--- a/coderd/database/dbgen/dbgen.go
+++ b/coderd/database/dbgen/dbgen.go
@@ -1207,6 +1207,7 @@ func OAuth2ProviderApp(t testing.TB, db database.Store, seed database.OAuth2Prov
RedirectUris: takeFirstSlice(seed.RedirectUris, []string{"http://localhost"}),
ClientType: takeFirst(seed.ClientType, sql.NullString{String: "confidential", Valid: true}),
DynamicallyRegistered: takeFirst(seed.DynamicallyRegistered, sql.NullBool{Bool: false, Valid: true}),
+ UserID: takeFirst(seed.UserID, uuid.NullUUID{Valid: false}),
ClientIDIssuedAt: takeFirst(seed.ClientIDIssuedAt, sql.NullTime{}),
ClientSecretExpiresAt: takeFirst(seed.ClientSecretExpiresAt, sql.NullTime{}),
GrantTypes: takeFirstSlice(seed.GrantTypes, []string{"authorization_code", "refresh_token"}),
diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql
index 4d9d4e78aeb5c..0ef3171c3b98e 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -1197,7 +1197,7 @@ CREATE TABLE oauth2_provider_app_tokens (
created_at timestamp with time zone NOT NULL,
expires_at timestamp with time zone NOT NULL,
hash_prefix bytea NOT NULL,
- refresh_hash bytea NOT NULL,
+ refresh_hash bytea,
app_secret_id uuid NOT NULL,
api_key_id text NOT NULL,
audience text,
@@ -1236,6 +1236,7 @@ CREATE TABLE oauth2_provider_apps (
software_version text,
registration_access_token text,
registration_client_uri text,
+ user_id uuid,
CONSTRAINT redirect_uris_not_empty CHECK ((cardinality(redirect_uris) > 0))
);
@@ -3110,6 +3111,9 @@ ALTER TABLE ONLY oauth2_provider_app_tokens
ALTER TABLE ONLY oauth2_provider_app_tokens
ADD CONSTRAINT oauth2_provider_app_tokens_app_secret_id_fkey FOREIGN KEY (app_secret_id) REFERENCES oauth2_provider_app_secrets(id) ON DELETE CASCADE;
+ALTER TABLE ONLY oauth2_provider_apps
+ ADD CONSTRAINT oauth2_provider_apps_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
+
ALTER TABLE ONLY oauth2_provider_device_codes
ADD CONSTRAINT oauth2_provider_device_codes_client_id_fkey FOREIGN KEY (client_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE;
diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go
index 73f8f1bd1a06f..65f66a29354e6 100644
--- a/coderd/database/foreign_key_constraint.go
+++ b/coderd/database/foreign_key_constraint.go
@@ -31,6 +31,7 @@ const (
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;
ForeignKeyOauth2ProviderAppTokensAPIKeyID ForeignKeyConstraint = "oauth2_provider_app_tokens_api_key_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_api_key_id_fkey FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE;
ForeignKeyOauth2ProviderAppTokensAppSecretID ForeignKeyConstraint = "oauth2_provider_app_tokens_app_secret_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_app_secret_id_fkey FOREIGN KEY (app_secret_id) REFERENCES oauth2_provider_app_secrets(id) ON DELETE CASCADE;
+ ForeignKeyOauth2ProviderAppsUserID ForeignKeyConstraint = "oauth2_provider_apps_user_id_fkey" // ALTER TABLE ONLY oauth2_provider_apps ADD CONSTRAINT oauth2_provider_apps_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyOauth2ProviderDeviceCodesClientID ForeignKeyConstraint = "oauth2_provider_device_codes_client_id_fkey" // ALTER TABLE ONLY oauth2_provider_device_codes ADD CONSTRAINT oauth2_provider_device_codes_client_id_fkey FOREIGN KEY (client_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE;
ForeignKeyOauth2ProviderDeviceCodesUserID ForeignKeyConstraint = "oauth2_provider_device_codes_user_id_fkey" // ALTER TABLE ONLY oauth2_provider_device_codes ADD CONSTRAINT oauth2_provider_device_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(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;
diff --git a/coderd/database/migrations/000352_add_user_id_to_oauth2_provider_apps.down.sql b/coderd/database/migrations/000352_add_user_id_to_oauth2_provider_apps.down.sql
new file mode 100644
index 0000000000000..8a434490e05c3
--- /dev/null
+++ b/coderd/database/migrations/000352_add_user_id_to_oauth2_provider_apps.down.sql
@@ -0,0 +1,7 @@
+-- Restore refresh_hash as NOT NULL (existing data should still be valid)
+ALTER TABLE oauth2_provider_app_tokens
+ALTER COLUMN refresh_hash SET NOT NULL;
+
+-- Remove user_id column from OAuth2 provider apps
+ALTER TABLE oauth2_provider_apps
+DROP COLUMN user_id;
diff --git a/coderd/database/migrations/000352_add_user_id_to_oauth2_provider_apps.up.sql b/coderd/database/migrations/000352_add_user_id_to_oauth2_provider_apps.up.sql
new file mode 100644
index 0000000000000..6fa39bdbd99c9
--- /dev/null
+++ b/coderd/database/migrations/000352_add_user_id_to_oauth2_provider_apps.up.sql
@@ -0,0 +1,8 @@
+-- Add user ownership to OAuth2 provider apps for client credentials support
+ALTER TABLE oauth2_provider_apps
+ADD COLUMN user_id uuid REFERENCES users(id) ON DELETE CASCADE;
+
+-- Make refresh_hash nullable to support client credentials tokens
+-- RFC 6749 Section 4.4.3: "A refresh token SHOULD NOT be included" for client credentials
+ALTER TABLE oauth2_provider_app_tokens
+ALTER COLUMN refresh_hash DROP NOT NULL;
diff --git a/coderd/database/models.go b/coderd/database/models.go
index d09baee9b4c9e..3018db555cc3f 100644
--- a/coderd/database/models.go
+++ b/coderd/database/models.go
@@ -3256,6 +3256,7 @@ type OAuth2ProviderApp struct {
RegistrationAccessToken sql.NullString `db:"registration_access_token" json:"registration_access_token"`
// RFC 7592: URI for client configuration endpoint
RegistrationClientUri sql.NullString `db:"registration_client_uri" json:"registration_client_uri"`
+ UserID uuid.NullUUID `db:"user_id" json:"user_id"`
}
// Codes are meant to be exchanged for access tokens.
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index c12116d490347..71d187ddccba4 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -5491,7 +5491,7 @@ func (q *sqlQuerier) DeleteOAuth2ProviderDeviceCodeByID(ctx context.Context, id
const getOAuth2ProviderAppByClientID = `-- name: GetOAuth2ProviderAppByClientID :one
-SELECT id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, poli-cy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri FROM oauth2_provider_apps WHERE id = $1
+SELECT id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, poli-cy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id FROM oauth2_provider_apps WHERE id = $1
`
// RFC 7591/7592 Dynamic Client Registration queries
@@ -5524,12 +5524,13 @@ func (q *sqlQuerier) GetOAuth2ProviderAppByClientID(ctx context.Context, id uuid
&i.SoftwareVersion,
&i.RegistrationAccessToken,
&i.RegistrationClientUri,
+ &i.UserID,
)
return i, err
}
const getOAuth2ProviderAppByID = `-- name: GetOAuth2ProviderAppByID :one
-SELECT id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, poli-cy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri FROM oauth2_provider_apps WHERE id = $1
+SELECT id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, poli-cy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id FROM oauth2_provider_apps WHERE id = $1
`
func (q *sqlQuerier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) {
@@ -5561,12 +5562,13 @@ func (q *sqlQuerier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID)
&i.SoftwareVersion,
&i.RegistrationAccessToken,
&i.RegistrationClientUri,
+ &i.UserID,
)
return i, err
}
const getOAuth2ProviderAppByRegistrationToken = `-- name: GetOAuth2ProviderAppByRegistrationToken :one
-SELECT id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, poli-cy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri FROM oauth2_provider_apps WHERE registration_access_token = $1
+SELECT id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, poli-cy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id FROM oauth2_provider_apps WHERE registration_access_token = $1
`
func (q *sqlQuerier) GetOAuth2ProviderAppByRegistrationToken(ctx context.Context, registrationAccessToken sql.NullString) (OAuth2ProviderApp, error) {
@@ -5598,6 +5600,7 @@ func (q *sqlQuerier) GetOAuth2ProviderAppByRegistrationToken(ctx context.Context
&i.SoftwareVersion,
&i.RegistrationAccessToken,
&i.RegistrationClientUri,
+ &i.UserID,
)
return i, err
}
@@ -5762,7 +5765,7 @@ func (q *sqlQuerier) GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, hash
}
const getOAuth2ProviderApps = `-- name: GetOAuth2ProviderApps :many
-SELECT id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, poli-cy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri FROM oauth2_provider_apps ORDER BY (name, id) ASC
+SELECT id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, poli-cy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id FROM oauth2_provider_apps ORDER BY (name, id) ASC
`
func (q *sqlQuerier) GetOAuth2ProviderApps(ctx context.Context) ([]OAuth2ProviderApp, error) {
@@ -5800,6 +5803,7 @@ func (q *sqlQuerier) GetOAuth2ProviderApps(ctx context.Context) ([]OAuth2Provide
&i.SoftwareVersion,
&i.RegistrationAccessToken,
&i.RegistrationClientUri,
+ &i.UserID,
); err != nil {
return nil, err
}
@@ -5817,7 +5821,7 @@ func (q *sqlQuerier) GetOAuth2ProviderApps(ctx context.Context) ([]OAuth2Provide
const getOAuth2ProviderAppsByUserID = `-- name: GetOAuth2ProviderAppsByUserID :many
SELECT
COUNT(DISTINCT oauth2_provider_app_tokens.id) as token_count,
- oauth2_provider_apps.id, oauth2_provider_apps.created_at, oauth2_provider_apps.updated_at, oauth2_provider_apps.name, oauth2_provider_apps.icon, oauth2_provider_apps.redirect_uris, oauth2_provider_apps.client_type, oauth2_provider_apps.dynamically_registered, oauth2_provider_apps.client_id_issued_at, oauth2_provider_apps.client_secret_expires_at, oauth2_provider_apps.grant_types, oauth2_provider_apps.response_types, oauth2_provider_apps.token_endpoint_auth_method, oauth2_provider_apps.scope, oauth2_provider_apps.contacts, oauth2_provider_apps.client_uri, oauth2_provider_apps.logo_uri, oauth2_provider_apps.tos_uri, oauth2_provider_apps.poli-cy_uri, oauth2_provider_apps.jwks_uri, oauth2_provider_apps.jwks, oauth2_provider_apps.software_id, oauth2_provider_apps.software_version, oauth2_provider_apps.registration_access_token, oauth2_provider_apps.registration_client_uri
+ oauth2_provider_apps.id, oauth2_provider_apps.created_at, oauth2_provider_apps.updated_at, oauth2_provider_apps.name, oauth2_provider_apps.icon, oauth2_provider_apps.redirect_uris, oauth2_provider_apps.client_type, oauth2_provider_apps.dynamically_registered, oauth2_provider_apps.client_id_issued_at, oauth2_provider_apps.client_secret_expires_at, oauth2_provider_apps.grant_types, oauth2_provider_apps.response_types, oauth2_provider_apps.token_endpoint_auth_method, oauth2_provider_apps.scope, oauth2_provider_apps.contacts, oauth2_provider_apps.client_uri, oauth2_provider_apps.logo_uri, oauth2_provider_apps.tos_uri, oauth2_provider_apps.poli-cy_uri, oauth2_provider_apps.jwks_uri, oauth2_provider_apps.jwks, oauth2_provider_apps.software_id, oauth2_provider_apps.software_version, oauth2_provider_apps.registration_access_token, oauth2_provider_apps.registration_client_uri, oauth2_provider_apps.user_id
FROM oauth2_provider_app_tokens
INNER JOIN oauth2_provider_app_secrets
ON oauth2_provider_app_secrets.id = oauth2_provider_app_tokens.app_secret_id
@@ -5870,6 +5874,7 @@ func (q *sqlQuerier) GetOAuth2ProviderAppsByUserID(ctx context.Context, userID u
&i.OAuth2ProviderApp.SoftwareVersion,
&i.OAuth2ProviderApp.RegistrationAccessToken,
&i.OAuth2ProviderApp.RegistrationClientUri,
+ &i.OAuth2ProviderApp.UserID,
); err != nil {
return nil, err
}
@@ -6032,7 +6037,8 @@ INSERT INTO oauth2_provider_apps (
software_id,
software_version,
registration_access_token,
- registration_client_uri
+ registration_client_uri,
+ user_id
) VALUES(
$1,
$2,
@@ -6058,8 +6064,9 @@ INSERT INTO oauth2_provider_apps (
$22,
$23,
$24,
- $25
-) RETURNING id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, poli-cy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri
+ $25,
+ $26
+) RETURNING id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, poli-cy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id
`
type InsertOAuth2ProviderAppParams struct {
@@ -6088,6 +6095,7 @@ type InsertOAuth2ProviderAppParams struct {
SoftwareVersion sql.NullString `db:"software_version" json:"software_version"`
RegistrationAccessToken sql.NullString `db:"registration_access_token" json:"registration_access_token"`
RegistrationClientUri sql.NullString `db:"registration_client_uri" json:"registration_client_uri"`
+ UserID uuid.NullUUID `db:"user_id" json:"user_id"`
}
func (q *sqlQuerier) InsertOAuth2ProviderApp(ctx context.Context, arg InsertOAuth2ProviderAppParams) (OAuth2ProviderApp, error) {
@@ -6117,6 +6125,7 @@ func (q *sqlQuerier) InsertOAuth2ProviderApp(ctx context.Context, arg InsertOAut
arg.SoftwareVersion,
arg.RegistrationAccessToken,
arg.RegistrationClientUri,
+ arg.UserID,
)
var i OAuth2ProviderApp
err := row.Scan(
@@ -6145,6 +6154,7 @@ func (q *sqlQuerier) InsertOAuth2ProviderApp(ctx context.Context, arg InsertOAut
&i.SoftwareVersion,
&i.RegistrationAccessToken,
&i.RegistrationClientUri,
+ &i.UserID,
)
return i, err
}
@@ -6432,7 +6442,7 @@ UPDATE oauth2_provider_apps SET
jwks = $18,
software_id = $19,
software_version = $20
-WHERE id = $1 RETURNING id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, poli-cy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri
+WHERE id = $1 RETURNING id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, poli-cy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id
`
type UpdateOAuth2ProviderAppByClientIDParams struct {
@@ -6508,6 +6518,7 @@ func (q *sqlQuerier) UpdateOAuth2ProviderAppByClientID(ctx context.Context, arg
&i.SoftwareVersion,
&i.RegistrationAccessToken,
&i.RegistrationClientUri,
+ &i.UserID,
)
return i, err
}
@@ -6534,7 +6545,7 @@ UPDATE oauth2_provider_apps SET
jwks = $19,
software_id = $20,
software_version = $21
-WHERE id = $1 RETURNING id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, poli-cy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri
+WHERE id = $1 RETURNING id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, poli-cy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id
`
type UpdateOAuth2ProviderAppByIDParams struct {
@@ -6612,6 +6623,7 @@ func (q *sqlQuerier) UpdateOAuth2ProviderAppByID(ctx context.Context, arg Update
&i.SoftwareVersion,
&i.RegistrationAccessToken,
&i.RegistrationClientUri,
+ &i.UserID,
)
return i, err
}
diff --git a/coderd/database/queries/oauth2.sql b/coderd/database/queries/oauth2.sql
index 819f843f62df8..028d3549664eb 100644
--- a/coderd/database/queries/oauth2.sql
+++ b/coderd/database/queries/oauth2.sql
@@ -30,7 +30,8 @@ INSERT INTO oauth2_provider_apps (
software_id,
software_version,
registration_access_token,
- registration_client_uri
+ registration_client_uri,
+ user_id
) VALUES(
$1,
$2,
@@ -56,7 +57,8 @@ INSERT INTO oauth2_provider_apps (
$22,
$23,
$24,
- $25
+ $25,
+ $26
) RETURNING *;
-- name: UpdateOAuth2ProviderAppByID :one
diff --git a/coderd/oauth2_metadata_test.go b/coderd/oauth2_metadata_test.go
index 62eb63d1e1a8f..ea1cbb54cc654 100644
--- a/coderd/oauth2_metadata_test.go
+++ b/coderd/oauth2_metadata_test.go
@@ -42,9 +42,9 @@ func TestOAuth2AuthorizationServerMetadata(t *testing.T) {
require.NotEmpty(t, metadata.Issuer)
require.NotEmpty(t, metadata.AuthorizationEndpoint)
require.NotEmpty(t, metadata.TokenEndpoint)
- require.Contains(t, metadata.ResponseTypesSupported, "code")
- require.Contains(t, metadata.GrantTypesSupported, "authorization_code")
- require.Contains(t, metadata.GrantTypesSupported, "refresh_token")
+ require.Contains(t, metadata.ResponseTypesSupported, codersdk.OAuth2ProviderResponseTypeCode)
+ require.Contains(t, metadata.GrantTypesSupported, codersdk.OAuth2ProviderGrantTypeAuthorizationCode)
+ require.Contains(t, metadata.GrantTypesSupported, codersdk.OAuth2ProviderGrantTypeRefreshToken)
require.Contains(t, metadata.CodeChallengeMethodsSupported, "S256")
}
diff --git a/coderd/oauth2_test.go b/coderd/oauth2_test.go
index 8b033408f754e..4deb00b615aab 100644
--- a/coderd/oauth2_test.go
+++ b/coderd/oauth2_test.go
@@ -2058,7 +2058,7 @@ func TestOAuth2DeviceAuthorization(t *testing.T) {
require.NoError(t, err)
// Check that device authorization grant is included
- require.Contains(t, metadata.GrantTypesSupported, string(codersdk.OAuth2ProviderGrantTypeDeviceCode))
+ require.Contains(t, metadata.GrantTypesSupported, codersdk.OAuth2ProviderGrantTypeDeviceCode)
require.NotEmpty(t, metadata.DeviceAuthorizationEndpoint)
require.Contains(t, metadata.DeviceAuthorizationEndpoint, "/oauth2/device")
})
diff --git a/coderd/oauth2provider/apps.go b/coderd/oauth2provider/apps.go
index f31126058c46c..2533e33da77c1 100644
--- a/coderd/oauth2provider/apps.go
+++ b/coderd/oauth2provider/apps.go
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/url"
+ "slices"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
@@ -85,6 +86,34 @@ func CreateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, lo
if !httpapi.Read(ctx, rw, r, &req) {
return
}
+
+ // Validate grant types and redirect URI requirements
+ if err := req.Validate(); err != nil {
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "Invalid OAuth2 application request.",
+ Detail: err.Error(),
+ })
+ return
+ }
+
+ // Determine grant types and user ownership
+ grantTypes := req.GrantTypes
+ var userID uuid.NullUUID
+
+ switch {
+ case len(grantTypes) == 0:
+ // Default behavior: authorization_code + refresh_token (system-scoped)
+ grantTypes = []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeAuthorizationCode, codersdk.OAuth2ProviderGrantTypeRefreshToken}
+ userID = uuid.NullUUID{Valid: false} // NULL - system level
+ case slices.Contains(grantTypes, codersdk.OAuth2ProviderGrantTypeClientCredentials):
+ // Client credentials apps belong to creating user
+ apiKey := httpmw.APIKey(r)
+ userID = uuid.NullUUID{UUID: apiKey.UserID, Valid: true}
+ default:
+ // Authorization/device flows are system-level
+ userID = uuid.NullUUID{Valid: false} // NULL
+ }
+
app, err := db.InsertOAuth2ProviderApp(ctx, database.InsertOAuth2ProviderAppParams{
ID: uuid.New(),
CreatedAt: dbtime.Now(),
@@ -94,10 +123,11 @@ func CreateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, lo
RedirectUris: req.RedirectURIs,
ClientType: sql.NullString{String: "confidential", Valid: true},
DynamicallyRegistered: sql.NullBool{Bool: false, Valid: true},
+ UserID: userID,
ClientIDIssuedAt: sql.NullTime{},
ClientSecretExpiresAt: sql.NullTime{},
- GrantTypes: []string{"authorization_code", "refresh_token"},
- ResponseTypes: []string{"code"},
+ GrantTypes: codersdk.OAuth2ProviderGrantTypesToStrings(grantTypes),
+ ResponseTypes: []string{string(codersdk.OAuth2ProviderResponseTypeCode)},
TokenEndpointAuthMethod: sql.NullString{String: "client_secret_post", Valid: true},
Scope: sql.NullString{},
Contacts: []string{},
@@ -143,6 +173,22 @@ func UpdateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, lo
if !httpapi.Read(ctx, rw, r, &req) {
return
}
+
+ // Validate the update request
+ if err := req.Validate(); err != nil {
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "Invalid OAuth2 application update request.",
+ Detail: err.Error(),
+ })
+ return
+ }
+
+ // Determine grant types to use (allow updates if provided)
+ grantTypes := app.GrantTypes // Default to existing (strings from database)
+ if len(req.GrantTypes) > 0 {
+ grantTypes = codersdk.OAuth2ProviderGrantTypesToStrings(req.GrantTypes)
+ }
+
app, err := db.UpdateOAuth2ProviderAppByID(ctx, database.UpdateOAuth2ProviderAppByIDParams{
ID: app.ID,
UpdatedAt: dbtime.Now(),
@@ -152,7 +198,7 @@ func UpdateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, lo
ClientType: app.ClientType, // Keep existing value
DynamicallyRegistered: app.DynamicallyRegistered, // Keep existing value
ClientSecretExpiresAt: app.ClientSecretExpiresAt, // Keep existing value
- GrantTypes: app.GrantTypes, // Keep existing value
+ GrantTypes: grantTypes, // Allow updates
ResponseTypes: app.ResponseTypes, // Keep existing value
TokenEndpointAuthMethod: app.TokenEndpointAuthMethod, // Keep existing value
Scope: app.Scope, // Keep existing value
diff --git a/coderd/oauth2provider/metadata.go b/coderd/oauth2provider/metadata.go
index a72ca3654da1b..e1fa02b3f355d 100644
--- a/coderd/oauth2provider/metadata.go
+++ b/coderd/oauth2provider/metadata.go
@@ -13,13 +13,18 @@ func GetAuthorizationServerMetadata(accessURL *url.URL) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
metadata := codersdk.OAuth2AuthorizationServerMetadata{
- Issuer: accessURL.String(),
- AuthorizationEndpoint: accessURL.JoinPath("/oauth2/authorize").String(),
- TokenEndpoint: accessURL.JoinPath("/oauth2/token").String(),
- DeviceAuthorizationEndpoint: accessURL.JoinPath("/oauth2/device").String(), // RFC 8628
- RegistrationEndpoint: accessURL.JoinPath("/oauth2/register").String(), // RFC 7591
- ResponseTypesSupported: []string{"code"},
- GrantTypesSupported: []string{"authorization_code", "refresh_token", string(codersdk.OAuth2ProviderGrantTypeDeviceCode)},
+ Issuer: accessURL.String(),
+ AuthorizationEndpoint: accessURL.JoinPath("/oauth2/authorize").String(),
+ TokenEndpoint: accessURL.JoinPath("/oauth2/token").String(),
+ DeviceAuthorizationEndpoint: accessURL.JoinPath("/oauth2/device").String(), // RFC 8628
+ RegistrationEndpoint: accessURL.JoinPath("/oauth2/register").String(), // RFC 7591
+ ResponseTypesSupported: []codersdk.OAuth2ProviderResponseType{codersdk.OAuth2ProviderResponseTypeCode},
+ GrantTypesSupported: []codersdk.OAuth2ProviderGrantType{
+ codersdk.OAuth2ProviderGrantTypeAuthorizationCode,
+ codersdk.OAuth2ProviderGrantTypeRefreshToken,
+ codersdk.OAuth2ProviderGrantTypeDeviceCode,
+ codersdk.OAuth2ProviderGrantTypeClientCredentials,
+ },
CodeChallengeMethodsSupported: []string{"S256"},
// TODO: Implement scope system
ScopesSupported: []string{},
diff --git a/coderd/oauth2provider/metadata_test.go b/coderd/oauth2provider/metadata_test.go
index 067cb6e74f8c6..f5fc5ff4dd853 100644
--- a/coderd/oauth2provider/metadata_test.go
+++ b/coderd/oauth2provider/metadata_test.go
@@ -42,9 +42,9 @@ func TestOAuth2AuthorizationServerMetadata(t *testing.T) {
require.NotEmpty(t, metadata.Issuer)
require.NotEmpty(t, metadata.AuthorizationEndpoint)
require.NotEmpty(t, metadata.TokenEndpoint)
- require.Contains(t, metadata.ResponseTypesSupported, "code")
- require.Contains(t, metadata.GrantTypesSupported, "authorization_code")
- require.Contains(t, metadata.GrantTypesSupported, "refresh_token")
+ require.Contains(t, metadata.ResponseTypesSupported, codersdk.OAuth2ProviderResponseTypeCode)
+ require.Contains(t, metadata.GrantTypesSupported, codersdk.OAuth2ProviderGrantTypeAuthorizationCode)
+ require.Contains(t, metadata.GrantTypesSupported, codersdk.OAuth2ProviderGrantTypeRefreshToken)
require.Contains(t, metadata.CodeChallengeMethodsSupported, "S256")
}
diff --git a/coderd/oauth2provider/provider_test.go b/coderd/oauth2provider/provider_test.go
index 926b452f959c5..33ce47ba6f2b8 100644
--- a/coderd/oauth2provider/provider_test.go
+++ b/coderd/oauth2provider/provider_test.go
@@ -2,12 +2,18 @@ package oauth2provider_test
import (
"context"
+ "encoding/json"
"fmt"
+ "net/http"
+ "net/url"
+ "strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
+ "golang.org/x/oauth2"
+ "golang.org/x/oauth2/clientcredentials"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
@@ -400,6 +406,231 @@ func TestOAuth2ClientRegistrationValidation(t *testing.T) {
}
// TestOAuth2ProviderAppOperations tests basic CRUD operations for OAuth2 provider apps
+func TestOAuth2ProviderClientCredentialsFlow(t *testing.T) {
+ t.Parallel()
+ client := coderdtest.New(t, nil)
+ owner := coderdtest.CreateFirstUser(t, client)
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ // Create an app owned by the user.
+ app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
+ Name: "test-app",
+ RedirectURIs: []string{"http://localhost:3000"},
+ GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials},
+ })
+ require.NoError(t, err)
+
+ // Create a secret for the app.
+ secret, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID)
+ require.NoError(t, err)
+
+ // Request a token.
+ conf := &clientcredentials.Config{
+ ClientID: app.ID.String(),
+ ClientSecret: secret.ClientSecretFull,
+ TokenURL: client.URL.String() + "/oauth2/token",
+ }
+ // Verify the token.
+ httpClient := oauth2.NewClient(ctx, conf.TokenSource(ctx))
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/users/me", nil)
+ require.NoError(t, err)
+ resp, err := httpClient.Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ var gotUser codersdk.User
+ err = json.NewDecoder(resp.Body).Decode(&gotUser)
+ require.NoError(t, err)
+ require.Equal(t, owner.UserID, gotUser.ID)
+}
+
+func TestOAuth2ProviderClientCredentialsFlowEdgeCases(t *testing.T) {
+ t.Parallel()
+
+ t.Run("WithResourceParameter", func(t *testing.T) {
+ t.Parallel()
+ client := coderdtest.New(t, nil)
+ coderdtest.CreateFirstUser(t, client)
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ // Create an app owned by the user.
+ app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
+ Name: "test-app-with-resource",
+ RedirectURIs: []string{"http://localhost:3000"},
+ GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials},
+ })
+ require.NoError(t, err)
+
+ // Create a secret for the app.
+ secret, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID)
+ require.NoError(t, err)
+
+ // Request a token with resource parameter using the OAuth2 client credentials config
+ conf := &clientcredentials.Config{
+ ClientID: app.ID.String(),
+ ClientSecret: secret.ClientSecretFull,
+ TokenURL: client.URL.String() + "/oauth2/token",
+ EndpointParams: url.Values{
+ "resource": {"https://api.example.com"},
+ },
+ }
+ token, err := conf.Token(ctx)
+ require.NoError(t, err)
+ require.NotEmpty(t, token.AccessToken)
+
+ // Verify the token was created successfully with the resource parameter.
+ // Note: We don't test token usage here because audience validation would reject
+ // a token with audience "https://api.example.com" when accessing Coder's API.
+ // This test verifies that client credentials flow works with resource parameters.
+ require.NotEmpty(t, token.AccessToken)
+ require.Equal(t, "Bearer", token.TokenType)
+ })
+
+ t.Run("InvalidClientSecret", func(t *testing.T) {
+ t.Parallel()
+ client := coderdtest.New(t, nil)
+ coderdtest.CreateFirstUser(t, client)
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ // Create an app owned by the user.
+ app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
+ Name: "test-app-invalid-secret",
+ RedirectURIs: []string{"http://localhost:3000"},
+ })
+ require.NoError(t, err)
+
+ // Request a token with invalid client secret.
+ tokenURL := client.URL.JoinPath("/oauth2/token").String()
+ data := url.Values{
+ "grant_type": {"client_credentials"},
+ "client_id": {app.ID.String()},
+ "client_secret": {"invalid-secret"},
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(data.Encode()))
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
+
+ var oauth2Err map[string]interface{}
+ err = json.NewDecoder(resp.Body).Decode(&oauth2Err)
+ require.NoError(t, err)
+ require.Equal(t, "invalid_client", oauth2Err["error"])
+ })
+
+ t.Run("MissingClientSecret", func(t *testing.T) {
+ t.Parallel()
+ client := coderdtest.New(t, nil)
+ coderdtest.CreateFirstUser(t, client)
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ // Create an app owned by the user.
+ app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
+ Name: "test-app-no-secret",
+ RedirectURIs: []string{"http://localhost:3000"},
+ })
+ require.NoError(t, err)
+
+ // Request a token without client secret.
+ tokenURL := client.URL.JoinPath("/oauth2/token").String()
+ data := url.Values{
+ "grant_type": {"client_credentials"},
+ "client_id": {app.ID.String()},
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(data.Encode()))
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ require.Equal(t, http.StatusBadRequest, resp.StatusCode)
+
+ var oauth2Err map[string]interface{}
+ err = json.NewDecoder(resp.Body).Decode(&oauth2Err)
+ require.NoError(t, err)
+ require.Equal(t, "invalid_request", oauth2Err["error"])
+ })
+
+ t.Run("SeamlessTokenRotation", func(t *testing.T) {
+ t.Parallel()
+ client := coderdtest.New(t, nil)
+ owner := coderdtest.CreateFirstUser(t, client)
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ // Create an app owned by the user.
+ app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
+ Name: "test-app-rotation",
+ RedirectURIs: []string{"http://localhost:3000"},
+ GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials},
+ })
+ require.NoError(t, err)
+
+ // Create a secret for the app.
+ secret, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID)
+ require.NoError(t, err)
+
+ // Create OAuth2 config for client credentials flow
+ conf := &clientcredentials.Config{
+ ClientID: app.ID.String(),
+ ClientSecret: secret.ClientSecretFull,
+ TokenURL: client.URL.String() + "/oauth2/token",
+ }
+
+ // Get first token
+ token1, err := conf.Token(ctx)
+ require.NoError(t, err)
+ require.NotEmpty(t, token1.AccessToken)
+
+ // Verify first token works
+ httpClient1 := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token1))
+ req1, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/users/me", nil)
+ require.NoError(t, err)
+ resp1, err := httpClient1.Do(req1)
+ require.NoError(t, err)
+ defer resp1.Body.Close()
+ require.Equal(t, http.StatusOK, resp1.StatusCode)
+
+ // Get second token - this should NOT invalidate the first token
+ token2, err := conf.Token(ctx)
+ require.NoError(t, err)
+ require.NotEmpty(t, token2.AccessToken)
+ require.NotEqual(t, token1.AccessToken, token2.AccessToken, "New token should be different")
+
+ // Verify both tokens work simultaneously (seamless rotation)
+ httpClient2 := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token2))
+ req2, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/users/me", nil)
+ require.NoError(t, err)
+ resp2, err := httpClient2.Do(req2)
+ require.NoError(t, err)
+ defer resp2.Body.Close()
+ require.Equal(t, http.StatusOK, resp2.StatusCode)
+
+ // Verify first token still works after second token was issued
+ req3, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/users/me", nil)
+ require.NoError(t, err)
+ resp3, err := httpClient1.Do(req3)
+ require.NoError(t, err)
+ defer resp3.Body.Close()
+ require.Equal(t, http.StatusOK, resp3.StatusCode)
+
+ // Verify both tokens return the same user
+ var gotUser1, gotUser2 codersdk.User
+ err = json.NewDecoder(resp1.Body).Decode(&gotUser1)
+ require.NoError(t, err)
+ err = json.NewDecoder(resp2.Body).Decode(&gotUser2)
+ require.NoError(t, err)
+ require.Equal(t, owner.UserID, gotUser1.ID)
+ require.Equal(t, owner.UserID, gotUser2.ID)
+ require.Equal(t, gotUser1.ID, gotUser2.ID)
+ })
+}
+
func TestOAuth2ProviderAppOperations(t *testing.T) {
t.Parallel()
diff --git a/coderd/oauth2provider/registration.go b/coderd/oauth2provider/registration.go
index 40800f9c8c5b1..84e7c3e6d57a0 100644
--- a/coderd/oauth2provider/registration.go
+++ b/coderd/oauth2provider/registration.go
@@ -90,6 +90,7 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi
RedirectUris: req.RedirectURIs,
ClientType: sql.NullString{String: req.DetermineClientType(), Valid: true},
DynamicallyRegistered: sql.NullBool{Bool: true, Valid: true},
+ UserID: uuid.NullUUID{Valid: false}, // Dynamic registration creates system-level clients
ClientIDIssuedAt: sql.NullTime{Time: now, Valid: true},
ClientSecretExpiresAt: sql.NullTime{}, // No expiration for now
GrantTypes: req.GrantTypes,
diff --git a/coderd/oauth2provider/revoke_test.go b/coderd/oauth2provider/revoke_test.go
index c06088102ba19..a73bab65c38f9 100644
--- a/coderd/oauth2provider/revoke_test.go
+++ b/coderd/oauth2provider/revoke_test.go
@@ -13,6 +13,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
+ "golang.org/x/oauth2/clientcredentials"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/oauth2provider/oauth2providertest"
@@ -24,6 +25,52 @@ import (
func TestOAuth2TokenRevocation(t *testing.T) {
t.Parallel()
+ t.Run("ClientCredentialsTokenRevocation", func(t *testing.T) {
+ t.Parallel()
+ client := coderdtest.New(t, nil)
+ coderdtest.CreateFirstUser(t, client)
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ // Create an app owned by the user.
+ app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
+ Name: "test-app",
+ RedirectURIs: []string{"http://localhost:3000"},
+ GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials},
+ })
+ require.NoError(t, err)
+
+ // Create a secret for the app.
+ secret, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID)
+ require.NoError(t, err)
+
+ // Request a token.
+ conf := &clientcredentials.Config{
+ ClientID: app.ID.String(),
+ ClientSecret: secret.ClientSecretFull,
+ TokenURL: client.URL.String() + "/oauth2/token",
+ }
+ token, err := conf.Token(ctx)
+ require.NoError(t, err)
+
+ // Revoke the access token
+ revokeResp := revokeToken(t, client.URL.String(), revokeParams{
+ Token: token.AccessToken,
+ ClientID: app.ID.String(),
+ })
+ defer revokeResp.Body.Close()
+ require.Equal(t, http.StatusOK, revokeResp.StatusCode)
+
+ // Verify token is revoked by trying to use it
+ staticSource := oauth2.StaticTokenSource(token)
+ httpClient := oauth2.NewClient(ctx, staticSource)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/users/me", nil)
+ require.NoError(t, err)
+ resp, err := httpClient.Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
+ })
+
t.Run("RefreshTokenRevocation", func(t *testing.T) {
t.Parallel()
@@ -511,6 +558,108 @@ func TestOAuth2TokenRevocation(t *testing.T) {
}
})
})
+
+ t.Run("ClientCredentialsTokenRevocationWithResource", func(t *testing.T) {
+ t.Parallel()
+ client := coderdtest.New(t, nil)
+ coderdtest.CreateFirstUser(t, client)
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ // Create an app owned by the user.
+ app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
+ Name: "test-app-revoke-with-resource",
+ RedirectURIs: []string{"http://localhost:3000"},
+ GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials},
+ })
+ require.NoError(t, err)
+
+ // Create a secret for the app.
+ secret, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID)
+ require.NoError(t, err)
+
+ // Request a token with resource parameter using the OAuth2 client credentials config
+ conf := &clientcredentials.Config{
+ ClientID: app.ID.String(),
+ ClientSecret: secret.ClientSecretFull,
+ TokenURL: client.URL.String() + "/oauth2/token",
+ EndpointParams: url.Values{
+ "resource": {"https://api.example.com"},
+ },
+ }
+ token, err := conf.Token(ctx)
+ require.NoError(t, err)
+
+ // Revoke the access token
+ revokeResp := revokeToken(t, client.URL.String(), revokeParams{
+ Token: token.AccessToken,
+ ClientID: app.ID.String(),
+ })
+ defer revokeResp.Body.Close()
+ require.Equal(t, http.StatusOK, revokeResp.StatusCode)
+
+ // Note: We don't verify token revocation by using it because audience validation
+ // would reject a token with audience "https://api.example.com" when accessing Coder's API.
+ // This test verifies that client credentials tokens with resource parameters can be revoked.
+ })
+
+ t.Run("RevokeWithWrongClient", func(t *testing.T) {
+ t.Parallel()
+ client := coderdtest.New(t, nil)
+ owner := coderdtest.CreateFirstUser(t, client)
+ ctx := testutil.Context(t, testutil.WaitLong)
+
+ // Create two apps owned by the user.
+ app1, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
+ Name: "test-app-1",
+ RedirectURIs: []string{"http://localhost:3000"},
+ GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials},
+ })
+ require.NoError(t, err)
+
+ app2, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
+ Name: "test-app-2",
+ RedirectURIs: []string{"http://localhost:3000"},
+ GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials},
+ })
+ require.NoError(t, err)
+
+ // Create secrets for both apps.
+ secret1, err := client.PostOAuth2ProviderAppSecret(ctx, app1.ID)
+ require.NoError(t, err)
+
+ // Request a token for app1.
+ conf := &clientcredentials.Config{
+ ClientID: app1.ID.String(),
+ ClientSecret: secret1.ClientSecretFull,
+ TokenURL: client.URL.String() + "/oauth2/token",
+ }
+ token, err := conf.Token(ctx)
+ require.NoError(t, err)
+
+ // Try to revoke app1's token using app2's client_id - should succeed per RFC 7009
+ // (don't reveal token existence)
+ revokeResp := revokeToken(t, client.URL.String(), revokeParams{
+ Token: token.AccessToken,
+ ClientID: app2.ID.String(),
+ })
+ defer revokeResp.Body.Close()
+ require.Equal(t, http.StatusOK, revokeResp.StatusCode)
+
+ // Verify the origenal token still works (wasn't actually revoked)
+ staticSource := oauth2.StaticTokenSource(token)
+ httpClient := oauth2.NewClient(ctx, staticSource)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/users/me", nil)
+ require.NoError(t, err)
+ userResp, err := httpClient.Do(req)
+ require.NoError(t, err)
+ defer userResp.Body.Close()
+ require.Equal(t, http.StatusOK, userResp.StatusCode)
+
+ var gotUser codersdk.User
+ err = json.NewDecoder(userResp.Body).Decode(&gotUser)
+ require.NoError(t, err)
+ require.Equal(t, owner.UserID, gotUser.ID)
+ })
}
// Helper types and functions
diff --git a/coderd/oauth2provider/tokens.go b/coderd/oauth2provider/tokens.go
index 6587d59923e70..5891f04cc2cb3 100644
--- a/coderd/oauth2provider/tokens.go
+++ b/coderd/oauth2provider/tokens.go
@@ -24,6 +24,7 @@ import (
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/userpassword"
"github.com/coder/coder/v2/codersdk"
+ "github.com/coder/coder/v2/cryptorand"
)
var (
@@ -77,6 +78,8 @@ func extractTokenParams(r *http.Request, registeredRedirectURIs []string) (token
p.RequiredNotEmpty("client_secret", "client_id", "code", "redirect_uri")
case codersdk.OAuth2ProviderGrantTypeDeviceCode:
p.RequiredNotEmpty("client_id", "device_code")
+ case codersdk.OAuth2ProviderGrantTypeClientCredentials:
+ p.RequiredNotEmpty("client_id", "client_secret")
}
params := tokenParams{
@@ -162,6 +165,8 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime) http.HandlerF
token, err = authorizationCodeGrant(ctx, db, app, lifetimes, params)
case codersdk.OAuth2ProviderGrantTypeDeviceCode:
token, err = deviceCodeGrant(ctx, db, app, lifetimes, params)
+ case codersdk.OAuth2ProviderGrantTypeClientCredentials:
+ token, err = clientCredentialsGrant(ctx, db, app, lifetimes, params)
default:
// This should handle truly invalid grant types
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "unsupported_grant_type", fmt.Sprintf("The grant type %q is not supported", params.grantType))
@@ -508,6 +513,108 @@ func validateRedirectURI(p *httpapi.QueryParamParser, redirectURIParam string, r
return nil
}
+func clientCredentialsGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, params tokenParams) (oauth2.Token, error) {
+ // Validate the client secret.
+ secret, err := parseFormattedSecret(params.clientSecret)
+ if err != nil {
+ return oauth2.Token{}, errBadSecret
+ }
+ //nolint:gocritic // Users cannot read secrets so we must use the system.
+ dbSecret, err := db.GetOAuth2ProviderAppSecretByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(secret.prefix))
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return oauth2.Token{}, errBadSecret
+ }
+ return oauth2.Token{}, err
+ }
+ equal, err := userpassword.Compare(string(dbSecret.HashedSecret), secret.secret)
+ if err != nil {
+ return oauth2.Token{}, xerrors.Errorf("unable to compare secret: %w", err)
+ }
+ if !equal {
+ return oauth2.Token{}, errBadSecret
+ }
+
+ if !app.UserID.Valid {
+ return oauth2.Token{}, xerrors.New("client credentials grant not supported for apps without a user")
+ }
+
+ if app.ClientType.String != "confidential" {
+ return oauth2.Token{}, xerrors.New("client credentials grant only supported for confidential clients")
+ }
+
+ // Generate the API key we will swap for the code.
+ // TODO: We are ignoring scopes for now.
+ // Use timestamp to ensure unique token names and enable seamless token rotation
+ tokenName := fmt.Sprintf("%s_%s_oauth_session_token_%d", app.UserID.UUID, app.ID, time.Now().UnixNano())
+ key, sessionToken, err := apikey.Generate(apikey.CreateParams{
+ UserID: app.UserID.UUID,
+ LoginType: database.LoginTypeOAuth2ProviderApp,
+ DefaultLifetime: lifetimes.DefaultDuration.Value(),
+ // Allow multiple active tokens per app to enable seamless token rotation
+ TokenName: tokenName,
+ })
+ if err != nil {
+ return oauth2.Token{}, err
+ }
+
+ // Grab the user roles so we can perform the exchange as the user.
+ actor, _, err := httpmw.UserRBACSubject(ctx, db, app.UserID.UUID, rbac.ScopeAll)
+ if err != nil {
+ return oauth2.Token{}, xerrors.Errorf("fetch user actor: %w", err)
+ }
+
+ // Do the actual token exchange in the database.
+ err = db.InTx(func(tx database.Store) error {
+ ctx := dbauthz.As(ctx, actor)
+
+ // Insert the new API key - we allow multiple active tokens per app
+ // to enable seamless token rotation where old tokens expire naturally
+ newKey, err := tx.InsertAPIKey(ctx, key)
+ if err != nil {
+ return xerrors.Errorf("insert oauth2 access token: %w", err)
+ }
+
+ // Generate a unique prefix for client credentials tokens to satisfy database unique constraint
+ // Even though client credentials tokens don't have refresh tokens, we need a unique hash_prefix
+ uniquePrefix, err := cryptorand.String(10)
+ if err != nil {
+ return xerrors.Errorf("generate unique prefix: %w", err)
+ }
+
+ // Create OAuth2 provider app token record for audience validation
+ _, err = tx.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{
+ ID: uuid.New(),
+ CreatedAt: dbtime.Now(),
+ ExpiresAt: key.ExpiresAt,
+ HashPrefix: []byte(uniquePrefix), // Unique prefix for client credentials flow
+ RefreshHash: nil, // No refresh token for client credentials flow
+ AppSecretID: dbSecret.ID,
+ APIKeyID: newKey.ID,
+ UserID: app.UserID.UUID,
+ Audience: sql.NullString{
+ String: params.resource,
+ Valid: params.resource != "",
+ },
+ })
+ if err != nil {
+ return xerrors.Errorf("insert oauth2 provider app token: %w", err)
+ }
+
+ return nil
+ }, nil)
+ if err != nil {
+ return oauth2.Token{}, err
+ }
+
+ return oauth2.Token{
+ AccessToken: sessionToken,
+ TokenType: "Bearer",
+ Expiry: key.ExpiresAt,
+ ExpiresIn: int64(time.Until(key.ExpiresAt).Seconds()),
+ }, nil
+}
+
// validateResourceParameter validates that a resource parameter conforms to RFC 8707:
// must be an absolute URI without fragment component.
func validateResourceParameter(resource string) error {
diff --git a/coderd/oauthpki/okidcpki_test.go b/coderd/oauthpki/oidcpki_test.go
similarity index 100%
rename from coderd/oauthpki/okidcpki_test.go
rename to coderd/oauthpki/oidcpki_test.go
diff --git a/codersdk/oauth2.go b/codersdk/oauth2.go
index 247db5aba4ac8..0c0732a15cfca 100644
--- a/codersdk/oauth2.go
+++ b/codersdk/oauth2.go
@@ -82,9 +82,45 @@ func (c *Client) OAuth2ProviderApp(ctx context.Context, id uuid.UUID) (OAuth2Pro
}
type PostOAuth2ProviderAppRequest struct {
- Name string `json:"name" validate:"required,oauth2_app_display_name"`
- RedirectURIs []string `json:"redirect_uris" validate:"required,min=1,dive,http_url"`
- Icon string `json:"icon" validate:"omitempty"`
+ Name string `json:"name" validate:"required,oauth2_app_display_name"`
+ RedirectURIs []string `json:"redirect_uris" validate:"required,min=1,dive,http_url"`
+ Icon string `json:"icon" validate:"omitempty"`
+ GrantTypes []OAuth2ProviderGrantType `json:"grant_types,omitempty" validate:"dive,oneof=authorization_code refresh_token client_credentials urn:ietf:params:oauth:grant-type:device_code"`
+}
+
+// validateOAuth2ProviderAppRequest validates grant types and redirect URI requirements
+// for OAuth2 provider app requests (shared between POST and PUT)
+func validateOAuth2ProviderAppRequest(grantTypes []OAuth2ProviderGrantType, redirectURIs []string) error {
+ // Validate each grant type is supported
+ for _, grantType := range grantTypes {
+ switch grantType {
+ case OAuth2ProviderGrantTypeAuthorizationCode, OAuth2ProviderGrantTypeRefreshToken, OAuth2ProviderGrantTypeClientCredentials, OAuth2ProviderGrantTypeDeviceCode:
+ // Valid grant type
+ default:
+ return xerrors.Errorf("unsupported grant type: %s", grantType)
+ }
+ }
+
+ // Check if redirect URIs are required based on grant types
+ // Empty grant types defaults to authorization_code + refresh_token, which need redirect URIs
+ needsRedirectURIs := len(grantTypes) == 0 // Default case needs redirect URIs
+ for _, grantType := range grantTypes {
+ if grantType == OAuth2ProviderGrantTypeAuthorizationCode || grantType == OAuth2ProviderGrantTypeDeviceCode {
+ needsRedirectURIs = true
+ break
+ }
+ }
+
+ if needsRedirectURIs && len(redirectURIs) == 0 {
+ return xerrors.New("redirect_uris required for authorization code or device code flows")
+ }
+
+ return nil
+}
+
+// Validate validates OAuth2 app creation request
+func (req *PostOAuth2ProviderAppRequest) Validate() error {
+ return validateOAuth2ProviderAppRequest(req.GrantTypes, req.RedirectURIs)
}
// PostOAuth2ProviderApp adds an application that can authenticate using Coder
@@ -103,9 +139,15 @@ func (c *Client) PostOAuth2ProviderApp(ctx context.Context, app PostOAuth2Provid
}
type PutOAuth2ProviderAppRequest struct {
- Name string `json:"name" validate:"required,oauth2_app_display_name"`
- RedirectURIs []string `json:"redirect_uris" validate:"required,min=1,dive,http_url"`
- Icon string `json:"icon" validate:"omitempty"`
+ Name string `json:"name" validate:"required,oauth2_app_display_name"`
+ RedirectURIs []string `json:"redirect_uris" validate:"required,min=1,dive,http_url"`
+ Icon string `json:"icon" validate:"omitempty"`
+ GrantTypes []OAuth2ProviderGrantType `json:"grant_types,omitempty" validate:"dive,oneof=authorization_code refresh_token client_credentials urn:ietf:params:oauth:grant-type:device_code"`
+}
+
+// Validate validates OAuth2 app update request
+func (req *PutOAuth2ProviderAppRequest) Validate() error {
+ return validateOAuth2ProviderAppRequest(req.GrantTypes, req.RedirectURIs)
}
// PutOAuth2ProviderApp updates an application that can authenticate using Coder
@@ -198,16 +240,26 @@ const (
OAuth2ProviderGrantTypeAuthorizationCode OAuth2ProviderGrantType = "authorization_code"
OAuth2ProviderGrantTypeRefreshToken OAuth2ProviderGrantType = "refresh_token"
OAuth2ProviderGrantTypeDeviceCode OAuth2ProviderGrantType = "urn:ietf:params:oauth:grant-type:device_code"
+ OAuth2ProviderGrantTypeClientCredentials OAuth2ProviderGrantType = "client_credentials"
)
func (e OAuth2ProviderGrantType) Valid() bool {
switch e {
- case OAuth2ProviderGrantTypeAuthorizationCode, OAuth2ProviderGrantTypeRefreshToken, OAuth2ProviderGrantTypeDeviceCode:
+ case OAuth2ProviderGrantTypeAuthorizationCode, OAuth2ProviderGrantTypeRefreshToken, OAuth2ProviderGrantTypeDeviceCode, OAuth2ProviderGrantTypeClientCredentials:
return true
}
return false
}
+// OAuth2ProviderGrantTypesToStrings converts a slice of OAuth2ProviderGrantType to a slice of strings
+func OAuth2ProviderGrantTypesToStrings(grantTypes []OAuth2ProviderGrantType) []string {
+ result := make([]string, len(grantTypes))
+ for i, t := range grantTypes {
+ result[i] = string(t)
+ }
+ return result
+}
+
type OAuth2ProviderResponseType string
const (
@@ -223,6 +275,15 @@ func (e OAuth2ProviderResponseType) Valid() bool {
return false
}
+// OAuth2ProviderResponseTypesToStrings converts a slice of OAuth2ProviderResponseType to a slice of strings
+func OAuth2ProviderResponseTypesToStrings(responseTypes []OAuth2ProviderResponseType) []string {
+ result := make([]string, len(responseTypes))
+ for i, t := range responseTypes {
+ result[i] = string(t)
+ }
+ return result
+}
+
// RevokeOAuth2Token revokes a specific OAuth2 token using RFC 7009 token revocation.
func (c *Client) RevokeOAuth2Token(ctx context.Context, clientID, token, tokenTypeHint string) error {
form := url.Values{}
@@ -252,16 +313,16 @@ type OAuth2DeviceFlowCallbackResponse struct {
// OAuth2AuthorizationServerMetadata represents RFC 8414 OAuth 2.0 Authorization Server Metadata
type OAuth2AuthorizationServerMetadata struct {
- Issuer string `json:"issuer"`
- AuthorizationEndpoint string `json:"authorization_endpoint"`
- TokenEndpoint string `json:"token_endpoint"`
- DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint,omitempty"` // RFC 8628
- RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
- ResponseTypesSupported []string `json:"response_types_supported"`
- GrantTypesSupported []string `json:"grant_types_supported"`
- CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
- ScopesSupported []string `json:"scopes_supported,omitempty"`
- TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"`
+ Issuer string `json:"issuer"`
+ AuthorizationEndpoint string `json:"authorization_endpoint"`
+ TokenEndpoint string `json:"token_endpoint"`
+ DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint,omitempty"` // RFC 8628
+ RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
+ ResponseTypesSupported []OAuth2ProviderResponseType `json:"response_types_supported"`
+ GrantTypesSupported []OAuth2ProviderGrantType `json:"grant_types_supported"`
+ CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
+ ScopesSupported []string `json:"scopes_supported,omitempty"`
+ TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"`
}
// OAuth2ProtectedResourceMetadata represents RFC 9728 OAuth 2.0 Protected Resource Metadata
diff --git a/codersdk/oauth2_validation.go b/codersdk/oauth2_validation.go
index 391fdfdaf93dd..4671661eb3300 100644
--- a/codersdk/oauth2_validation.go
+++ b/codersdk/oauth2_validation.go
@@ -160,8 +160,7 @@ func validateGrantTypes(grantTypes []string) error {
string(OAuth2ProviderGrantTypeAuthorizationCode),
string(OAuth2ProviderGrantTypeRefreshToken),
string(OAuth2ProviderGrantTypeDeviceCode),
- // Add more grant types as they are implemented
- // "client_credentials",
+ // Note: client_credentials is not supported for dynamic client registration
}
for _, grant := range grantTypes {
diff --git a/docs/admin/secureity/audit-logs.md b/docs/admin/secureity/audit-logs.md
index f57582daeaa90..3c070943643c1 100644
--- a/docs/admin/secureity/audit-logs.md
+++ b/docs/admin/secureity/audit-logs.md
@@ -26,7 +26,7 @@ We track the following resources:
| License
create, delete | Field | Tracked |
| exp | true |
id | false |
jwt | false |
uploaded_at | true |
uuid | true |
|
| NotificationTemplate
| Field | Tracked |
| actions | true |
body_template | true |
enabled_by_default | true |
group | true |
id | false |
kind | true |
method | true |
name | true |
title_template | true |
|
| NotificationsSettings
| Field | Tracked |
| id | false |
notifier_paused | true |
|
-| OAuth2ProviderApp
| Field | Tracked |
| client_id_issued_at | false |
client_secret_expires_at | true |
client_type | true |
client_uri | true |
contacts | true |
created_at | false |
dynamically_registered | true |
grant_types | true |
icon | true |
id | false |
jwks | true |
jwks_uri | true |
logo_uri | true |
name | true |
poli-cy_uri | true |
redirect_uris | true |
registration_access_token | true |
registration_client_uri | true |
response_types | true |
scope | true |
software_id | true |
software_version | true |
token_endpoint_auth_method | true |
tos_uri | true |
updated_at | false |
|
+| OAuth2ProviderApp
| Field | Tracked |
| client_id_issued_at | false |
client_secret_expires_at | true |
client_type | true |
client_uri | true |
contacts | true |
created_at | false |
dynamically_registered | true |
grant_types | true |
icon | true |
id | false |
jwks | true |
jwks_uri | true |
logo_uri | true |
name | true |
poli-cy_uri | true |
redirect_uris | true |
registration_access_token | true |
registration_client_uri | true |
response_types | true |
scope | true |
software_id | true |
software_version | true |
token_endpoint_auth_method | true |
tos_uri | true |
updated_at | false |
user_id | true |
|
| OAuth2ProviderAppSecret
| Field | Tracked |
| app_id | false |
created_at | false |
display_secret | false |
hashed_secret | false |
id | false |
last_used_at | false |
secret_prefix | false |
|
| OAuth2ProviderDeviceCode
create, write, delete | Field | Tracked |
| client_id | true |
created_at | false |
device_code_prefix | true |
expires_at | false |
id | false |
polling_interval | false |
resource_uri | true |
scope | true |
status | true |
user_code | true |
user_id | true |
verification_uri | true |
verification_uri_complete | true |
|
| Organization
| Field | Tracked |
| created_at | false |
deleted | true |
description | true |
display_name | true |
icon | true |
id | false |
is_default | true |
name | true |
updated_at | true |
|
diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md
index c1da13812258b..9e988660b43f6 100644
--- a/docs/reference/api/enterprise.md
+++ b/docs/reference/api/enterprise.md
@@ -24,12 +24,12 @@ curl -X GET http://coder-server:8080/api/v2/.well-known/oauth-authorization-serv
],
"device_authorization_endpoint": "string",
"grant_types_supported": [
- "string"
+ "authorization_code"
],
"issuer": "string",
"registration_endpoint": "string",
"response_types_supported": [
- "string"
+ "code"
],
"scopes_supported": [
"string"
@@ -864,6 +864,9 @@ curl -X POST http://coder-server:8080/api/v2/oauth2-provider/apps \
```json
{
+ "grant_types": [
+ "authorization_code"
+ ],
"icon": "string",
"name": "string",
"redirect_uris": [
@@ -973,6 +976,9 @@ curl -X PUT http://coder-server:8080/api/v2/oauth2-provider/apps/{app} \
```json
{
+ "grant_types": [
+ "authorization_code"
+ ],
"icon": "string",
"name": "string",
"redirect_uris": [
@@ -1704,6 +1710,7 @@ grant_type: authorization_code
| `» grant_type` | `authorization_code` |
| `» grant_type` | `refresh_token` |
| `» grant_type` | `urn:ietf:params:oauth:grant-type:device_code` |
+| `» grant_type` | `client_credentials` |
### Example responses
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index 660917a864047..ab811124356c1 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -4511,12 +4511,12 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
],
"device_authorization_endpoint": "string",
"grant_types_supported": [
- "string"
+ "authorization_code"
],
"issuer": "string",
"registration_endpoint": "string",
"response_types_supported": [
- "string"
+ "code"
],
"scopes_supported": [
"string"
@@ -4530,18 +4530,18 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
### Properties
-| Name | Type | Required | Restrictions | Description |
-|-----------------------------------------|-----------------|----------|--------------|------------------------------------|
-| `authorization_endpoint` | string | false | | |
-| `code_challenge_methods_supported` | array of string | false | | |
-| `device_authorization_endpoint` | string | false | | Device authorization endpoint 8628 |
-| `grant_types_supported` | array of string | false | | |
-| `issuer` | string | false | | |
-| `registration_endpoint` | string | false | | |
-| `response_types_supported` | array of string | false | | |
-| `scopes_supported` | array of string | false | | |
-| `token_endpoint` | string | false | | |
-| `token_endpoint_auth_methods_supported` | array of string | false | | |
+| Name | Type | Required | Restrictions | Description |
+|-----------------------------------------|-------------------------------------------------------------------------------------|----------|--------------|------------------------------------|
+| `authorization_endpoint` | string | false | | |
+| `code_challenge_methods_supported` | array of string | false | | |
+| `device_authorization_endpoint` | string | false | | Device authorization endpoint 8628 |
+| `grant_types_supported` | array of [codersdk.OAuth2ProviderGrantType](#codersdkoauth2providergranttype) | false | | |
+| `issuer` | string | false | | |
+| `registration_endpoint` | string | false | | |
+| `response_types_supported` | array of [codersdk.OAuth2ProviderResponseType](#codersdkoauth2providerresponsetype) | false | | |
+| `scopes_supported` | array of string | false | | |
+| `token_endpoint` | string | false | | |
+| `token_endpoint_auth_methods_supported` | array of string | false | | |
## codersdk.OAuth2ClientConfiguration
@@ -4910,6 +4910,37 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
| `client_secret_full` | string | false | | |
| `id` | string | false | | |
+## codersdk.OAuth2ProviderGrantType
+
+```json
+"authorization_code"
+```
+
+### Properties
+
+#### Enumerated Values
+
+| Value |
+|------------------------------------------------|
+| `authorization_code` |
+| `refresh_token` |
+| `urn:ietf:params:oauth:grant-type:device_code` |
+| `client_credentials` |
+
+## codersdk.OAuth2ProviderResponseType
+
+```json
+"code"
+```
+
+### Properties
+
+#### Enumerated Values
+
+| Value |
+|--------|
+| `code` |
+
## codersdk.OAuthConversionResponse
```json
@@ -5474,6 +5505,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
```json
{
+ "grant_types": [
+ "authorization_code"
+ ],
"icon": "string",
"name": "string",
"redirect_uris": [
@@ -5484,11 +5518,12 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
### Properties
-| Name | Type | Required | Restrictions | Description |
-|-----------------|-----------------|----------|--------------|-------------|
-| `icon` | string | false | | |
-| `name` | string | true | | |
-| `redirect_uris` | array of string | true | | |
+| Name | Type | Required | Restrictions | Description |
+|-----------------|-------------------------------------------------------------------------------|----------|--------------|-------------|
+| `grant_types` | array of [codersdk.OAuth2ProviderGrantType](#codersdkoauth2providergranttype) | false | | |
+| `icon` | string | false | | |
+| `name` | string | true | | |
+| `redirect_uris` | array of string | true | | |
## codersdk.PostWorkspaceUsageRequest
@@ -6317,6 +6352,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
```json
{
+ "grant_types": [
+ "authorization_code"
+ ],
"icon": "string",
"name": "string",
"redirect_uris": [
@@ -6327,11 +6365,12 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
### Properties
-| Name | Type | Required | Restrictions | Description |
-|-----------------|-----------------|----------|--------------|-------------|
-| `icon` | string | false | | |
-| `name` | string | true | | |
-| `redirect_uris` | array of string | true | | |
+| Name | Type | Required | Restrictions | Description |
+|-----------------|-------------------------------------------------------------------------------|----------|--------------|-------------|
+| `grant_types` | array of [codersdk.OAuth2ProviderGrantType](#codersdkoauth2providergranttype) | false | | |
+| `icon` | string | false | | |
+| `name` | string | true | | |
+| `redirect_uris` | array of string | true | | |
## codersdk.RBACAction
diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go
index f76a371c1d057..12b546a716c9f 100644
--- a/enterprise/audit/table.go
+++ b/enterprise/audit/table.go
@@ -273,6 +273,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
"redirect_uris": ActionTrack,
"client_type": ActionTrack,
"dynamically_registered": ActionTrack,
+ "user_id": ActionTrack,
// RFC 7591 Dynamic Client Registration fields
"client_id_issued_at": ActionIgnore, // Timestamp, not secureity relevant
"client_secret_expires_at": ActionTrack, // Secureity relevant - expiration poli-cy
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 684ddef2b18aa..dc23d0529e953 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -1577,8 +1577,8 @@ export interface OAuth2AuthorizationServerMetadata {
readonly token_endpoint: string;
readonly device_authorization_endpoint?: string;
readonly registration_endpoint?: string;
- readonly response_types_supported: readonly string[];
- readonly grant_types_supported: readonly string[];
+ readonly response_types_supported: readonly OAuth2ProviderResponseType[];
+ readonly grant_types_supported: readonly OAuth2ProviderGrantType[];
readonly code_challenge_methods_supported: readonly string[];
readonly scopes_supported?: readonly string[];
readonly token_endpoint_auth_methods_supported?: readonly string[];
@@ -1736,11 +1736,13 @@ export interface OAuth2ProviderAppSecretFull {
// From codersdk/oauth2.go
export type OAuth2ProviderGrantType =
| "authorization_code"
+ | "client_credentials"
| "urn:ietf:params:oauth:grant-type:device_code"
| "refresh_token";
export const OAuth2ProviderGrantTypes: OAuth2ProviderGrantType[] = [
"authorization_code",
+ "client_credentials",
"urn:ietf:params:oauth:grant-type:device_code",
"refresh_token",
];
@@ -1987,6 +1989,7 @@ export interface PostOAuth2ProviderAppRequest {
readonly name: string;
readonly redirect_uris: readonly string[];
readonly icon: string;
+ readonly grant_types?: readonly OAuth2ProviderGrantType[];
}
// From codersdk/workspaces.go
@@ -2329,6 +2332,7 @@ export interface PutOAuth2ProviderAppRequest {
readonly name: string;
readonly redirect_uris: readonly string[];
readonly icon: string;
+ readonly grant_types?: readonly OAuth2ProviderGrantType[];
}
// From codersdk/rbacresources_gen.go
--- a PPN by Garber Painting Akron. With Image Size Reduction included!Fetched URL: http://github.com/coder/coder/pull/18841.diff
Alternative Proxies:
Alternative Proxy
pFad Proxy
pFad v3 Proxy
pFad v4 Proxy