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)
|
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, {
|
const { response, json } = await this.dpopFetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...auth.headers, 'Content-Type': 'application/json' },
|
headers: {
|
||||||
body: JSON.stringify({ ...payload, ...auth.payload }),
|
...auth.headers,
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: wwwFormUrlEncode({ ...payload, ...auth.payload }),
|
||||||
}).then(fetchJsonProcessor())
|
}).then(fetchJsonProcessor())
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@ -294,3 +301,37 @@ export class OAuthServerAgent {
|
|||||||
throw new Error(`Unsupported ${endpoint} authentication method`)
|
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 { oidcClaimsParameterSchema } from './oidc-claims-parameter.js'
|
||||||
import { oidcClaimsPropertiesSchema } from './oidc-claims-properties.js'
|
import { oidcClaimsPropertiesSchema } from './oidc-claims-properties.js'
|
||||||
import { oidcEntityTypeSchema } from './oidc-entity-type.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}
|
* @see {@link https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest | OIDC}
|
||||||
*/
|
*/
|
||||||
export const oauthAuthorizationRequestParametersSchema = z.object({
|
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,
|
// 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
|
// the ID Token returned MUST include an auth_time Claim Value. Note that
|
||||||
// max_age=0 is equivalent to prompt=login.
|
// 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
|
claims: z
|
||||||
.record(
|
.preprocess(
|
||||||
oidcEntityTypeSchema,
|
jsonObjectPreprocess,
|
||||||
z.record(
|
z.record(
|
||||||
oidcClaimsParameterSchema,
|
oidcEntityTypeSchema,
|
||||||
z.union([z.literal(null), oidcClaimsPropertiesSchema]),
|
z.record(
|
||||||
|
oidcClaimsParameterSchema,
|
||||||
|
z.union([z.literal(null), oidcClaimsPropertiesSchema]),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
@ -85,7 +92,9 @@ export const oauthAuthorizationRequestParametersSchema = z.object({
|
|||||||
prompt: z.enum(['none', 'login', 'consent', 'select_account']).optional(),
|
prompt: z.enum(['none', 'login', 'consent', 'select_account']).optional(),
|
||||||
|
|
||||||
// https://datatracker.ietf.org/doc/html/rfc9396
|
// 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)
|
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