From fa995da0cb1c80fcf15e040ef7ad53730269734d Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Mon, 23 Jun 2025 13:32:59 -0700 Subject: [PATCH] Pass application_type as native (#252226) We should be treated as a public client. --- src/vs/base/common/oauth.ts | 60 +++++++- src/vs/base/test/common/oauth.test.ts | 211 +++++++++++++++++++++++++- 2 files changed, 267 insertions(+), 4 deletions(-) diff --git a/src/vs/base/common/oauth.ts b/src/vs/base/common/oauth.ts index 73bd16882ed..45288003e34 100644 --- a/src/vs/base/common/oauth.ts +++ b/src/vs/base/common/oauth.ts @@ -46,6 +46,28 @@ export const enum AuthorizationDeviceCodeErrorType { ExpiredToken = 'expired_token' } +/** + * Dynamic client registration specific error codes as specified in RFC 7591. + */ +export const enum AuthorizationRegistrationErrorType { + /** + * The value of one or more redirection URIs is invalid. + */ + InvalidRedirectUri = 'invalid_redirect_uri', + /** + * The value of one of the client metadata fields is invalid and the server has rejected this request. + */ + InvalidClientMetadata = 'invalid_client_metadata', + /** + * The software statement presented is invalid. + */ + InvalidSoftwareStatement = 'invalid_software_statement', + /** + * The software statement presented is not approved for use by this authorization server. + */ + UnapprovedSoftwareStatement = 'unapproved_software_statement' +} + /** * Metadata about a protected resource. */ @@ -458,6 +480,18 @@ export interface IAuthorizationDeviceTokenErrorResponse extends IAuthorizationEr error: AuthorizationErrorType | AuthorizationDeviceCodeErrorType | string; } +export interface IAuthorizationRegistrationErrorResponse { + /** + * REQUIRED. Error code as specified in OAuth 2.0 or Dynamic Client Registration. + */ + error: AuthorizationRegistrationErrorType | string; + + /** + * OPTIONAL. Human-readable description of the error. + */ + error_description?: string; +} + export interface IAuthorizationJWTClaims { /** * REQUIRED. JWT ID. Unique identifier for the token. @@ -653,6 +687,14 @@ export function isAuthorizationErrorResponse(obj: unknown): obj is IAuthorizatio return response.error !== undefined; } +export function isAuthorizationRegistrationErrorResponse(obj: unknown): obj is IAuthorizationRegistrationErrorResponse { + if (typeof obj !== 'object' || obj === null) { + return false; + } + const response = obj as IAuthorizationRegistrationErrorResponse; + return response.error !== undefined; +} + //#endregion export function getDefaultMetadataForUrl(authorizationServer: URL): IRequiredAuthorizationServerMetadata & IRequiredAuthorizationServerMetadata { @@ -719,12 +761,26 @@ export async function fetchDynamicRegistration(serverMetadata: IAuthorizationSer `http://127.0.0.1:${DEFAULT_AUTH_FLOW_PORT}/` ], scope: scopes?.join(AUTH_SCOPE_SEPARATOR), - token_endpoint_auth_method: 'none' + token_endpoint_auth_method: 'none', + // https://openid.net/specs/openid-connect-registration-1_0.html + application_type: 'native' }) }); if (!response.ok) { - throw new Error(`Registration failed: ${response.statusText}`); + const result = await response.text(); + let errorDetails: string = result; + + try { + const errorResponse = JSON.parse(result); + if (isAuthorizationRegistrationErrorResponse(errorResponse)) { + errorDetails = `${errorResponse.error}${errorResponse.error_description ? `: ${errorResponse.error_description}` : ''}`; + } + } catch { + // JSON parsing failed, use raw text + } + + throw new Error(`Registration to ${serverMetadata.registration_endpoint} failed: ${errorDetails}`); } const registration = await response.json(); diff --git a/src/vs/base/test/common/oauth.test.ts b/src/vs/base/test/common/oauth.test.ts index b2336ce3e17..b45211ac16e 100644 --- a/src/vs/base/test/common/oauth.test.ts +++ b/src/vs/base/test/common/oauth.test.ts @@ -356,7 +356,8 @@ suite('OAuth', () => { test('fetchDynamicRegistration should throw error on non-OK response', async () => { fetchStub.resolves({ ok: false, - statusText: 'Bad Request' + statusText: 'Bad Request', + text: async () => 'Bad Request' } as Response); const serverMetadata: IAuthorizationServerMetadata = { @@ -367,7 +368,7 @@ suite('OAuth', () => { await assert.rejects( async () => await fetchDynamicRegistration(serverMetadata, 'Test Client'), - /Registration failed: Bad Request/ + /Registration to https:\/\/auth\.example\.com\/register failed: Bad Request/ ); }); @@ -448,6 +449,212 @@ suite('OAuth', () => { const requestBody = JSON.parse(options.body as string); assert.deepStrictEqual(requestBody.grant_types, ['authorization_code', 'refresh_token', 'urn:ietf:params:oauth:grant-type:device_code']); }); + + test('fetchDynamicRegistration should throw error when registration endpoint is missing', async () => { + const serverMetadata: IAuthorizationServerMetadata = { + issuer: 'https://auth.example.com', + response_types_supported: ['code'] + // registration_endpoint is missing + }; + + await assert.rejects( + async () => await fetchDynamicRegistration(serverMetadata, 'Test Client'), + /Server does not support dynamic registration/ + ); + }); + + test('fetchDynamicRegistration should handle structured error response', async () => { + const errorResponse = { + error: 'invalid_client_metadata', + error_description: 'The client metadata is invalid' + }; + + fetchStub.resolves({ + ok: false, + text: async () => JSON.stringify(errorResponse) + } as Response); + + const serverMetadata: IAuthorizationServerMetadata = { + issuer: 'https://auth.example.com', + registration_endpoint: 'https://auth.example.com/register', + response_types_supported: ['code'] + }; + + await assert.rejects( + async () => await fetchDynamicRegistration(serverMetadata, 'Test Client'), + /Registration to https:\/\/auth\.example\.com\/register failed: invalid_client_metadata: The client metadata is invalid/ + ); + }); + + test('fetchDynamicRegistration should handle structured error response without description', async () => { + const errorResponse = { + error: 'invalid_redirect_uri' + }; + + fetchStub.resolves({ + ok: false, + text: async () => JSON.stringify(errorResponse) + } as Response); + + const serverMetadata: IAuthorizationServerMetadata = { + issuer: 'https://auth.example.com', + registration_endpoint: 'https://auth.example.com/register', + response_types_supported: ['code'] + }; + + await assert.rejects( + async () => await fetchDynamicRegistration(serverMetadata, 'Test Client'), + /Registration to https:\/\/auth\.example\.com\/register failed: invalid_redirect_uri/ + ); + }); + + test('fetchDynamicRegistration should handle malformed JSON error response', async () => { + fetchStub.resolves({ + ok: false, + text: async () => 'Invalid JSON {' + } as Response); + + const serverMetadata: IAuthorizationServerMetadata = { + issuer: 'https://auth.example.com', + registration_endpoint: 'https://auth.example.com/register', + response_types_supported: ['code'] + }; + + await assert.rejects( + async () => await fetchDynamicRegistration(serverMetadata, 'Test Client'), + /Registration to https:\/\/auth\.example\.com\/register failed: Invalid JSON \{/ + ); + }); + + test('fetchDynamicRegistration should include scopes in request when provided', async () => { + const mockResponse = { + client_id: 'generated-client-id', + client_name: 'Test Client' + }; + + fetchStub.resolves({ + ok: true, + json: async () => mockResponse + } as Response); + + const serverMetadata: IAuthorizationServerMetadata = { + issuer: 'https://auth.example.com', + registration_endpoint: 'https://auth.example.com/register', + response_types_supported: ['code'] + }; + + await fetchDynamicRegistration(serverMetadata, 'Test Client', ['read', 'write']); + + // Verify request includes scopes + const [, options] = fetchStub.firstCall.args; + const requestBody = JSON.parse(options.body as string); + assert.strictEqual(requestBody.scope, 'read write'); + }); + + test('fetchDynamicRegistration should omit scope from request when not provided', async () => { + const mockResponse = { + client_id: 'generated-client-id', + client_name: 'Test Client' + }; + + fetchStub.resolves({ + ok: true, + json: async () => mockResponse + } as Response); + + const serverMetadata: IAuthorizationServerMetadata = { + issuer: 'https://auth.example.com', + registration_endpoint: 'https://auth.example.com/register', + response_types_supported: ['code'] + }; + + await fetchDynamicRegistration(serverMetadata, 'Test Client'); + + // Verify request does not include scope when not provided + const [, options] = fetchStub.firstCall.args; + const requestBody = JSON.parse(options.body as string); + assert.strictEqual(requestBody.scope, undefined); + }); + + test('fetchDynamicRegistration should handle empty scopes array', async () => { + const mockResponse = { + client_id: 'generated-client-id', + client_name: 'Test Client' + }; + + fetchStub.resolves({ + ok: true, + json: async () => mockResponse + } as Response); + + const serverMetadata: IAuthorizationServerMetadata = { + issuer: 'https://auth.example.com', + registration_endpoint: 'https://auth.example.com/register', + response_types_supported: ['code'] + }; + + await fetchDynamicRegistration(serverMetadata, 'Test Client', []); + + // Verify request includes empty scope + const [, options] = fetchStub.firstCall.args; + const requestBody = JSON.parse(options.body as string); + assert.strictEqual(requestBody.scope, ''); + }); + + test('fetchDynamicRegistration should handle network fetch failure', async () => { + fetchStub.rejects(new Error('Network error')); + + const serverMetadata: IAuthorizationServerMetadata = { + issuer: 'https://auth.example.com', + registration_endpoint: 'https://auth.example.com/register', + response_types_supported: ['code'] + }; + + await assert.rejects( + async () => await fetchDynamicRegistration(serverMetadata, 'Test Client'), + /Network error/ + ); + }); + + test('fetchDynamicRegistration should handle response.json() failure', async () => { + fetchStub.resolves({ + ok: true, + json: async () => { + throw new Error('JSON parsing failed'); + } + } as unknown as Response); + + const serverMetadata: IAuthorizationServerMetadata = { + issuer: 'https://auth.example.com', + registration_endpoint: 'https://auth.example.com/register', + response_types_supported: ['code'] + }; + + await assert.rejects( + async () => await fetchDynamicRegistration(serverMetadata, 'Test Client'), + /JSON parsing failed/ + ); + }); + + test('fetchDynamicRegistration should handle response.text() failure for error cases', async () => { + fetchStub.resolves({ + ok: false, + text: async () => { + throw new Error('Text parsing failed'); + } + } as unknown as Response); + + const serverMetadata: IAuthorizationServerMetadata = { + issuer: 'https://auth.example.com', + registration_endpoint: 'https://auth.example.com/register', + response_types_supported: ['code'] + }; + + await assert.rejects( + async () => await fetchDynamicRegistration(serverMetadata, 'Test Client'), + /Text parsing failed/ + ); + }); }); suite('getResourceServerBaseUrlFromDiscoveryUrl', () => {