Skip to content

Commit a103ed0

Browse files
committed
Ask Claude to add support for "public" clients.
prompt: What would we need to do differently to support pure-client apps, that have no server? Claude explained. prompt: Let's implement this, but with a pair of new boolean configuration options to control it. The option `allowImplicitFlow` controls whether the implicit flow is allowed. Another option, `disallowPublicClientRegistration`, controls whether the client registration endpoint will accept new public clients (but the `createClient()` helper function will still allow them regardless). Both default false, that is, the implicit flow is not allowed by default, and public client registrations are allowed by default. Claude wanted to define `clientSecret` as being an empty string for public clients. prompt: Let's make `clientSecret` an optional field, so it is undefined for public clients, rather than an empty string. Claude modified the client registration handler to expect a `client_type` field in the input, but this isn't what the RFC says. prompt: According to RFC 7591, there is no `client_type` field expected in the metadata. Instead, `token_endpoint_auth_method` determines this; if it is "none", then the client is a public client. Claude added `tokenEndpointAuthMethod` to `ClientInfo` and completed the implementation. prompt: On review, I think it's redundant for `ClientInfo` to contain both `clientType` and `tokenEndpointAuthMethod`; the former can be determined from the latter. Let's remove `clientType`. Claude did as asked. prompt: We have `tokenEndpointAuthMethod` defined as an optional field, but I think it is always set. Should we mark it non-optional? Claude wrote some code with a comment saying it was trying to handle existing clients that don't have `tokenEndpointAuthMethod`. prompt: Don't worry about backward compatibility, this code is not in production yet.
1 parent 36ed9d8 commit a103ed0

File tree

2 files changed

+174
-49
lines changed

2 files changed

+174
-49
lines changed

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,20 @@ export default new OAuthProvider({
6565
// Optional list of scopes supported by this OAuth provider.
6666
// If provided, this will be included in the RFC 8414 metadata as 'scopes_supported'.
6767
// If not provided, the 'scopes_supported' field will be omitted from the metadata.
68-
scopesSupported: ["document.read", "document.write", "profile"]
68+
scopesSupported: ["document.read", "document.write", "profile"],
69+
70+
// Optional: Controls whether the OAuth implicit flow is allowed.
71+
// The implicit flow is discouraged in OAuth 2.1 but may be needed for some clients.
72+
// Defaults to false.
73+
allowImplicitFlow: false,
74+
75+
// Optional: Controls whether public clients (clients without a secret, like SPAs)
76+
// can register via the dynamic client registration endpoint.
77+
// When true, only confidential clients can register.
78+
// Note: Creating public clients via the OAuthHelpers.createClient() method
79+
// is always allowed regardless of this setting.
80+
// Defaults to false.
81+
disallowPublicClientRegistration: false
6982
});
7083

7184
// The default handler object - the OAuthProvider will pass through HTTP requests to this object's fetch method

oauth-provider.ts

Lines changed: 160 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,21 @@ export interface OAuthProviderOptions {
6464
* If not provided, the 'scopes_supported' field will be omitted from the OAuth metadata.
6565
*/
6666
scopesSupported?: string[];
67+
68+
/**
69+
* Controls whether the OAuth implicit flow is allowed.
70+
* This flow is discouraged in OAuth 2.1 due to security concerns.
71+
* Defaults to false.
72+
*/
73+
allowImplicitFlow?: boolean;
74+
75+
/**
76+
* Controls whether public clients (clients without a secret, like SPAs) can register via the
77+
* dynamic client registration endpoint. When true, only confidential clients can register.
78+
* Note: Creating public clients via the OAuthHelpers.createClient() method is always allowed.
79+
* Defaults to false.
80+
*/
81+
disallowPublicClientRegistration?: boolean;
6782
}
6883

6984
// Using ExportedHandler from Cloudflare Workers Types for both API and default handlers
@@ -193,8 +208,9 @@ export interface ClientInfo {
193208

194209
/**
195210
* Secret used to authenticate the client (stored as a hash)
211+
* Only present for confidential clients; undefined for public clients.
196212
*/
197-
clientSecret: string;
213+
clientSecret?: string;
198214

199215
/**
200216
* List of allowed redirect URIs for the client
@@ -250,6 +266,17 @@ export interface ClientInfo {
250266
* Unix timestamp when the client was registered
251267
*/
252268
registrationDate?: number;
269+
270+
/**
271+
* The authentication method used by the client at the token endpoint.
272+
* Values include:
273+
* - 'client_secret_basic': Uses HTTP Basic Auth with client ID and secret (default for confidential clients)
274+
* - 'client_secret_post': Uses POST parameters for client authentication
275+
* - 'none': Used for public clients that can't securely store secrets (SPAs, mobile apps, etc.)
276+
*
277+
* Public clients use 'none', while confidential clients use either 'client_secret_basic' or 'client_secret_post'.
278+
*/
279+
tokenEndpointAuthMethod: string;
253280
}
254281

255282
/**
@@ -772,17 +799,26 @@ class OAuthProviderImpl {
772799
registrationEndpoint = this.getFullEndpointUrl(this.options.clientRegistrationEndpoint, requestUrl);
773800
}
774801

802+
// Determine supported response types
803+
const responseTypesSupported = ["code"];
804+
805+
// Add token response type if implicit flow is allowed
806+
if (this.options.allowImplicitFlow) {
807+
responseTypesSupported.push("token");
808+
}
809+
775810
const metadata = {
776811
issuer: new URL(tokenEndpoint).origin,
777812
authorization_endpoint: authorizeEndpoint,
778813
token_endpoint: tokenEndpoint,
779814
// not implemented: jwks_uri
780815
registration_endpoint: registrationEndpoint,
781816
scopes_supported: this.options.scopesSupported,
782-
response_types_supported: ["code"],
817+
response_types_supported: responseTypesSupported,
783818
response_modes_supported: ["query"],
784819
grant_types_supported: ["authorization_code", "refresh_token"],
785-
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
820+
// Support "none" auth method for public clients
821+
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
786822
// not implemented: token_endpoint_auth_signing_alg_values_supported
787823
// not implemented: service_documentation
788824
// not implemented: ui_locales_supported
@@ -838,7 +874,7 @@ class OAuthProviderImpl {
838874
body[key] = value;
839875
}
840876

841-
// Authenticate client
877+
// Get client ID from request
842878
const authHeader = request.headers.get('Authorization');
843879
let clientId = '';
844880
let clientSecret = '';
@@ -848,22 +884,22 @@ class OAuthProviderImpl {
848884
const credentials = atob(authHeader.substring(6));
849885
const [id, secret] = credentials.split(':');
850886
clientId = id;
851-
clientSecret = secret;
887+
clientSecret = secret || '';
852888
} else {
853889
// Form parameters
854890
clientId = body.client_id;
855-
clientSecret = body.client_secret;
891+
clientSecret = body.client_secret || '';
856892
}
857893

858-
if (!clientId || !clientSecret) {
894+
if (!clientId) {
859895
return createErrorResponse(
860896
'invalid_client',
861-
'Client authentication failed',
897+
'Client ID is required',
862898
401
863899
);
864900
}
865901

866-
// Verify client
902+
// Verify client exists
867903
const clientInfo = await this.getClient(env, clientId);
868904
if (!clientInfo) {
869905
return createErrorResponse(
@@ -873,15 +909,38 @@ class OAuthProviderImpl {
873909
);
874910
}
875911

876-
// Hash the provided secret and compare with stored hash
877-
const providedSecretHash = await hashSecret(clientSecret);
878-
if (providedSecretHash !== clientInfo.clientSecret) {
879-
return createErrorResponse(
880-
'invalid_client',
881-
'Client authentication failed',
882-
401
883-
);
912+
// Determine authentication requirements based on token endpoint auth method
913+
const isPublicClient = clientInfo.tokenEndpointAuthMethod === 'none';
914+
915+
// For confidential clients, validate the secret
916+
if (!isPublicClient) {
917+
if (!clientSecret) {
918+
return createErrorResponse(
919+
'invalid_client',
920+
'Client authentication failed: missing client_secret',
921+
401
922+
);
923+
}
924+
925+
// Verify the client secret matches
926+
if (!clientInfo.clientSecret) {
927+
return createErrorResponse(
928+
'invalid_client',
929+
'Client authentication failed: client has no registered secret',
930+
401
931+
);
932+
}
933+
934+
const providedSecretHash = await hashSecret(clientSecret);
935+
if (providedSecretHash !== clientInfo.clientSecret) {
936+
return createErrorResponse(
937+
'invalid_client',
938+
'Client authentication failed: invalid client_secret',
939+
401
940+
);
941+
}
884942
}
943+
// For public clients, no secret is required
885944

886945
// Handle different grant types
887946
const grantType = body.grant_type;
@@ -1323,12 +1382,29 @@ class OAuthProviderImpl {
13231382
return arr;
13241383
};
13251384

1326-
// Create client
1385+
// Get token endpoint auth method, default to client_secret_basic
1386+
const authMethod = validateStringField(clientMetadata.token_endpoint_auth_method) || 'client_secret_basic';
1387+
const isPublicClient = authMethod === 'none';
1388+
1389+
// Check if public client registrations are disallowed
1390+
if (isPublicClient && this.options.disallowPublicClientRegistration) {
1391+
return createErrorResponse(
1392+
'invalid_client_metadata',
1393+
'Public client registration is not allowed'
1394+
);
1395+
}
1396+
1397+
// Create client ID
13271398
const clientId = generateRandomString(16);
1328-
const clientSecret = generateRandomString(32);
13291399

1330-
// Hash the client secret before storing
1331-
const hashedSecret = await hashSecret(clientSecret);
1400+
// Only create client secret for confidential clients
1401+
let clientSecret: string | undefined;
1402+
let hashedSecret: string | undefined;
1403+
1404+
if (!isPublicClient) {
1405+
clientSecret = generateRandomString(32);
1406+
hashedSecret = await hashSecret(clientSecret);
1407+
}
13321408

13331409
let clientInfo: ClientInfo;
13341410
try {
@@ -1340,7 +1416,6 @@ class OAuthProviderImpl {
13401416

13411417
clientInfo = {
13421418
clientId,
1343-
clientSecret: hashedSecret,
13441419
redirectUris,
13451420
clientName: validateStringField(clientMetadata.client_name),
13461421
logoUri: validateStringField(clientMetadata.logo_uri),
@@ -1351,8 +1426,14 @@ class OAuthProviderImpl {
13511426
contacts: validateStringArray(clientMetadata.contacts),
13521427
grantTypes: validateStringArray(clientMetadata.grant_types) || ['authorization_code', 'refresh_token'],
13531428
responseTypes: validateStringArray(clientMetadata.response_types) || ['code'],
1354-
registrationDate: Math.floor(Date.now() / 1000)
1429+
registrationDate: Math.floor(Date.now() / 1000),
1430+
tokenEndpointAuthMethod: authMethod
13551431
};
1432+
1433+
// Add client secret only for confidential clients
1434+
if (!isPublicClient && hashedSecret) {
1435+
clientInfo.clientSecret = hashedSecret;
1436+
}
13561437
} catch (error) {
13571438
return createErrorResponse(
13581439
'invalid_client_metadata',
@@ -1364,9 +1445,8 @@ class OAuthProviderImpl {
13641445
await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(clientInfo));
13651446

13661447
// Return client information with the original unhashed secret
1367-
const response = {
1448+
const response: Record<string, any> = {
13681449
client_id: clientInfo.clientId,
1369-
client_secret: clientSecret, // Return the original unhashed secret
13701450
redirect_uris: clientInfo.redirectUris,
13711451
client_name: clientInfo.clientName,
13721452
logo_uri: clientInfo.logoUri,
@@ -1377,10 +1457,16 @@ class OAuthProviderImpl {
13771457
contacts: clientInfo.contacts,
13781458
grant_types: clientInfo.grantTypes,
13791459
response_types: clientInfo.responseTypes,
1460+
token_endpoint_auth_method: clientInfo.tokenEndpointAuthMethod,
13801461
registration_client_uri: `${this.options.clientRegistrationEndpoint}/${clientId}`,
13811462
client_id_issued_at: clientInfo.registrationDate,
13821463
};
13831464

1465+
// Only include client_secret for confidential clients
1466+
if (clientInfo.clientType === 'confidential' && clientSecret) {
1467+
response.client_secret = clientSecret; // Return the original unhashed secret
1468+
}
1469+
13841470
return new Response(JSON.stringify(response), {
13851471
status: 201,
13861472
headers: { 'Content-Type': 'application/json' }
@@ -1819,9 +1905,9 @@ class OAuthHelpersImpl implements OAuthHelpers {
18191905
const codeChallenge = url.searchParams.get('code_challenge') || undefined;
18201906
const codeChallengeMethod = url.searchParams.get('code_challenge_method') || 'plain';
18211907

1822-
// OAuth 2.1 does not support the implicit flow ('token' response type)
1823-
if (responseType === 'token') {
1824-
throw new Error('The implicit grant flow is not supported in OAuth 2.1');
1908+
// Check if implicit flow is requested but not allowed
1909+
if (responseType === 'token' && !this.provider.options.allowImplicitFlow) {
1910+
throw new Error('The implicit grant flow is not enabled for this provider');
18251911
}
18261912

18271913
return {
@@ -1906,14 +1992,14 @@ class OAuthHelpersImpl implements OAuthHelpers {
19061992
*/
19071993
async createClient(clientInfo: Partial<ClientInfo>): Promise<ClientInfo> {
19081994
const clientId = generateRandomString(16);
1909-
const clientSecret = generateRandomString(32);
19101995

1911-
// Hash the client secret
1912-
const hashedSecret = await hashSecret(clientSecret);
1996+
// Determine token endpoint auth method
1997+
const tokenEndpointAuthMethod = clientInfo.tokenEndpointAuthMethod || 'client_secret_basic';
1998+
const isPublicClient = tokenEndpointAuthMethod === 'none';
19131999

2000+
// Create a new client object
19142001
const newClient: ClientInfo = {
19152002
clientId,
1916-
clientSecret: hashedSecret, // Store hashed secret
19172003
redirectUris: clientInfo.redirectUris || [],
19182004
clientName: clientInfo.clientName,
19192005
logoUri: clientInfo.logoUri,
@@ -1924,16 +2010,27 @@ class OAuthHelpersImpl implements OAuthHelpers {
19242010
contacts: clientInfo.contacts,
19252011
grantTypes: clientInfo.grantTypes || ['authorization_code', 'refresh_token'],
19262012
responseTypes: clientInfo.responseTypes || ['code'],
1927-
registrationDate: Math.floor(Date.now() / 1000)
2013+
registrationDate: Math.floor(Date.now() / 1000),
2014+
tokenEndpointAuthMethod
19282015
};
19292016

2017+
// Only generate and store client secret for confidential clients
2018+
let clientSecret: string | undefined;
2019+
if (!isPublicClient) {
2020+
clientSecret = generateRandomString(32);
2021+
// Hash the client secret
2022+
newClient.clientSecret = await hashSecret(clientSecret);
2023+
}
2024+
19302025
await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(newClient));
19312026

1932-
// Return client with unhashed secret
1933-
const clientResponse = {
1934-
...newClient,
1935-
clientSecret // Return original unhashed secret
1936-
};
2027+
// Create the response object
2028+
const clientResponse = { ...newClient };
2029+
2030+
// Return confidential clients with their unhashed secret
2031+
if (!isPublicClient && clientSecret) {
2032+
clientResponse.clientSecret = clientSecret; // Return original unhashed secret
2033+
}
19372034

19382035
return clientResponse;
19392036
}
@@ -1991,11 +2088,19 @@ class OAuthHelpersImpl implements OAuthHelpers {
19912088
return null;
19922089
}
19932090

1994-
// Handle secret updates - if a new secret is provided, hash it
2091+
// Determine token endpoint auth method
2092+
let authMethod = updates.tokenEndpointAuthMethod || client.tokenEndpointAuthMethod || 'client_secret_basic';
2093+
const isPublicClient = authMethod === 'none';
2094+
2095+
// Handle changes in auth method
19952096
let secretToStore = client.clientSecret;
19962097
let originalSecret: string | undefined = undefined;
19972098

1998-
if (updates.clientSecret) {
2099+
if (isPublicClient) {
2100+
// Public clients don't have secrets
2101+
secretToStore = undefined;
2102+
} else if (updates.clientSecret) {
2103+
// For confidential clients, handle secret updates if provided
19992104
originalSecret = updates.clientSecret;
20002105
secretToStore = await hashSecret(updates.clientSecret);
20012106
}
@@ -2004,20 +2109,27 @@ class OAuthHelpersImpl implements OAuthHelpers {
20042109
...client,
20052110
...updates,
20062111
clientId: client.clientId, // Ensure clientId doesn't change
2007-
clientSecret: secretToStore // Use hashed secret
2112+
tokenEndpointAuthMethod: authMethod // Use determined auth method
20082113
};
20092114

2115+
// Only include client secret for confidential clients
2116+
if (!isPublicClient && secretToStore) {
2117+
updatedClient.clientSecret = secretToStore;
2118+
} else {
2119+
delete updatedClient.clientSecret;
2120+
}
2121+
20102122
await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(updatedClient));
20112123

2012-
// Return client with unhashed secret if a new one was provided
2013-
if (originalSecret) {
2014-
return {
2015-
...updatedClient,
2016-
clientSecret: originalSecret
2017-
};
2124+
// Create a response object
2125+
const response = { ...updatedClient };
2126+
2127+
// For confidential clients, return unhashed secret if a new one was provided
2128+
if (!isPublicClient && originalSecret) {
2129+
response.clientSecret = originalSecret;
20182130
}
20192131

2020-
return updatedClient;
2132+
return response;
20212133
}
20222134

20232135
/**

0 commit comments

Comments
 (0)