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 <devinivy@gmail.com>

---------

Co-authored-by: devin ivy <devinivy@gmail.com>
This commit is contained in:
Matthieu Sieben 2025-06-05 14:15:42 +02:00 committed by GitHub
parent 9214bd0170
commit a3b24ca77c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 88 additions and 8 deletions

View File

@ -0,0 +1,5 @@
---
"@atproto/oauth-types": patch
---
Parse JSON encoded Authorization Request Parameters

View File

@ -0,0 +1,5 @@
---
"@atproto/oauth-client": patch
---
Use `application/x-www-form-urlencoded` content instead of JSON for OAuth requests

View File

@ -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<string, undefined | unknown>) {
return new URLSearchParams(
Object.entries(payload)
.filter(entryHasDefinedValue)
.map(stringifyEntryValue),
).toString()
}
function entryHasDefinedValue(
entry: [string, unknown],
): entry is [string, null | NonNullable<unknown>] {
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]
}
}
}

View File

@ -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(),
})
/**

View File

@ -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
}