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:
parent
9214bd0170
commit
a3b24ca77c
5
.changeset/seven-pans-press.md
Normal file
5
.changeset/seven-pans-press.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@atproto/oauth-types": patch
|
||||
---
|
||||
|
||||
Parse JSON encoded Authorization Request Parameters
|
5
.changeset/ten-tools-exercise.md
Normal file
5
.changeset/ten-tools-exercise.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@atproto/oauth-client": patch
|
||||
---
|
||||
|
||||
Use `application/x-www-form-urlencoded` content instead of JSON for OAuth requests
|
@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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(),
|
||||
})
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user