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, policy_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, policy_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, policy_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, policy_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, policy_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, policy_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, policy_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, policy_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.policy_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.policy_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, policy_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, policy_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, policy_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, policy_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, policy_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, policy_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 original 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/security/audit-logs.md b/docs/admin/security/audit-logs.md index f57582daeaa90..3c070943643c1 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -26,7 +26,7 @@ We track the following resources: | License
create, delete | |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| | NotificationTemplate
| |
FieldTracked
actionstrue
body_templatetrue
enabled_by_defaulttrue
grouptrue
idfalse
kindtrue
methodtrue
nametrue
title_templatetrue
| | NotificationsSettings
| |
FieldTracked
idfalse
notifier_pausedtrue
| -| OAuth2ProviderApp
| |
FieldTracked
client_id_issued_atfalse
client_secret_expires_attrue
client_typetrue
client_uritrue
contactstrue
created_atfalse
dynamically_registeredtrue
grant_typestrue
icontrue
idfalse
jwkstrue
jwks_uritrue
logo_uritrue
nametrue
policy_uritrue
redirect_uristrue
registration_access_tokentrue
registration_client_uritrue
response_typestrue
scopetrue
software_idtrue
software_versiontrue
token_endpoint_auth_methodtrue
tos_uritrue
updated_atfalse
| +| OAuth2ProviderApp
| |
FieldTracked
client_id_issued_atfalse
client_secret_expires_attrue
client_typetrue
client_uritrue
contactstrue
created_atfalse
dynamically_registeredtrue
grant_typestrue
icontrue
idfalse
jwkstrue
jwks_uritrue
logo_uritrue
nametrue
policy_uritrue
redirect_uristrue
registration_access_tokentrue
registration_client_uritrue
response_typestrue
scopetrue
software_idtrue
software_versiontrue
token_endpoint_auth_methodtrue
tos_uritrue
updated_atfalse
user_idtrue
| | OAuth2ProviderAppSecret
| |
FieldTracked
app_idfalse
created_atfalse
display_secretfalse
hashed_secretfalse
idfalse
last_used_atfalse
secret_prefixfalse
| | OAuth2ProviderDeviceCode
create, write, delete | |
FieldTracked
client_idtrue
created_atfalse
device_code_prefixtrue
expires_atfalse
idfalse
polling_intervalfalse
resource_uritrue
scopetrue
statustrue
user_codetrue
user_idtrue
verification_uritrue
verification_uri_completetrue
| | Organization
| |
FieldTracked
created_atfalse
deletedtrue
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
updated_attrue
| 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 security relevant "client_secret_expires_at": ActionTrack, // Security relevant - expiration policy 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 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