diff --git a/platform/packages/api/src/auth/oidc.ts b/platform/packages/api/src/auth/oidc.ts index 46de351d6..d33feaddc 100644 --- a/platform/packages/api/src/auth/oidc.ts +++ b/platform/packages/api/src/auth/oidc.ts @@ -149,10 +149,12 @@ export class PlatformOidcProvider { try { const params = oauth.validateAuthResponse(server, client, input.callback, input.state); const methods = server.token_endpoint_auth_methods_supported ?? ['client_secret_basic']; - const auth = methods.includes('client_secret_basic') - ? oauth.ClientSecretBasic(input.clientSecret) - : methods.includes('client_secret_post') - ? oauth.ClientSecretPost(input.clientSecret) + // Prefer the form body: Basic credentials are form-encoded first (RFC 6749 ยง2.3.1), which + // escapes `-`, `.` and `_`, and providers such as Google compare them without decoding. + const auth = methods.includes('client_secret_post') + ? oauth.ClientSecretPost(input.clientSecret) + : methods.includes('client_secret_basic') + ? oauth.ClientSecretBasic(input.clientSecret) : null; if (!auth) { throw new Error('The identity provider must support client-secret authentication.'); diff --git a/platform/packages/api/test/unit/platform-oidc.test.ts b/platform/packages/api/test/unit/platform-oidc.test.ts index ad7efee7d..a75274e26 100644 --- a/platform/packages/api/test/unit/platform-oidc.test.ts +++ b/platform/packages/api/test/unit/platform-oidc.test.ts @@ -218,6 +218,26 @@ describe('platform OIDC', () => { expect((await accepted.exchange()).subject).toBe('person-123'); }); + it('sends client credentials unescaped in the form body when the provider supports it', async () => { + const clientId = '761060634857-abc_def.apps.googleusercontent.com'; + const server = fixture({ + configuration: { + id: 'google', + kind: 'google', + issuer: 'https://accounts.google.com', + clientId, + }, + metadata: { + token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'], + }, + }); + await server.start(); + await server.exchange(); + const [, init] = server.request.mock.calls.find(([url]) => url.endsWith('/token'))!; + expect((init.body as URLSearchParams).get('client_id')).toBe(clientId); + expect(new Headers(init.headers).has('authorization')).toBe(false); + }); + it('names each login failure with a short code', async () => { await expect( fixture({ metadata: { issuer: 'https://other.example.com' } }).start(),