UNPKG

@oa2/core

Version:

A comprehensive, RFC-compliant OAuth 2.0 authorization server implementation in TypeScript

1 lines 9.79 kB
{"version":3,"file":"utils.d.ts","sources":["../src/utils.ts"],"sourcesContent":["import { createHash, randomBytes, timingSafeEqual } from 'crypto';\nimport { InvalidRequestError, InvalidScopeError } from './errors';\nimport { Client, OAuth2Request } from './types';\n\n/**\n * Generates a cryptographically strong random string.\n * Compliant with RFC 7636 for PKCE code verifiers.\n */\nexport function generateSecureRandomString(length: number): string {\n return randomBytes(Math.ceil(length / 2))\n .toString('hex')\n .slice(0, length);\n}\n\n/**\n * Creates a SHA256 hash and encodes it in Base64 URL-safe format.\n * Used for PKCE S256 code challenge verification.\n */\nexport function createS256Challenge(verifier: string): string {\n return createHash('sha256')\n .update(verifier)\n .digest('base64')\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=/g, '');\n}\n\n/**\n * Hashes a client secret using SHA-256 with a salt.\n * Provides protection against rainbow table attacks.\n */\nexport function hashClientSecret(secret: string, salt?: string): { hashedSecret: string; salt: string } {\n const secretSalt = salt || randomBytes(32).toString('hex');\n const hashedSecret = createHash('sha256')\n .update(secret + secretSalt)\n .digest('hex');\n\n return { hashedSecret: secretSalt + ':' + hashedSecret, salt: secretSalt };\n}\n\n/**\n * Verifies a client secret against a hashed secret using timing-safe comparison.\n * Prevents timing attacks while maintaining backward compatibility.\n */\nexport function verifyClientSecret(plainSecret: string, hashedSecret: string): boolean {\n try {\n const [salt, hash] = hashedSecret.split(':');\n if (!salt || !hash) {\n // Fallback: if no salt format detected, assume plain text comparison (for backward compatibility)\n // In production, this should log a warning to migrate to hashed secrets\n return timingSafeEqual(Buffer.from(plainSecret), Buffer.from(hashedSecret));\n }\n\n const { hashedSecret: computedHash } = hashClientSecret(plainSecret, salt);\n const [, computedHashOnly] = computedHash.split(':');\n\n return timingSafeEqual(Buffer.from(hash), Buffer.from(computedHashOnly));\n } catch (error) {\n return false;\n }\n}\n\n/**\n * Validates PKCE code verifier length and character set according to RFC 7636.\n * Ensures the code verifier meets security requirements.\n */\nexport function validateCodeVerifier(codeVerifier: string, minLength: number = 43, maxLength: number = 128): boolean {\n if (!codeVerifier) {\n throw new InvalidRequestError('Missing code_verifier parameter');\n }\n\n if (codeVerifier.length < minLength) {\n throw new InvalidRequestError(`code_verifier too short. Minimum length is ${minLength} characters`);\n }\n\n if (codeVerifier.length > maxLength) {\n throw new InvalidRequestError(`code_verifier too long. Maximum length is ${maxLength} characters`);\n }\n\n // Validate character set: [A-Z] / [a-z] / [0-9] / \"-\" / \".\" / \"_\" / \"~\"\n const validChars = /^[A-Za-z0-9\\-._~]+$/;\n if (!validChars.test(codeVerifier)) {\n throw new InvalidRequestError('code_verifier contains invalid characters. Only [A-Za-z0-9-._~] are allowed');\n }\n\n return true;\n}\n\n/**\n * Validates a PKCE code challenge against a code verifier.\n * Supports both 'plain' and 'S256' challenge methods.\n */\nexport function validatePkceChallenge(\n codeVerifier: string,\n codeChallenge: string,\n codeChallengeMethod: 'plain' | 'S256',\n): boolean {\n if (codeChallengeMethod === 'plain') {\n return codeChallenge === codeVerifier;\n } else if (codeChallengeMethod === 'S256') {\n const hashedCodeVerifier = createS256Challenge(codeVerifier);\n return codeChallenge === hashedCodeVerifier;\n }\n\n throw new InvalidRequestError('Unsupported code_challenge_method');\n}\n\n/**\n * Parses a space-delimited scope string into an array of individual scopes.\n * Filters out empty strings and normalizes the input.\n */\nexport function parseScopes(scopeString: string | undefined): string[] {\n if (!scopeString) {\n return [];\n }\n\n return scopeString.split(' ').filter((scope) => scope.length > 0);\n}\n\n/**\n * Validates that all requested scopes are supported by the server.\n * Throws an error if any scope is not in the predefined list.\n */\nexport function validateScopeSupport(requestedScopes: string[], predefinedScopes: string[]): void {\n for (const scope of requestedScopes) {\n if (!predefinedScopes.includes(scope)) {\n throw new InvalidScopeError(`Invalid scope: ${scope}`);\n }\n }\n}\n\n/**\n * Validates that the client is allowed to request the specified scopes.\n * Throws an error if the client doesn't have permission for any scope.\n */\nexport function validateClientScopePermission(requestedScopes: string[], client: Client): void {\n for (const scope of requestedScopes) {\n if (!client.scopes.includes(scope)) {\n throw new InvalidScopeError(`Client not allowed to request scope: ${scope}`);\n }\n }\n}\n\n/**\n * Validates the requested scopes against predefined and client-specific allowed scopes.\n * Returns the validated, space-delimited scope string.\n */\nexport function validateScope(requestedScope: string | undefined, predefinedScopes: string[], client: Client): string {\n if (!requestedScope) {\n return '';\n }\n const scopes = parseScopes(requestedScope);\n\n // Validate all scopes are supported by the server\n validateScopeSupport(scopes, predefinedScopes);\n\n // Validate the client is allowed to request these scopes\n validateClientScopePermission(scopes, client);\n\n return scopes.join(' ');\n}\n\n/**\n * Validates a redirect URI against a client's registered URIs.\n * Handles the case where no redirect URI is provided but the client has exactly one registered.\n */\nexport function validateRedirectUri(client: { redirectUris: string[] }, redirectUri?: string): string {\n if (redirectUri) {\n if (!client.redirectUris.includes(redirectUri)) {\n throw new InvalidRequestError('Invalid redirect_uri');\n }\n return redirectUri;\n } else if (client.redirectUris.length === 1) {\n return client.redirectUris[0];\n } else {\n throw new InvalidRequestError('Missing redirect_uri parameter, and client has multiple registered redirect URIs');\n }\n}\n\n/**\n * Parses a URL-encoded body from an OAuth2Request.\n * Converts form data into a record for easy access.\n */\nfunction parseUrlEncodedBody(request: OAuth2Request & { body: string }): Record<string, any> {\n const body = request.body as string;\n const params = new URLSearchParams(body);\n const parsedBody: Record<string, any> = {};\n\n for (const [key, value] of params.entries()) {\n parsedBody[key] = value;\n }\n\n return parsedBody;\n}\n\n/**\n * Takes an OAuth2Request and returns the parsed body as a record.\n * Handles both JSON and form-urlencoded content types.\n *\n * @see RFC 6749, Section 4.1.3 Access Token Request\n * @see RFC 6749, Appendix B Use of application/x-www-form-urlencoded Media Type\n */\nexport function parseRequestBody(request: OAuth2Request): Record<string, any> {\n const contentType = request.headers['Content-Type'] || request.headers['content-type'] || '';\n\n // Handle application/x-www-form-urlencoded\n if (contentType.includes('application/x-www-form-urlencoded') && typeof request.body === 'string') {\n return parseUrlEncodedBody(request as OAuth2Request & { body: string });\n }\n\n // Handle application/json\n if (contentType.includes('application/json')) {\n return typeof request.body === 'string' ? JSON.parse(request.body) : request.body!;\n }\n\n // If the content type is not recognized, try to parse as URL-encoded first, then as JSON\n if (typeof request.body === 'string') {\n // Try URL-encoded parsing first (most common for OAuth)\n try {\n const mockRequest = { ...request, body: request.body } as OAuth2Request & { body: string };\n return parseUrlEncodedBody(mockRequest);\n } catch {\n // Fall back to JSON parsing\n try {\n return JSON.parse(request.body);\n } catch {\n // If both fail, return an empty object\n return {};\n }\n }\n }\n return request.body!;\n}\n\n/**\n * Generates a cryptographically strong random string.\n * Uses Node.js crypto module for secure random generation.\n */\nexport function generateRandomString(length: number): string {\n const { randomBytes } = require('crypto');\n return randomBytes(Math.ceil(length / 2))\n .toString('hex')\n .slice(0, length);\n}\n\n/**\n * Extracts client credentials from Basic authentication header.\n * Returns null if no Basic auth header is present.\n */\nexport function extractBasicAuthCredentials(authHeader?: string): { clientId: string; clientSecret: string } | null {\n if (!authHeader || !authHeader.startsWith('Basic ')) {\n return null;\n }\n\n const credentials = Buffer.from(authHeader.substring(6), 'base64').toString().split(':');\n return {\n clientId: credentials[0],\n clientSecret: credentials[1],\n };\n}\n"],"names":[],"mappings":";;AACA;AACA;AACA;AACA;AACO,iBAAA,0BAAA;AACP;AACA;AACA;AACA;AACO,iBAAA,mBAAA;AACP;AACA;AACA;AACA;AACO,iBAAA,gBAAA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACO,iBAAA,kBAAA;AACP;AACA;AACA;AACA;AACO,iBAAA,oBAAA;AACP;AACA;AACA;AACA;AACO,iBAAA,qBAAA;AACP;AACA;AACA;AACA;AACO,iBAAA,WAAA;AACP;AACA;AACA;AACA;AACO,iBAAA,oBAAA;AACP;AACA;AACA;AACA;AACO,iBAAA,6BAAA,oCAAA,MAAA;AACP;AACA;AACA;AACA;AACO,iBAAA,aAAA,yEAAA,MAAA;AACP;AACA;AACA;AACA;AACO,iBAAA,mBAAA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,iBAAA,gBAAA,UAAA,aAAA,GAAA,MAAA;AACP;AACA;AACA;AACA;AACO,iBAAA,oBAAA;AACP;AACA;AACA;AACA;AACO,iBAAA,2BAAA;AACP;AACA;AACA;;;;"}