From a3b24ca77ca24ac19b17cf9ee2a5ca9612ccf96c Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 5 Jun 2025 14:15:42 +0200 Subject: [PATCH] Use Form encoded body instead of JSON for OAuth requests (#3919) * Parse JSON encoded Authorization Request Parameters * Use `application/x-www-form-urlencoded` content instead of JSON for OAuth requests Fixes: #3723 * Pre-process number too * improved type checking * Update packages/oauth/oauth-client/src/oauth-server-agent.ts Co-authored-by: devin ivy --------- Co-authored-by: devin ivy --- .changeset/seven-pans-press.md | 5 +++ .changeset/ten-tools-exercise.md | 5 +++ .../oauth-client/src/oauth-server-agent.ts | 45 ++++++++++++++++++- .../oauth-authorization-request-parameters.ts | 21 ++++++--- packages/oauth/oauth-types/src/util.ts | 20 +++++++++ 5 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 .changeset/seven-pans-press.md create mode 100644 .changeset/ten-tools-exercise.md diff --git a/.changeset/seven-pans-press.md b/.changeset/seven-pans-press.md new file mode 100644 index 000000000..6c06f5d9b --- /dev/null +++ b/.changeset/seven-pans-press.md @@ -0,0 +1,5 @@ +--- +"@atproto/oauth-types": patch +--- + +Parse JSON encoded Authorization Request Parameters diff --git a/.changeset/ten-tools-exercise.md b/.changeset/ten-tools-exercise.md new file mode 100644 index 000000000..2c9992710 --- /dev/null +++ b/.changeset/ten-tools-exercise.md @@ -0,0 +1,5 @@ +--- +"@atproto/oauth-client": patch +--- + +Use `application/x-www-form-urlencoded` content instead of JSON for OAuth requests diff --git a/packages/oauth/oauth-client/src/oauth-server-agent.ts b/packages/oauth/oauth-client/src/oauth-server-agent.ts index 990a058b9..32ee5f557 100644 --- a/packages/oauth/oauth-client/src/oauth-server-agent.ts +++ b/packages/oauth/oauth-client/src/oauth-server-agent.ts @@ -207,10 +207,17 @@ export class OAuthServerAgent { const auth = await this.buildClientAuth(endpoint) + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-3.2.2 + // https://datatracker.ietf.org/doc/html/rfc7009#section-2.1 + // https://datatracker.ietf.org/doc/html/rfc7662#section-2.1 + // https://datatracker.ietf.org/doc/html/rfc9126#section-2 const { response, json } = await this.dpopFetch(url, { method: 'POST', - headers: { ...auth.headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...payload, ...auth.payload }), + headers: { + ...auth.headers, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: wwwFormUrlEncode({ ...payload, ...auth.payload }), }).then(fetchJsonProcessor()) if (response.ok) { @@ -294,3 +301,37 @@ export class OAuthServerAgent { throw new Error(`Unsupported ${endpoint} authentication method`) } } + +function wwwFormUrlEncode(payload: Record) { + return new URLSearchParams( + Object.entries(payload) + .filter(entryHasDefinedValue) + .map(stringifyEntryValue), + ).toString() +} + +function entryHasDefinedValue( + entry: [string, unknown], +): entry is [string, null | NonNullable] { + return entry[1] !== undefined +} + +function stringifyEntryValue(entry: [string, unknown]): [string, string] { + const name = entry[0] + const value = entry[1] + + switch (typeof value) { + case 'string': + return [name, value] + case 'number': + case 'boolean': + return [name, String(value)] + default: { + const enc = JSON.stringify(value) + if (enc === undefined) { + throw new Error(`Unsupported value type for ${name}: ${String(value)}`) + } + return [name, enc] + } + } +} diff --git a/packages/oauth/oauth-types/src/oauth-authorization-request-parameters.ts b/packages/oauth/oauth-types/src/oauth-authorization-request-parameters.ts index f2b31fde7..c35e452c4 100644 --- a/packages/oauth/oauth-types/src/oauth-authorization-request-parameters.ts +++ b/packages/oauth/oauth-types/src/oauth-authorization-request-parameters.ts @@ -10,8 +10,12 @@ import { oauthScopeSchema } from './oauth-scope.js' import { oidcClaimsParameterSchema } from './oidc-claims-parameter.js' import { oidcClaimsPropertiesSchema } from './oidc-claims-properties.js' import { oidcEntityTypeSchema } from './oidc-entity-type.js' +import { jsonObjectPreprocess, numberPreprocess } from './util.js' /** + * @note non string parameters will be converted from their string + * representation since oauth request parameters are typically sent as URL + * encoded form data or URL encoded query string. * @see {@link https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest | OIDC} */ export const oauthAuthorizationRequestParametersSchema = z.object({ @@ -47,14 +51,17 @@ export const oauthAuthorizationRequestParametersSchema = z.object({ // PAPE [OpenID.PAPE] max_auth_age request parameter.) When max_age is used, // the ID Token returned MUST include an auth_time Claim Value. Note that // max_age=0 is equivalent to prompt=login. - max_age: z.number().int().min(0).optional(), + max_age: z.preprocess(numberPreprocess, z.number().int().min(0)).optional(), claims: z - .record( - oidcEntityTypeSchema, + .preprocess( + jsonObjectPreprocess, z.record( - oidcClaimsParameterSchema, - z.union([z.literal(null), oidcClaimsPropertiesSchema]), + oidcEntityTypeSchema, + z.record( + oidcClaimsParameterSchema, + z.union([z.literal(null), oidcClaimsPropertiesSchema]), + ), ), ) .optional(), @@ -85,7 +92,9 @@ export const oauthAuthorizationRequestParametersSchema = z.object({ prompt: z.enum(['none', 'login', 'consent', 'select_account']).optional(), // https://datatracker.ietf.org/doc/html/rfc9396 - authorization_details: oauthAuthorizationDetailsSchema.optional(), + authorization_details: z + .preprocess(jsonObjectPreprocess, oauthAuthorizationDetailsSchema) + .optional(), }) /** diff --git a/packages/oauth/oauth-types/src/util.ts b/packages/oauth/oauth-types/src/util.ts index 6b43d9f8d..f87841abc 100644 --- a/packages/oauth/oauth-types/src/util.ts +++ b/packages/oauth/oauth-types/src/util.ts @@ -66,3 +66,23 @@ export function extractUrlPath(url) { return url.substring(pathStart, pathEnd) } + +export const jsonObjectPreprocess = (val: unknown) => { + if (typeof val === 'string' && val.startsWith('{') && val.endsWith('}')) { + try { + return JSON.parse(val) + } catch { + return val + } + } + + return val +} + +export const numberPreprocess = (val: unknown): unknown => { + if (typeof val === 'string') { + const number = Number(val) + if (!Number.isNaN(number)) return number + } + return val +}