Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/itchy-carrots-dig.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@cloudflare/workers-oauth-provider': patch
---

feat: add a function to validate client registration
fix: make request the first parameter
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,53 @@ This is a TypeScript library that implements the provider side of the OAuth 2.1
* The library is agnostic to how you build your UI. Your authorization flow can be implemented using whatever UI framework you use for everything else.
* The library's storage does not store any secrets, only hashes of them.

## Security Note

The library implements the OAuth 2.1 protocol with PKCE support. However, the following considerations should be taken into account:

### CSRF Prevention

To prevent Cross-Site Request Forgery (CSRF) attacks, it is crucial that your client application sends a unique and non-guessable `state` parameter in the authorization request. The library will include this `state` value when redirecting back to your application. Your application should then verify that the `state` value matches the one it originally sent.

The library does not and cannot handle CSRF prevention on its own, as this requires state management on the client-side. For more details, refer to the [OAuth 2.1 RFC on the state parameter](https://datatracker.ietf.org/doc/html/rfc6749#section-10.12).

### Dynamic Client Registration

The library supports dynamic client registration through the `clientRegistrationEndpoint` option. When this endpoint is enabled, anyone can register a client by default. It is your responsibility to restrict which clients can be registered.

To control which clients can register, you can provide a `validateClientRegistration` callback in the `OAuthProvider` options. This function receives the request and the client's metadata, and it should return `true` to allow registration or `false` to deny it.

Here is an example of how you can use this callback to restrict registration:

```ts
new OAuthProvider({
// ... other options ...
clientRegistrationEndpoint: "/oauth/register",
validateClientRegistration: (request, clientMetadata) => {
// Example: Only allow clients with a specific contact email domain
const allowedEmailDomain = "@example.com";
if (!clientMetadata.contacts?.some((email) => email.endsWith(allowedEmailDomain))) {
return false;
}

// Example: Check for an authorization token on the registration request
const authToken = request.headers.get("Authorization");
if (authToken !== "Bearer some-secret-token") {
return false;
}

// Example: Validate the client's redirect URIs against an allowlist
const allowedRedirectDomain = "myapp.com";
if (!clientMetadata.redirect_uris?.every((uri) => new URL(uri).hostname.endsWith(allowedRedirectDomain))) {
return false;
}

// If all checks pass, allow the registration
return true;
},
});
```

## Usage

A Worker that uses the library might look like this:
Expand Down
89 changes: 89 additions & 0 deletions __tests__/oauth-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,95 @@ describe('OAuthProvider', () => {
expect(savedClient).not.toBeNull();
expect(savedClient.clientSecret).toBeUndefined(); // No secret stored
});

describe('with validateClientRegistration callback', () => {
it('should allow registration when validator returns true', async () => {
const validateClientRegistration = vi.fn().mockReturnValue(true);
const providerWithValidator = new OAuthProvider({
apiRoute: ['/api/'],
apiHandler: TestApiHandler,
defaultHandler: testDefaultHandler,
authorizeEndpoint: '/authorize',
tokenEndpoint: '/oauth/token',
clientRegistrationEndpoint: '/oauth/register',
validateClientRegistration,
});

const clientData = {
redirect_uris: ['https://client.example.com/callback'],
client_name: 'Validated Client',
};
const request = createMockRequest(
'https://example.com/oauth/register',
'POST',
{ 'Content-Type': 'application/json' },
JSON.stringify(clientData)
);

const response = await providerWithValidator.fetch(request, mockEnv, mockCtx);
expect(response.status).toBe(201);
expect(validateClientRegistration).toHaveBeenCalledWith(expect.any(Request), clientData);
});

it('should deny registration when validator returns false', async () => {
const validateClientRegistration = vi.fn().mockReturnValue(false);
const providerWithValidator = new OAuthProvider({
apiRoute: ['/api/'],
apiHandler: TestApiHandler,
defaultHandler: testDefaultHandler,
authorizeEndpoint: '/authorize',
tokenEndpoint: '/oauth/token',
clientRegistrationEndpoint: '/oauth/register',
validateClientRegistration,
});

const clientData = {
redirect_uris: ['https://client.example.com/callback'],
client_name: 'Denied Client',
};
const request = createMockRequest(
'https://example.com/oauth/register',
'POST',
{ 'Content-Type': 'application/json' },
JSON.stringify(clientData)
);

const response = await providerWithValidator.fetch(request, mockEnv, mockCtx);
expect(response.status).toBe(403);
const error = await response.json<any>();
expect(error.error).toBe('access_denied');
expect(error.error_description).toBe('Client registration denied by policy');
expect(validateClientRegistration).toHaveBeenCalledWith(expect.any(Request), clientData);
});

it('should allow registration with an async validator that returns true', async () => {
const validateClientRegistration = vi.fn().mockResolvedValue(true);
const providerWithValidator = new OAuthProvider({
apiRoute: ['/api/'],
apiHandler: TestApiHandler,
defaultHandler: testDefaultHandler,
authorizeEndpoint: '/authorize',
tokenEndpoint: '/oauth/token',
clientRegistrationEndpoint: '/oauth/register',
validateClientRegistration,
});

const clientData = {
redirect_uris: ['https://client.example.com/callback'],
client_name: 'Async Validated Client',
};
const request = createMockRequest(
'https://example.com/oauth/register',
'POST',
{ 'Content-Type': 'application/json' },
JSON.stringify(clientData)
);

const response = await providerWithValidator.fetch(request, mockEnv, mockCtx);
expect(response.status).toBe(201);
expect(validateClientRegistration).toHaveBeenCalledWith(expect.any(Request), clientData);
});
});
});

describe('Authorization Code Flow', () => {
Expand Down
19 changes: 19 additions & 0 deletions src/oauth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,17 @@ export interface OAuthProviderOptions {
options: TokenExchangeCallbackOptions
) => Promise<TokenExchangeCallbackResult | void> | TokenExchangeCallbackResult | void;

/**
* Optional callback function to validate dynamic client registration requests.
* If provided, this function is called before a new client is registered.
* It should return `true` to allow registration or `false` to deny it.
* This allows for custom logic to restrict which clients can be registered.
* @param clientMetadata - The metadata of the client being registered
* @param request - The original HTTP request
* @returns Promise<boolean> indicating if the registration is allowed
*/
validateClientRegistration?: (request: Request, clientMetadata: Partial<ClientInfo>) => Promise<boolean> | boolean;

/**
* Optional callback function that is called when a provided token was not found in the internal KV.
* This allows authentication through external OAuth servers.
Expand Down Expand Up @@ -1917,6 +1928,14 @@ class OAuthProviderImpl {
return this.createErrorResponse('invalid_request', 'Invalid JSON payload', 400);
}

// Validate the client registration if a validator function is provided
if (this.options.validateClientRegistration) {
const isAllowed = await this.options.validateClientRegistration(request, clientMetadata);
if (!isAllowed) {
return this.createErrorResponse('access_denied', 'Client registration denied by policy', 403);
}
}

// Basic type validation functions
const validateStringField = (field: any): string | undefined => {
if (field === undefined) {
Expand Down
Loading