UNPKG

@oa2/core

Version:

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

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