fix: allow client secret based auth factories to be used with DCR

This commit is contained in:
Filip Skokan
2025-04-03 20:14:45 +02:00
parent 5daab0c47e
commit 88bffd1c58
7 changed files with 529 additions and 146 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ Support from the community to continue maintaining and improving this module is
***
▸ **ClientSecretBasic**(`clientSecret`): [`ClientAuth`](../type-aliases/ClientAuth.md)
▸ **ClientSecretBasic**(`clientSecret`?): [`ClientAuth`](../type-aliases/ClientAuth.md)
**`client_secret_basic`** uses the HTTP `Basic` authentication scheme to send
`client_id` and `client_secret` in an `Authorization` HTTP Header.
@@ -15,7 +15,7 @@ Support from the community to continue maintaining and improving this module is
| Parameter | Type | Description |
| ------ | ------ | ------ |
| `clientSecret` | `string` | Client Secret |
| `clientSecret`? | `string` | Client Secret |
## Returns
+2 -2
View File
@@ -6,7 +6,7 @@ Support from the community to continue maintaining and improving this module is
***
▸ **ClientSecretJwt**(`clientSecret`, `options`?): [`ClientAuth`](../type-aliases/ClientAuth.md)
▸ **ClientSecretJwt**(`clientSecret`?, `options`?): [`ClientAuth`](../type-aliases/ClientAuth.md)
**`client_secret_jwt`** uses the HTTP request body to send `client_id`,
`client_assertion_type`, and `client_assertion` as
@@ -17,7 +17,7 @@ assertion's authenticity and integrity.
| Parameter | Type | Description |
| ------ | ------ | ------ |
| `clientSecret` | `string` | Client Secret |
| `clientSecret`? | `string` | Client Secret |
| `options`? | [`ModifyAssertionOptions`](../interfaces/ModifyAssertionOptions.md) | |
## Returns
+2 -2
View File
@@ -6,7 +6,7 @@ Support from the community to continue maintaining and improving this module is
***
▸ **ClientSecretPost**(`clientSecret`): [`ClientAuth`](../type-aliases/ClientAuth.md)
▸ **ClientSecretPost**(`clientSecret`?): [`ClientAuth`](../type-aliases/ClientAuth.md)
**`client_secret_post`** uses the HTTP request body to send `client_id` and
`client_secret` as `application/x-www-form-urlencoded` body parameters
@@ -15,7 +15,7 @@ Support from the community to continue maintaining and improving this module is
| Parameter | Type | Description |
| ------ | ------ | ------ |
| `clientSecret` | `string` | Client Secret |
| `clientSecret`? | `string` | Client Secret |
## Returns
+7 -1
View File
@@ -17,13 +17,19 @@ Note: This method also accepts a URL pointing directly to the Authorization
Server's discovery document. Doing so is NOT RECOMMENDED as it disables the
[ServerMetadata.issuer](../interfaces/ServerMetadata.md#issuer) validation.
Note: The method does not contain any logic to default the registered
"token_endpoint_auth_method" based on
[ServerMetadata.token\_endpoint\_auth\_methods\_supported](../interfaces/ServerMetadata.md#token_endpoint_auth_methods_supported), nor does it
default the "clientAuthentication" argument value beyond what its description
says.
## Parameters
| Parameter | Type | Description |
| ------ | ------ | ------ |
| `server` | [`URL`](https://developer.mozilla.org/docs/Web/API/URL) | URL representation of the Authorization Server's Issuer Identifier |
| `metadata` | [`Partial`](https://www.typescriptlang.org/docs/handbook/utility-types.html#partialtype)\<[`ClientMetadata`](../interfaces/ClientMetadata.md)\> | Client Metadata to register at the Authorization Server |
| `clientAuthentication`? | [`ClientAuth`](../type-aliases/ClientAuth.md) | Implementation of the Client's Authentication Method at the Authorization Server. Default is [ClientSecretPost](ClientSecretPost.md) using the [ClientMetadata.client\_secret](../interfaces/ClientMetadata.md#client_secret) that the Authorization Server issued. |
| `clientAuthentication`? | [`ClientAuth`](../type-aliases/ClientAuth.md) | Implementation of the Client's Authentication Method at the Authorization Server. Default is [ClientSecretPost](ClientSecretPost.md) using the [ClientMetadata.client\_secret](../interfaces/ClientMetadata.md#client_secret) that the Authorization Server issued, [None](None.md) otherwise. |
| `options`? | [`DynamicClientRegistrationRequestOptions`](../interfaces/DynamicClientRegistrationRequestOptions.md) | |
## Returns
+70 -6
View File
@@ -106,6 +106,8 @@ export type ClientAuth = (
headers: Headers,
) => void
let tbi!: WeakMap<Internal['c'], ClientAuth>
/**
* **`client_secret_post`** uses the HTTP request body to send `client_id` and
* `client_secret` as `application/x-www-form-urlencoded` body parameters
@@ -154,10 +156,34 @@ export type ClientAuth = (
* @see [RFC 6749 - The OAuth 2.0 Authorization Framework](https://www.rfc-editor.org/rfc/rfc6749.html#section-2.3)
* @see [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication)
*/
export function ClientSecretPost(clientSecret: string): ClientAuth {
export function ClientSecretPost(clientSecret?: string): ClientAuth {
if (clientSecret !== undefined) {
return oauth.ClientSecretPost(clientSecret)
}
tbi ||= new WeakMap()
return (as, client, body, headers) => {
let auth: ClientAuth | undefined
if (!(auth = tbi.get(client))) {
assertString(client.client_secret, '"metadata.client_secret"')
auth = oauth.ClientSecretPost(client.client_secret)
tbi.set(client, auth)
}
return auth(as, client, body, headers)
}
}
function assertString(input: unknown, it: string): asserts input is string {
if (typeof input !== 'string') {
throw CodedTypeError(`${it} must be a string`, ERR_INVALID_ARG_TYPE)
}
if (input.length === 0) {
throw CodedTypeError(`${it} must not be empty`, ERR_INVALID_ARG_VALUE)
}
}
/**
* **`client_secret_basic`** uses the HTTP `Basic` authentication scheme to send
* `client_id` and `client_secret` in an `Authorization` HTTP Header.
@@ -206,10 +232,24 @@ export function ClientSecretPost(clientSecret: string): ClientAuth {
* @see [RFC 6749 - The OAuth 2.0 Authorization Framework](https://www.rfc-editor.org/rfc/rfc6749.html#section-2.3)
* @see [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication)
*/
export function ClientSecretBasic(clientSecret: string): ClientAuth {
export function ClientSecretBasic(clientSecret?: string): ClientAuth {
if (clientSecret !== undefined) {
return oauth.ClientSecretBasic(clientSecret)
}
tbi ||= new WeakMap()
return (as, client, body, headers) => {
let auth: ClientAuth | undefined
if (!(auth = tbi.get(client))) {
assertString(client.client_secret, '"metadata.client_secret"')
auth = oauth.ClientSecretBasic(client.client_secret)
tbi.set(client, auth)
}
return auth(as, client, body, headers)
}
}
/**
* **`client_secret_jwt`** uses the HTTP request body to send `client_id`,
* `client_assertion_type`, and `client_assertion` as
@@ -261,12 +301,26 @@ export function ClientSecretBasic(clientSecret: string): ClientAuth {
* @see [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication)
*/
export function ClientSecretJwt(
clientSecret: string,
clientSecret?: string,
options?: oauth.ModifyAssertionOptions,
): ClientAuth {
if (clientSecret !== undefined) {
return oauth.ClientSecretJwt(clientSecret, options)
}
tbi ||= new WeakMap()
return (as, client, body, headers) => {
let auth: ClientAuth | undefined
if (!(auth = tbi.get(client))) {
assertString(client.client_secret, '"metadata.client_secret"')
auth = oauth.ClientSecretJwt(client.client_secret, options)
tbi.set(client, auth)
}
return auth(as, client, body, headers)
}
}
/**
* **`none`** (public client) uses the HTTP request body to send only
* `client_id` as `application/x-www-form-urlencoded` body parameter.
@@ -1080,13 +1134,19 @@ export interface DynamicClientRegistrationRequestOptions
* Server's discovery document. Doing so is NOT RECOMMENDED as it disables the
* {@link ServerMetadata.issuer} validation.
*
* Note: The method does not contain any logic to default the registered
* "token_endpoint_auth_method" based on
* {@link ServerMetadata.token_endpoint_auth_methods_supported}, nor does it
* default the "clientAuthentication" argument value beyond what its description
* says.
*
* @param server URL representation of the Authorization Server's Issuer
* Identifier
* @param metadata Client Metadata to register at the Authorization Server
* @param clientAuthentication Implementation of the Client's Authentication
* Method at the Authorization Server. Default is {@link ClientSecretPost}
* using the {@link ClientMetadata.client_secret} that the Authorization Server
* issued.
* issued, {@link None} otherwise.
* @param options
*
* @group Dynamic Client Registration
@@ -1104,6 +1164,10 @@ export async function dynamicClientRegistration(
as = await performDiscovery(server, options)
}
const clockSkew = metadata[oauth.clockSkew] ?? 0
const clockTolerance = metadata[oauth.clockTolerance] ?? 30
metadata = structuredClone(metadata)
const timeout = options?.timeout ?? 30
const signal = AbortSignal.timeout(timeout * 1000)
@@ -1133,8 +1197,8 @@ export async function dynamicClientRegistration(
errorHandler(err)
}
registered[oauth.clockSkew] = metadata[oauth.clockSkew] ?? 0
registered[oauth.clockTolerance] = metadata[oauth.clockTolerance] ?? 30
registered[oauth.clockSkew] = clockSkew
registered[oauth.clockTolerance] = clockTolerance
const instance = new Configuration(
as,
-130
View File
@@ -1,130 +0,0 @@
import test from 'ava'
import * as client from '../src/index.js'
import * as oauth from 'oauth4webapi'
import * as undici from 'undici'
import * as jose from 'jose'
test('dpop may be used with DCR w/ initial access token', async (t) => {
let agent = new undici.MockAgent()
agent.disableNetConnect()
const issuer = new URL('https://op.example.com')
const kp = await client.randomDPoPKeyPair('ES256')
const mockAgent = agent.get(issuer.origin)
mockAgent
.intercept({
method: 'GET',
path: '/.well-known/openid-configuration',
})
.reply(200, {
issuer: issuer.href,
registration_endpoint: `${issuer.origin}/reg`,
})
mockAgent
.intercept({
method: 'POST',
path: '/reg',
headers({ dpop, authorization }) {
return typeof dpop === 'string' && authorization === 'DPoP token'
},
})
.reply(
400,
{},
{
headers: {
'dpop-nonce': 'use this',
'www-authenticate': 'dpop error="use_dpop_nonce"',
},
},
)
mockAgent
.intercept({
method: 'POST',
path: '/reg',
headers({ dpop, authorization }) {
const { nonce } = jose.decodeJwt(dpop)
return nonce === 'use this' && authorization === 'DPoP token'
},
})
.reply(201, {
client_id: 'foo',
})
await client.dynamicClientRegistration(issuer, {}, undefined, {
initialAccessToken: 'token',
DPoP: oauth.DPoP({}, kp),
// @ts-ignore
[client.customFetch](url, options) {
return undici.fetch(url, { ...options, dispatcher: agent })
},
})
t.notThrows(() => agent.assertNoPendingInterceptors())
})
test('dpop may be used with DCR w/o initial access token', async (t) => {
let agent = new undici.MockAgent()
agent.disableNetConnect()
const issuer = new URL('https://op.example.com')
const kp = await client.randomDPoPKeyPair('ES256')
const mockAgent = agent.get(issuer.origin)
mockAgent
.intercept({
method: 'GET',
path: '/.well-known/openid-configuration',
})
.reply(200, {
issuer: issuer.href,
registration_endpoint: `${issuer.origin}/reg`,
})
mockAgent
.intercept({
method: 'POST',
path: '/reg',
headers({ dpop, authorization }) {
return typeof dpop === 'string' && authorization === undefined
},
})
.reply(
400,
{},
{
headers: {
'dpop-nonce': 'use this',
'www-authenticate': 'dpop error="use_dpop_nonce"',
},
},
)
mockAgent
.intercept({
method: 'POST',
path: '/reg',
headers({ dpop, authorization }) {
const { nonce } = jose.decodeJwt(dpop)
return nonce === 'use this' && authorization === undefined
},
})
.reply(201, {
client_id: 'foo',
})
await client.dynamicClientRegistration(issuer, {}, undefined, {
DPoP: oauth.DPoP({}, kp),
// @ts-ignore
[client.customFetch](url, options) {
return undici.fetch(url, { ...options, dispatcher: agent })
},
})
t.notThrows(() => agent.assertNoPendingInterceptors())
})
+443
View File
@@ -0,0 +1,443 @@
import test from 'ava'
import * as client from '../src/index.js'
import * as oauth from 'oauth4webapi'
import * as undici from 'undici'
import * as jose from 'jose'
test('dpop may be used with DCR w/ initial access token', async (t) => {
let agent = new undici.MockAgent()
agent.disableNetConnect()
const issuer = new URL('https://op.example.com')
const kp = await client.randomDPoPKeyPair('ES256')
const mockAgent = agent.get(issuer.origin)
mockAgent
.intercept({
method: 'GET',
path: '/.well-known/openid-configuration',
})
.reply(200, {
issuer: issuer.href,
registration_endpoint: `${issuer.origin}/reg`,
})
mockAgent
.intercept({
method: 'POST',
path: '/reg',
headers({ dpop, authorization }) {
return typeof dpop === 'string' && authorization === 'DPoP token'
},
})
.reply(
400,
{},
{
headers: {
'dpop-nonce': 'use this',
'www-authenticate': 'dpop error="use_dpop_nonce"',
},
},
)
mockAgent
.intercept({
method: 'POST',
path: '/reg',
headers({ dpop, authorization }) {
const { nonce } = jose.decodeJwt(dpop)
return nonce === 'use this' && authorization === 'DPoP token'
},
})
.reply(201, {
client_id: 'foo',
})
await client.dynamicClientRegistration(issuer, {}, undefined, {
initialAccessToken: 'token',
DPoP: oauth.DPoP({}, kp),
// @ts-ignore
[client.customFetch](url, options) {
return undici.fetch(url, { ...options, dispatcher: agent })
},
})
t.notThrows(() => agent.assertNoPendingInterceptors())
})
test('dpop may be used with DCR w/o initial access token', async (t) => {
let agent = new undici.MockAgent()
agent.disableNetConnect()
const issuer = new URL('https://op.example.com')
const kp = await client.randomDPoPKeyPair('ES256')
const mockAgent = agent.get(issuer.origin)
mockAgent
.intercept({
method: 'GET',
path: '/.well-known/openid-configuration',
})
.reply(200, {
issuer: issuer.href,
registration_endpoint: `${issuer.origin}/reg`,
token_endpoint: `${issuer.origin}/token`,
})
mockAgent
.intercept({
method: 'POST',
path: '/reg',
headers({ dpop, authorization }) {
return typeof dpop === 'string' && authorization === undefined
},
})
.reply(
400,
{},
{
headers: {
'dpop-nonce': 'use this',
'www-authenticate': 'dpop error="use_dpop_nonce"',
},
},
)
mockAgent
.intercept({
method: 'POST',
path: '/reg',
headers({ dpop, authorization }) {
const { nonce } = jose.decodeJwt(dpop)
return nonce === 'use this' && authorization === undefined
},
})
.reply(201, {
client_id: 'foo',
client_secret: 'bar',
client_secret_expires_at: 0,
})
await client.dynamicClientRegistration(issuer, {}, undefined, {
DPoP: oauth.DPoP({}, kp),
// @ts-ignore
[client.customFetch](url, options) {
return undici.fetch(url, { ...options, dispatcher: agent })
},
})
t.notThrows(() => agent.assertNoPendingInterceptors())
})
test('DCR uses client_secret_post by default when client_secret was returned', async (t) => {
let agent = new undici.MockAgent()
agent.disableNetConnect()
const issuer = new URL('https://op.example.com')
const mockAgent = agent.get(issuer.origin)
mockAgent
.intercept({
method: 'GET',
path: '/.well-known/openid-configuration',
})
.reply(200, {
issuer: issuer.href,
registration_endpoint: `${issuer.origin}/reg`,
token_endpoint: `${issuer.origin}/token`,
})
mockAgent
.intercept({
method: 'POST',
path: '/reg',
})
.reply(201, {
client_id: 'foo',
client_secret: 'bar',
client_secret_expires_at: 0,
})
const config = await client.dynamicClientRegistration(issuer, {}, undefined, {
// @ts-ignore
[client.customFetch](url, options) {
return undici.fetch(url, { ...options, dispatcher: agent })
},
})
mockAgent
.intercept({
method: 'POST',
path: '/token',
body(body) {
const params = new URLSearchParams(body)
t.true(params.has('client_id'))
t.true(params.has('client_secret'))
return true
},
})
.reply(200, {
access_token: 'token',
token_type: 'bearer',
})
.times(2)
await client.clientCredentialsGrant(config)
await client.clientCredentialsGrant(config)
t.notThrows(() => agent.assertNoPendingInterceptors())
})
test('DCR uses none by default when client_secret was not returned', async (t) => {
let agent = new undici.MockAgent()
agent.disableNetConnect()
const issuer = new URL('https://op.example.com')
const mockAgent = agent.get(issuer.origin)
mockAgent
.intercept({
method: 'GET',
path: '/.well-known/openid-configuration',
})
.reply(200, {
issuer: issuer.href,
registration_endpoint: `${issuer.origin}/reg`,
token_endpoint: `${issuer.origin}/token`,
})
mockAgent
.intercept({
method: 'POST',
path: '/reg',
})
.reply(201, {
client_id: 'foo',
})
const config = await client.dynamicClientRegistration(issuer, {}, undefined, {
// @ts-ignore
[client.customFetch](url, options) {
return undici.fetch(url, { ...options, dispatcher: agent })
},
})
mockAgent
.intercept({
method: 'POST',
path: '/token',
body(body) {
const params = new URLSearchParams(body)
t.true(params.has('client_id'))
return true
},
})
.reply(200, {
access_token: 'token',
token_type: 'bearer',
})
.times(2)
await client.clientCredentialsGrant(config)
await client.clientCredentialsGrant(config)
t.notThrows(() => agent.assertNoPendingInterceptors())
})
test('DCR can use ClientSecretBasic without having a secret at hand prior to the registration', async (t) => {
let agent = new undici.MockAgent()
agent.disableNetConnect()
const issuer = new URL('https://op.example.com')
const mockAgent = agent.get(issuer.origin)
mockAgent
.intercept({
method: 'GET',
path: '/.well-known/openid-configuration',
})
.reply(200, {
issuer: issuer.href,
registration_endpoint: `${issuer.origin}/reg`,
token_endpoint: `${issuer.origin}/token`,
})
mockAgent
.intercept({
method: 'POST',
path: '/reg',
})
.reply(201, {
client_id: 'foo',
client_secret: 'bar',
client_secret_expires_at: 0,
})
const config = await client.dynamicClientRegistration(
issuer,
{},
client.ClientSecretBasic(),
{
// @ts-ignore
[client.customFetch](url, options) {
return undici.fetch(url, { ...options, dispatcher: agent })
},
},
)
mockAgent
.intercept({
method: 'POST',
path: '/token',
headers(headers) {
return headers.authorization === 'Basic Zm9vOmJhcg=='
},
})
.reply(200, {
access_token: 'token',
token_type: 'bearer',
})
.times(2)
await client.clientCredentialsGrant(config)
await client.clientCredentialsGrant(config)
t.notThrows(() => agent.assertNoPendingInterceptors())
})
test('DCR can use ClientSecretPost without having a secret at hand prior to the registration', async (t) => {
let agent = new undici.MockAgent()
agent.disableNetConnect()
const issuer = new URL('https://op.example.com')
const mockAgent = agent.get(issuer.origin)
mockAgent
.intercept({
method: 'GET',
path: '/.well-known/openid-configuration',
})
.reply(200, {
issuer: issuer.href,
registration_endpoint: `${issuer.origin}/reg`,
token_endpoint: `${issuer.origin}/token`,
})
mockAgent
.intercept({
method: 'POST',
path: '/reg',
})
.reply(201, {
client_id: 'foo',
client_secret: 'bar',
client_secret_expires_at: 0,
})
const config = await client.dynamicClientRegistration(
issuer,
{},
client.ClientSecretPost(),
{
// @ts-ignore
[client.customFetch](url, options) {
return undici.fetch(url, { ...options, dispatcher: agent })
},
},
)
mockAgent
.intercept({
method: 'POST',
path: '/token',
body(body) {
const params = new URLSearchParams(body)
t.true(params.has('client_id'))
t.true(params.has('client_secret'))
return true
},
})
.reply(200, {
access_token: 'token',
token_type: 'bearer',
})
.times(2)
await client.clientCredentialsGrant(config)
await client.clientCredentialsGrant(config)
t.notThrows(() => agent.assertNoPendingInterceptors())
})
test('DCR can use ClientSecretJwt without having a secret at hand prior to the registration', async (t) => {
let agent = new undici.MockAgent()
agent.disableNetConnect()
const issuer = new URL('https://op.example.com')
const mockAgent = agent.get(issuer.origin)
mockAgent
.intercept({
method: 'GET',
path: '/.well-known/openid-configuration',
})
.reply(200, {
issuer: issuer.href,
registration_endpoint: `${issuer.origin}/reg`,
token_endpoint: `${issuer.origin}/token`,
})
mockAgent
.intercept({
method: 'POST',
path: '/reg',
})
.reply(201, {
client_id: 'foo',
client_secret: 'bar',
client_secret_expires_at: 0,
})
const config = await client.dynamicClientRegistration(
issuer,
{},
client.ClientSecretJwt(),
{
// @ts-ignore
[client.customFetch](url, options) {
return undici.fetch(url, { ...options, dispatcher: agent })
},
},
)
mockAgent
.intercept({
method: 'POST',
path: '/token',
body(body) {
const params = new URLSearchParams(body)
t.true(params.has('client_id'))
t.true(params.has('client_assertion_type'))
t.true(params.has('client_assertion'))
return true
},
})
.reply(200, {
access_token: 'token',
token_type: 'bearer',
})
.times(2)
await client.clientCredentialsGrant(config)
await client.clientCredentialsGrant(config)
t.notThrows(() => agent.assertNoPendingInterceptors())
})