From 2a1a9f0d30c6b43ef23f33836cc181b5df672e0f Mon Sep 17 00:00:00 2001 From: Sunil Pai Date: Thu, 7 Aug 2025 12:26:56 +0100 Subject: [PATCH] Attempting to codify types This patch tries to remove a bynch of `any` types and make it all a bit more type safe (as much as typescript lets us). There are no functional changes and it should be backwards compatible. --- src/oauth-provider.ts | 141 +++++++++++++++++++++++++----------------- 1 file changed, 85 insertions(+), 56 deletions(-) diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index 1478027..98a813c 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -1,4 +1,5 @@ -import { WorkerEntrypoint } from 'cloudflare:workers'; +/** biome-ignore-all lint/style/noNonNullAssertion: it's fine */ +import { type env, WorkerEntrypoint } from 'cloudflare:workers'; // Types @@ -10,17 +11,30 @@ enum HandlerType { WORKER_ENTRYPOINT, } +/** + * Required environment bindings + */ +type RequiredEnv = { + OAUTH_KV: KVNamespace; + OAUTH_PROVIDER: OAuthHelpers; +}; + +/** + * Default environment bindings + */ +type DefaultEnv = typeof env & RequiredEnv; + /** * Discriminated union type for handlers */ -type TypedHandler = +type TypedHandler = | { type: HandlerType.EXPORTED_HANDLER; handler: ExportedHandlerWithFetch; } | { type: HandlerType.WORKER_ENTRYPOINT; - handler: new (ctx: ExecutionContext, env: any) => WorkerEntrypointWithFetch; + handler: new (ctx: ExecutionContext, env: Env) => WorkerEntrypointWithFetch; }; /** @@ -42,7 +56,7 @@ export interface TokenExchangeCallbackResult { * If not provided but newProps is, the access token will use newProps. * If neither is provided, the original props will be used. */ - accessTokenProps?: any; + accessTokenProps?: Record; /** * New props to replace the props stored in the grant itself. @@ -50,7 +64,7 @@ export interface TokenExchangeCallbackResult { * If accessTokenProps is not provided, these props will also be used for the current access token. * If not provided, the original props will be used. */ - newProps?: any; + newProps?: Record; /** * Override the default access token TTL (time-to-live) for this specific token. @@ -90,10 +104,10 @@ export interface TokenExchangeCallbackOptions { /** * Application-specific properties currently associated with this grant */ - props: any; + props: Record; } -export interface OAuthProviderOptions { +export interface OAuthProviderOptions { /** * URL(s) for API routes. Requests with URLs starting with any of these prefixes * will be treated as API requests and require a valid access token. @@ -112,7 +126,7 @@ export interface OAuthProviderOptions { * Used with `apiRoute` for the single-handler configuration. This is incompatible with * the `apiHandlers` property. You must use either `apiRoute` + `apiHandler` OR `apiHandlers`, not both. */ - apiHandler?: ExportedHandlerWithFetch | (new (ctx: ExecutionContext, env: any) => WorkerEntrypointWithFetch); + apiHandler?: ExportedHandlerWithFetch | (new (ctx: ExecutionContext, env: Env) => WorkerEntrypointWithFetch); /** * Map of API routes to their corresponding handlers for the multi-handler configuration. @@ -126,14 +140,14 @@ export interface OAuthProviderOptions { */ apiHandlers?: Record< string, - ExportedHandlerWithFetch | (new (ctx: ExecutionContext, env: any) => WorkerEntrypointWithFetch) + ExportedHandlerWithFetch | (new (ctx: ExecutionContext, env: Env) => WorkerEntrypointWithFetch) >; /** * Handler for all non-API requests or API requests without a valid token. * Can be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint. */ - defaultHandler: ExportedHandler | (new (ctx: ExecutionContext, env: any) => WorkerEntrypointWithFetch); + defaultHandler: ExportedHandler | (new (ctx: ExecutionContext, env: Env) => WorkerEntrypointWithFetch); /** * URL of the OAuth authorization endpoint where users can grant permissions. @@ -191,7 +205,7 @@ export interface OAuthProviderOptions { */ tokenExchangeCallback?: ( options: TokenExchangeCallbackOptions - ) => Promise | TokenExchangeCallbackResult | void; + ) => Promise | TokenExchangeCallbackResult | undefined | null; /** * Optional callback function that is called whenever the OAuthProvider returns an error response @@ -204,7 +218,7 @@ export interface OAuthProviderOptions { description: string; status: number; headers: Record; - }) => Response | void; + }) => Response | null | undefined; } // Using ExportedHandler from Cloudflare Workers Types for both API and default handlers @@ -422,7 +436,7 @@ export interface CompleteAuthorizationOptions { /** * Application-specific metadata to associate with this grant */ - metadata: any; + metadata: Record; /** * List of scopes that were actually granted (may differ from requested scopes) @@ -433,7 +447,7 @@ export interface CompleteAuthorizationOptions { * Application-specific properties to include with API requests * authorized by this grant */ - props: any; + props: Record; } /** @@ -463,7 +477,7 @@ export interface Grant { /** * Application-specific metadata associated with this grant */ - metadata: any; + metadata: Record; /** * Encrypted application-specific properties @@ -637,7 +651,7 @@ export interface GrantSummary { /** * Application-specific metadata associated with this grant */ - metadata: any; + metadata: Record; /** * Unix timestamp when the grant was created @@ -650,14 +664,14 @@ export interface GrantSummary { * Implements authorization code flow with support for refresh tokens * and dynamic client registration. */ -export class OAuthProvider { - #impl: OAuthProviderImpl; +export class OAuthProvider { + #impl: OAuthProviderImpl; /** * Creates a new OAuth provider instance * @param options - Configuration options for the provider */ - constructor(options: OAuthProviderOptions) { + constructor(options: OAuthProviderOptions) { this.#impl = new OAuthProviderImpl(options); } @@ -669,7 +683,7 @@ export class OAuthProvider { * @param ctx - Cloudflare Worker execution context * @returns A Promise resolving to an HTTP Response */ - fetch(request: Request, env: any, ctx: ExecutionContext): Promise { + fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { return this.#impl.fetch(request, env, ctx); } } @@ -682,29 +696,29 @@ export class OAuthProvider { * annotation, and does not actually prevent the method from being called from outside the class, * including over RPC. */ -class OAuthProviderImpl { +class OAuthProviderImpl { /** * Configuration options for the provider */ - options: OAuthProviderOptions; + options: OAuthProviderOptions; /** * Represents the validated type of a handler (ExportedHandler or WorkerEntrypoint) */ - private typedDefaultHandler: TypedHandler; + private typedDefaultHandler: TypedHandler; /** * Array of tuples of API routes and their validated handlers * In the simple case, this will be a single entry with the route and handler from options.apiRoute/apiHandler * In the advanced case, this will contain entries from options.apiHandlers */ - private typedApiHandlers: Array<[string, TypedHandler]>; + private typedApiHandlers: Array<[string, TypedHandler]>; /** * Creates a new OAuth provider instance * @param options - Configuration options for the provider */ - constructor(options: OAuthProviderOptions) { + constructor(options: OAuthProviderOptions) { // Initialize typedApiHandlers as an array this.typedApiHandlers = []; @@ -760,8 +774,10 @@ class OAuthProviderImpl { this.options = { accessTokenTTL: DEFAULT_ACCESS_TOKEN_TTL, - onError: ({ status, code, description }) => - console.warn(`OAuth error response: ${status} ${code} - ${description}`), + onError: ({ status, code, description }) => { + console.warn(`OAuth error response: ${status} ${code} - ${description}`); + return undefined; + }, ...options, }; } @@ -783,6 +799,7 @@ class OAuthProviderImpl { try { new URL(endpoint); } catch (e) { + console.error('Error validating endpoint', e); throw new TypeError(`${name} must be either an absolute path starting with / or a valid URL`); } } @@ -795,15 +812,18 @@ class OAuthProviderImpl { * @returns The type of the handler (EXPORTED_HANDLER or WORKER_ENTRYPOINT) * @throws TypeError if the handler is invalid */ - private validateHandler(handler: any, name: string): TypedHandler { - if (typeof handler === 'object' && handler !== null && typeof handler.fetch === 'function') { + private validateHandler(handler: unknown, name: string): TypedHandler { + if (typeof handler === 'object' && handler !== null && 'fetch' in handler && typeof handler.fetch === 'function') { // It's an ExportedHandler object - return { type: HandlerType.EXPORTED_HANDLER, handler }; + return { type: HandlerType.EXPORTED_HANDLER, handler: handler as ExportedHandlerWithFetch }; } // Check if it's a class constructor extending WorkerEntrypoint if (typeof handler === 'function' && handler.prototype instanceof WorkerEntrypoint) { - return { type: HandlerType.WORKER_ENTRYPOINT, handler }; + return { + type: HandlerType.WORKER_ENTRYPOINT, + handler: handler as new (ctx: ExecutionContext, env: Env) => WorkerEntrypointWithFetch, + }; } throw new TypeError( @@ -819,7 +839,7 @@ class OAuthProviderImpl { * @param ctx - Cloudflare Worker execution context * @returns A Promise resolving to an HTTP Response */ - async fetch(request: Request, env: any, ctx: ExecutionContext): Promise { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { const url = new URL(request.url); // Special handling for OPTIONS requests (CORS preflight) @@ -971,7 +991,7 @@ class OAuthProviderImpl { * @param url - The URL to find a handler for * @returns The TypedHandler for the URL, or undefined if no handler matches */ - private findApiHandlerForUrl(url: URL): TypedHandler | undefined { + private findApiHandlerForUrl(url: URL): TypedHandler | undefined { // Check each route in our array of validated API handlers for (const [route, handler] of this.typedApiHandlers) { if (this.matchApiRoute(url, route)) { @@ -1038,7 +1058,7 @@ class OAuthProviderImpl { const tokenEndpoint = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl); const authorizeEndpoint = this.getFullEndpointUrl(this.options.authorizeEndpoint, requestUrl); - let registrationEndpoint: string | undefined = undefined; + let registrationEndpoint: string | undefined; if (this.options.clientRegistrationEndpoint) { registrationEndpoint = this.getFullEndpointUrl(this.options.clientRegistrationEndpoint, requestUrl); } @@ -1089,14 +1109,14 @@ class OAuthProviderImpl { * @param env - Cloudflare Worker environment variables * @returns Response with token data or error */ - private async handleTokenRequest(request: Request, env: any): Promise { + private async handleTokenRequest(request: Request, env: Env): Promise { // Only accept POST requests if (request.method !== 'POST') { return this.createErrorResponse('invalid_request', 'Method not allowed', 405); } - let contentType = request.headers.get('Content-Type') || ''; - let body: any = {}; + const contentType = request.headers.get('Content-Type') || ''; + const body: Record = {}; // According to OAuth 2.0 RFC 6749 Section 2.3, token requests MUST use // application/x-www-form-urlencoded content type @@ -1115,7 +1135,7 @@ class OAuthProviderImpl { let clientId = ''; let clientSecret = ''; - if (authHeader && authHeader.startsWith('Basic ')) { + if (authHeader?.startsWith('Basic ')) { // Basic auth const credentials = atob(authHeader.substring(6)); const [id, secret] = credentials.split(':', 2); @@ -1182,7 +1202,11 @@ class OAuthProviderImpl { * @param env - Cloudflare Worker environment variables * @returns Response with token data or error */ - private async handleAuthorizationCodeGrant(body: any, clientInfo: ClientInfo, env: any): Promise { + private async handleAuthorizationCodeGrant( + body: Record, + clientInfo: ClientInfo, + env: Env + ): Promise { const code = body.code; const redirectUri = body.redirect_uri; const codeVerifier = body.code_verifier; @@ -1417,7 +1441,11 @@ class OAuthProviderImpl { * @param env - Cloudflare Worker environment variables * @returns Response with token data or error */ - private async handleRefreshTokenGrant(body: any, clientInfo: ClientInfo, env: any): Promise { + private async handleRefreshTokenGrant( + body: Record, + clientInfo: ClientInfo, + env: Env + ): Promise { const refreshToken = body.refresh_token; if (!refreshToken) { @@ -1627,7 +1655,7 @@ class OAuthProviderImpl { * @param env - Cloudflare Worker environment variables * @returns Response with client registration data or error */ - private async handleClientRegistration(request: Request, env: any): Promise { + private async handleClientRegistration(request: Request, env: Env): Promise { if (!this.options.clientRegistrationEndpoint) { return this.createErrorResponse('not_implemented', 'Client registration is not enabled', 501); } @@ -1645,7 +1673,7 @@ class OAuthProviderImpl { } // Parse client metadata with a size limitation - let clientMetadata; + let clientMetadata: Record; try { const text = await request.text(); if (text.length > 1048576) { @@ -1654,11 +1682,12 @@ class OAuthProviderImpl { } clientMetadata = JSON.parse(text); } catch (error) { + console.error('Error parsing client metadata', error); return this.createErrorResponse('invalid_request', 'Invalid JSON payload', 400); } // Basic type validation functions - const validateStringField = (field: any): string | undefined => { + const validateStringField = (field: unknown): string | undefined => { if (field === undefined) { return undefined; } @@ -1668,7 +1697,7 @@ class OAuthProviderImpl { return field; }; - const validateStringArray = (arr: any): string[] | undefined => { + const validateStringArray = (arr: unknown): string[] | undefined => { if (arr === undefined) { return undefined; } @@ -1746,7 +1775,7 @@ class OAuthProviderImpl { await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(clientInfo)); // Return client information with the original unhashed secret - const response: Record = { + const response: Record = { client_id: clientInfo.clientId, redirect_uris: clientInfo.redirectUris, client_name: clientInfo.clientName, @@ -1781,7 +1810,7 @@ class OAuthProviderImpl { * @param ctx - Cloudflare Worker execution context * @returns Response from the API handler or error */ - private async handleApiRequest(request: Request, env: any, ctx: ExecutionContext): Promise { + private async handleApiRequest(request: Request, env: Env, ctx: ExecutionContext): Promise { // Get access token from Authorization header const authHeader = request.headers.get('Authorization'); @@ -1866,7 +1895,7 @@ class OAuthProviderImpl { * @param env - Cloudflare Worker environment variables * @returns An instance of OAuthHelpers */ - private createOAuthHelpers(env: any): OAuthHelpers { + private createOAuthHelpers(env: Env): OAuthHelpers { return new OAuthHelpersImpl(env, this); } @@ -1879,7 +1908,7 @@ class OAuthProviderImpl { * @param clientId - The client ID to look up * @returns The client information, or null if not found */ - getClient(env: any, clientId: string): Promise { + getClient(env: Env, clientId: string): Promise { const clientKey = `client:${clientId}`; return env.OAUTH_KV.get(clientKey, { type: 'json' }); } @@ -2012,7 +2041,7 @@ function base64ToArrayBuffer(base64: string): ArrayBuffer { * @param data - The data to encrypt * @returns An object containing the encrypted data and the generated key */ -async function encryptProps(data: any): Promise<{ encryptedData: string; key: CryptoKey }> { +async function encryptProps(data: unknown): Promise<{ encryptedData: string; key: CryptoKey }> { // Generate a new encryption key for this specific props data // @ts-ignore const key: CryptoKey = await crypto.subtle.generateKey( @@ -2055,7 +2084,7 @@ async function encryptProps(data: any): Promise<{ encryptedData: string; key: Cr * @param encryptedData - The encrypted data as a base64 string * @returns The decrypted data object */ -async function decryptProps(key: CryptoKey, encryptedData: string): Promise { +async function decryptProps(key: CryptoKey, encryptedData: string): Promise> { // Convert base64 string back to ArrayBuffer const encryptedBuffer = base64ToArrayBuffer(encryptedData); @@ -2164,16 +2193,16 @@ async function unwrapKeyWithToken(tokenStr: string, wrappedKeyBase64: string): P * Class that implements the OAuth helper methods * Provides methods for OAuth operations needed by handlers */ -class OAuthHelpersImpl implements OAuthHelpers { - private env: any; - private provider: OAuthProviderImpl; +class OAuthHelpersImpl implements OAuthHelpers { + private env: Env; + private provider: OAuthProviderImpl; /** * Creates a new OAuthHelpers instance * @param env - Cloudflare Worker environment variables * @param provider - Reference to the parent provider instance */ - constructor(env: any, provider: OAuthProviderImpl) { + constructor(env: Env, provider: OAuthProviderImpl) { this.env = env; this.provider = provider; } @@ -2468,12 +2497,12 @@ class OAuthHelpersImpl implements OAuthHelpers { } // Determine token endpoint auth method - let authMethod = updates.tokenEndpointAuthMethod || client.tokenEndpointAuthMethod || 'client_secret_basic'; + const authMethod = updates.tokenEndpointAuthMethod || client.tokenEndpointAuthMethod || 'client_secret_basic'; const isPublicClient = authMethod === 'none'; // Handle changes in auth method let secretToStore = client.clientSecret; - let originalSecret: string | undefined = undefined; + let originalSecret: string | undefined; if (isPublicClient) { // Public clients don't have secrets