From aab272aff0c2b9bab49c5b3faf8159e59ce75648 Mon Sep 17 00:00:00 2001 From: Edward Sun Date: Fri, 16 Jan 2026 21:25:55 -0500 Subject: [PATCH 1/2] adding support for Cognito multi-region issuer --- .../cognito-multi-issuer-format/design.md | 338 +++++ .../requirements.md | 75 ++ .../cognito-multi-issuer-format/tasks.md | 133 ++ package-lock.json | 41 + package.json | 1 + src/cognito-verifier.ts | 141 +- tests/unit/cognito-verifier.property.test.ts | 355 +++++ tests/unit/cognito-verifier.test.ts | 1163 +++++++++++++++++ 8 files changed, 2225 insertions(+), 22 deletions(-) create mode 100644 .kiro/specs/cognito-multi-issuer-format/design.md create mode 100644 .kiro/specs/cognito-multi-issuer-format/requirements.md create mode 100644 .kiro/specs/cognito-multi-issuer-format/tasks.md create mode 100644 tests/unit/cognito-verifier.property.test.ts diff --git a/.kiro/specs/cognito-multi-issuer-format/design.md b/.kiro/specs/cognito-multi-issuer-format/design.md new file mode 100644 index 0000000..38e5f9b --- /dev/null +++ b/.kiro/specs/cognito-multi-issuer-format/design.md @@ -0,0 +1,338 @@ +# Design Document: Cognito Multi-Issuer Format Support + +## Overview + +This design document describes the minimal changes required to support the multi-region Cognito issuer format (`https://issuer.cognito-idp.{region}.amazonaws.com/{userPoolId}`) alongside the standard format (`https://cognito-idp.{region}.amazonaws.com/{userPoolId}`). + +### Design Principle: Minimal Changes + +The goal is to make the smallest possible changes to the existing codebase while achieving full functionality. We will: + +- Reuse existing infrastructure (JWKS cache, base verifier class) as-is +- Add new methods only where necessary +- Modify existing methods minimally +- Avoid restructuring the configuration storage + +## Architecture + +### Key Insight + +The existing `JwtVerifierBase` already supports multiple issuers via its `issuersConfig` map. Instead of changing the storage structure, we can: + +1. Register **both** issuer formats as separate entries in the map during `create()` +2. The existing lookup by `iss` claim will then work naturally for either format + +This approach requires minimal changes: + +- Modify `parseUserPoolId()` to return both issuers +- Modify the constructor to register both issuers pointing to the same config +- Add a helper to parse/validate incoming issuers + +### Current vs Proposed Flow + +**Current**: One issuer registered per User Pool +**Proposed**: Two issuers registered per User Pool (standard + multi-region), both pointing to the same verification config but with their respective JWKS URIs + +## Components and Interfaces + +### Modified: `CognitoJwtVerifier.parseUserPoolId()` + +Minimal change: Return both issuer formats instead of just one. + +```typescript +// BEFORE +public static parseUserPoolId(userPoolId: string): { + issuer: string; + jwksUri: string; +} { + // ... validation ... + const issuer = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`; + return { + issuer, + jwksUri: `${issuer}/.well-known/jwks.json`, + }; +} + +// AFTER +public static parseUserPoolId(userPoolId: string): { + issuer: string; // standard issuer (for backward compat) + jwksUri: string; // standard JWKS URI (for backward compat) + multiRegionIssuer: string; + multiRegionJwksUri: string; +} { + // ... validation (unchanged) ... + const standardIssuer = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`; + const multiRegionIssuer = `https://issuer.cognito-idp.${region}.amazonaws.com/${userPoolId}`; + return { + issuer: standardIssuer, + jwksUri: `${standardIssuer}/.well-known/jwks.json`, + multiRegionIssuer, + multiRegionJwksUri: `${multiRegionIssuer}/.well-known/jwks.json`, + }; +} +``` + +### Modified: `CognitoJwtVerifier` Constructor + +Minimal change: Register both issuers in the config map. + +```typescript +// BEFORE +private constructor( + props: CognitoJwtVerifierProperties | CognitoJwtVerifierMultiProperties[], + jwksCache?: JwksCache +) { + const issuerConfig = Array.isArray(props) + ? (props.map((p) => ({ + ...p, + ...CognitoJwtVerifier.parseUserPoolId(p.userPoolId), + audience: null, + })) as IssuerConfig[]) + : ({ + ...props, + ...CognitoJwtVerifier.parseUserPoolId(props.userPoolId), + audience: null, + } as IssuerConfig); + super(issuerConfig, jwksCache); +} + +// AFTER +private constructor( + props: CognitoJwtVerifierProperties | CognitoJwtVerifierMultiProperties[], + jwksCache?: JwksCache +) { + const propsArray = Array.isArray(props) ? props : [props]; + const issuerConfigs: IssuerConfig[] = []; + + for (const p of propsArray) { + const parsed = CognitoJwtVerifier.parseUserPoolId(p.userPoolId); + const baseConfig = { + ...p, + userPoolId: p.userPoolId, + audience: null, + }; + + // Register standard issuer config + issuerConfigs.push({ + ...baseConfig, + issuer: parsed.issuer, + jwksUri: parsed.jwksUri, + } as IssuerConfig); + + // Register multi-region issuer config + issuerConfigs.push({ + ...baseConfig, + issuer: parsed.multiRegionIssuer, + jwksUri: parsed.multiRegionJwksUri, + } as IssuerConfig); + } + + super(issuerConfigs, jwksCache); +} +``` + +### New: `CognitoJwtVerifier.parseIssuer()` (Optional Helper) + +Add a static method to validate and parse Cognito issuers. This is useful for users who want to validate issuers manually. + +```typescript +/** + * Parse a Cognito issuer URL to extract the User Pool ID and determine the format. + * Supports both standard and multi-region issuer formats. + */ +public static parseIssuer(issuer: string): { + userPoolId: string; + region: string; + format: "standard" | "multiRegion"; +} | null { + // Standard format: https://cognito-idp.{region}.amazonaws.com/{userPoolId} + const standardMatch = issuer.match( + /^https:\/\/cognito-idp\.([a-z]{2}-(gov-)?[a-z]+-\d)\.amazonaws\.com\/([a-z]{2}-(gov-)?[a-z]+-\d_[a-zA-Z0-9]+)$/ + ); + if (standardMatch) { + return { + region: standardMatch[1], + userPoolId: standardMatch[3], + format: "standard", + }; + } + + // Multi-region format: https://issuer.cognito-idp.{region}.amazonaws.com/{userPoolId} + const multiRegionMatch = issuer.match( + /^https:\/\/issuer\.cognito-idp\.([a-z]{2}-(gov-)?[a-z]+-\d)\.amazonaws\.com\/([a-z]{2}-(gov-)?[a-z]+-\d_[a-zA-Z0-9]+)$/ + ); + if (multiRegionMatch) { + return { + region: multiRegionMatch[1], + userPoolId: multiRegionMatch[3], + format: "multiRegion", + }; + } + + return null; +} +``` + +### Modified: `hydrate()` Method + +The base class `hydrate()` already iterates over all issuer configs. Since we now register two configs per User Pool, it will automatically fetch JWKS from both endpoints. **No change needed.** + +### Modified: `cacheJwks()` Method + +Minor change: When caching JWKS for a User Pool, cache for both issuers. + +```typescript +public cacheJwks( + ...[jwks, userPoolId]: MultiIssuer extends false + ? [jwks: Jwks, userPoolId?: string] + : [jwks: Jwks, userPoolId: string] +): void { + // Find both issuer configs for this User Pool + const parsed = userPoolId + ? CognitoJwtVerifier.parseUserPoolId(userPoolId) + : null; + + if (parsed) { + // Cache for both issuers + const standardConfig = this.getIssuerConfig(parsed.issuer); + const multiRegionConfig = this.getIssuerConfig(parsed.multiRegionIssuer); + super.cacheJwks(jwks, standardConfig.issuer); + super.cacheJwks(jwks, multiRegionConfig.issuer); + } else { + // Single User Pool case - cache for both issuers + for (const config of this.issuersConfig.values()) { + this.jwksCache.addJwks(config.jwksUri, jwks); + } + // Clear public key cache for all issuers + for (const config of this.issuersConfig.values()) { + this.publicKeyCache.clearCache(config.issuer); + } + } +} +``` + +## Data Models + +No new data models required. The existing `IssuerConfig` type is sufficient. + +## Summary of Changes + +| File | Change | Lines Changed (Est.) | +| ------------------------- | ------------------------------------------- | -------------------- | +| `src/cognito-verifier.ts` | Modify `parseUserPoolId()` return type | ~5 | +| `src/cognito-verifier.ts` | Modify constructor to register both issuers | ~15 | +| `src/cognito-verifier.ts` | Add `parseIssuer()` static method | ~25 | +| `src/cognito-verifier.ts` | Modify `cacheJwks()` to handle both issuers | ~10 | + +**Total estimated changes: ~55 lines** + +## What Stays the Same + +- `JwtVerifierBase` class - no changes +- `SimpleJwksCache` - no changes +- `verify()` and `verifySync()` methods - no changes (they use base class implementation) +- `validateCognitoJwtFields()` - no changes +- Error types - no changes +- All other verifiers (`JwtVerifier`, `AlbJwtVerifier`) - no changes + +## Correctness Properties + +_A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do._ + +**Property 1: Both issuer formats are accepted for valid tokens** + +_For any_ valid JWT signed by a configured User Pool, if the issuer matches either the standard format (`https://cognito-idp.{region}.amazonaws.com/{userPoolId}`) or the multi-region format (`https://issuer.cognito-idp.{region}.amazonaws.com/{userPoolId}`), the CognitoJwtVerifier should accept the token. + +**Validates: Requirements 1.1, 1.2** + +--- + +**Property 2: Invalid issuer formats are rejected** + +_For any_ JWT with an issuer that does not match either the standard or multi-region Cognito issuer format for the configured User Pool ID, the CognitoJwtVerifier should reject the token with a ParameterValidationError. + +**Validates: Requirements 1.3, 4.3** + +--- + +**Property 3: JWKS URI is derived from token's issuer** + +_For any_ JWT being verified, the JWKS URI used for fetching keys should match the issuer's domain (standard or multi-region), ensuring the correct endpoint is used. + +**Validates: Requirements 2.1, 2.2** + +--- + +**Property 4: Issuer must match configured User Pool** + +_For any_ JWT with an issuer containing a User Pool ID that does not match the configured User Pool, the CognitoJwtVerifier should reject the token. + +**Validates: Requirements 4.1, 4.2** + +--- + +**Property 5: Multi-pool configuration accepts tokens from any configured pool** + +_For any_ CognitoJwtVerifier configured with multiple User Pools, and _for any_ valid JWT from any of those pools using either issuer format, the verifier should accept the token. + +**Validates: Requirements 5.1, 5.2** + +--- + +**Property 6: Hydrate fetches JWKS from both endpoints** + +_For any_ CognitoJwtVerifier with configured User Pools, calling `hydrate()` should result in JWKS being fetched from both the standard and multi-region endpoints for each configured pool. + +**Validates: Requirements 2.1.3** + +--- + +**Property 7: Cache isolation between endpoints** + +_For any_ cache miss on one JWKS endpoint (standard or multi-region), fetching fresh JWKS from that endpoint should not invalidate or affect the cached JWKS from the other endpoint. + +**Validates: Requirements 2.1.4** + +## Error Handling + +### Existing Error Handling (Unchanged) + +The existing error handling remains the same: + +- `ParameterValidationError`: Invalid User Pool ID or issuer not configured +- `JwtExpiredError`: Token has expired +- `JwtInvalidSignatureError`: Signature verification failed +- `CognitoJwtInvalidClientIdError`: Client ID doesn't match +- `CognitoJwtInvalidTokenUseError`: Token use doesn't match +- `KidNotFoundInJwksError`: Key ID not found in JWKS + +When a token has an issuer that doesn't match any configured issuer (neither standard nor multi-region for any configured User Pool), the existing `getIssuerConfig()` will throw `ParameterValidationError` with message `"issuer not configured: {issuer}"`. + +## Testing Strategy + +### Unit Tests + +- `parseUserPoolId()` returns both issuer formats +- `parseIssuer()` correctly identifies standard vs multi-region format +- `parseIssuer()` returns null for invalid formats +- Constructor registers both issuers for each User Pool +- `cacheJwks()` caches for both issuers + +### Property-Based Tests + +- **Property 1**: Generate valid tokens with both issuer formats, verify acceptance +- **Property 2**: Generate tokens with invalid issuers, verify rejection +- **Property 3**: Verify correct JWKS URI is used based on issuer format + +### Integration Tests + +- Verify token with standard issuer format +- Verify token with multi-region issuer format +- Verify `hydrate()` fetches from both endpoints +- Verify multi-pool configuration works with mixed formats + +### Test Configuration + +- Property-based tests: minimum 100 iterations +- Use `nock` for mocking JWKS endpoints +- Use existing test utilities for JWT generation diff --git a/.kiro/specs/cognito-multi-issuer-format/requirements.md b/.kiro/specs/cognito-multi-issuer-format/requirements.md new file mode 100644 index 0000000..c84697e --- /dev/null +++ b/.kiro/specs/cognito-multi-issuer-format/requirements.md @@ -0,0 +1,75 @@ +# Requirements Document + +## Introduction + +This document specifies the requirements for enhancing the `aws-jwt-verify` library to support an additional Cognito issuer format. Amazon Cognito User Pools with multi-region replication enabled use a different issuer URL format (`https://issuer.cognito-idp.{region}.amazonaws.com/{userPoolId}`) compared to standard User Pools (`https://cognito-idp.{region}.amazonaws.com/{userPoolId}`). The library must be updated to accept JWTs with either issuer format. + +## Glossary + +- **CognitoJwtVerifier**: The class in the library responsible for verifying JWTs signed by Amazon Cognito User Pools. +- **Issuer**: The `iss` claim in a JWT that identifies the principal that issued the token. +- **User_Pool_ID**: A unique identifier for an Amazon Cognito User Pool in the format `{region}_{alphanumeric}`. +- **JWKS_URI**: The URL endpoint where the JSON Web Key Set can be fetched for signature verification. +- **Standard_Issuer_Format**: The issuer format for standard User Pools: `https://cognito-idp.{region}.amazonaws.com/{userPoolId}`. +- **MultiRegion_Issuer_Format**: The issuer format for multi-region replication enabled User Pools: `https://issuer.cognito-idp.{region}.amazonaws.com/{userPoolId}`. + +## Requirements + +### Requirement 1: Support Both Issuer Formats + +**User Story:** As a developer using aws-jwt-verify, I want the library to accept JWTs with either the standard or multi-region Cognito issuer format, so that my application works with both standard and multi-region User Pools. + +#### Acceptance Criteria + +1. WHEN a JWT with the Standard_Issuer_Format is verified, THE CognitoJwtVerifier SHALL accept the token if all other validation checks pass. +2. WHEN a JWT with the MultiRegion_Issuer_Format is verified, THE CognitoJwtVerifier SHALL accept the token if all other validation checks pass. +3. WHEN a JWT with an issuer format that does not match either the Standard_Issuer_Format or MultiRegion_Issuer_Format for the configured User_Pool_ID, THE CognitoJwtVerifier SHALL reject the token with an appropriate error. + +### Requirement 2: Derive JWKS URI from Token Issuer + +**User Story:** As a developer, I want the library to fetch the JWKS from the correct endpoint based on the token's issuer, so that signature verification works correctly regardless of which issuer format was used. + +#### Acceptance Criteria + +1. WHEN a JWT with the Standard_Issuer_Format is verified, THE CognitoJwtVerifier SHALL fetch the JWKS from `https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json`. +2. WHEN a JWT with the MultiRegion_Issuer_Format is verified, THE CognitoJwtVerifier SHALL fetch the JWKS from `https://issuer.cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json`. + +### Requirement 2.1: JWKS Caching for Both Endpoints + +**User Story:** As a developer, I want the library to cache JWKS from both issuer endpoints independently, so that performance is optimized when my application receives tokens with different issuer formats. + +#### Acceptance Criteria + +1. WHEN a JWT with the Standard_Issuer_Format is verified, THE CognitoJwtVerifier SHALL cache the JWKS using the standard JWKS URI as the cache key. +2. WHEN a JWT with the MultiRegion_Issuer_Format is verified, THE CognitoJwtVerifier SHALL cache the JWKS using the multi-region JWKS URI as the cache key. +3. WHEN the JWKS cache is hydrated via the `hydrate()` method, THE CognitoJwtVerifier SHALL fetch and cache JWKS from both the standard and multi-region endpoints for each configured User Pool. +4. WHEN a JWK is not found in the cached JWKS for one endpoint, THE CognitoJwtVerifier SHALL fetch fresh JWKS from that specific endpoint without affecting the cache for the other endpoint. + +### Requirement 3: Backward Compatibility + +**User Story:** As a developer with an existing integration, I want the library update to be backward compatible, so that my existing code continues to work without modifications. + +#### Acceptance Criteria + +1. THE CognitoJwtVerifier SHALL maintain the existing API signature for the `create()` method. +2. THE CognitoJwtVerifier SHALL maintain the existing API signature for the `verify()` and `verifySync()` methods. +3. WHEN a developer upgrades to the new version, THE CognitoJwtVerifier SHALL work with existing code without requiring changes. + +### Requirement 4: Issuer Validation + +**User Story:** As a security-conscious developer, I want the library to validate that the token's issuer corresponds to the configured User Pool, so that tokens from other User Pools are rejected. + +#### Acceptance Criteria + +1. WHEN a JWT's issuer contains a User_Pool_ID that does not match the configured User_Pool_ID, THE CognitoJwtVerifier SHALL reject the token. +2. WHEN a JWT's issuer contains a region that does not match the region in the configured User_Pool_ID, THE CognitoJwtVerifier SHALL reject the token. +3. WHEN a JWT's issuer uses an unrecognized domain pattern (not cognito-idp or issuer.cognito-idp), THE CognitoJwtVerifier SHALL reject the token. + +### Requirement 5: Multiple User Pools with Mixed Issuer Formats + +**User Story:** As a developer supporting multiple User Pools, I want the library to handle tokens from different User Pools that may use different issuer formats, so that my multi-tenant application works correctly. + +#### Acceptance Criteria + +1. WHEN the CognitoJwtVerifier is configured with multiple User Pools, THE CognitoJwtVerifier SHALL accept tokens with either issuer format from any of the configured User Pools. +2. WHEN verifying a token, THE CognitoJwtVerifier SHALL match the token's issuer to the correct User Pool configuration based on the User_Pool_ID extracted from the issuer. diff --git a/.kiro/specs/cognito-multi-issuer-format/tasks.md b/.kiro/specs/cognito-multi-issuer-format/tasks.md new file mode 100644 index 0000000..8367e09 --- /dev/null +++ b/.kiro/specs/cognito-multi-issuer-format/tasks.md @@ -0,0 +1,133 @@ +# Implementation Plan: Cognito Multi-Issuer Format Support + +## Overview + +This implementation plan covers the minimal changes needed to support both standard and multi-region Cognito issuer formats. The approach registers both issuer formats as separate entries in the existing configuration map, leveraging the existing infrastructure. + +## Tasks + +- [x] 1. Modify `parseUserPoolId()` to return both issuer formats + + - Update return type to include `multiRegionIssuer` and `multiRegionJwksUri` + - Keep existing `issuer` and `jwksUri` fields for backward compatibility + - _Requirements: 2.1, 2.2_ + +- [x] 2. Add `parseIssuer()` static method + + - Add new static method to parse and validate Cognito issuer URLs + - Support both standard and multi-region formats + - Return `null` for invalid formats (or throw for strict validation) + - _Requirements: 1.3, 4.3_ + +- [x] 3. Modify constructor to register both issuers + + - [x] 3.1 Update constructor to create two issuer configs per User Pool + - One for standard issuer format + - One for multi-region issuer format + - Both share the same verification properties (clientId, tokenUse, etc.) + - _Requirements: 1.1, 1.2_ + +- [x] 4. Update `cacheJwks()` method + + - Modify to cache JWKS for both issuer formats when a User Pool ID is provided + - Ensure backward compatibility for single User Pool case + - _Requirements: 2.1.1, 2.1.2_ + +- [x] 5. Checkpoint - Verify core functionality + + - Ensure all existing tests pass + - Ensure the library compiles without errors + +- [x] 6. Add unit tests for new functionality + + - [x] 6.1 Test `parseUserPoolId()` returns both formats + + - Test with various valid User Pool IDs + - Test with GovCloud regions + - _Requirements: 2.1, 2.2_ + + - [x] 6.2 Test `parseIssuer()` method + + - Test with valid standard issuer format + - Test with valid multi-region issuer format + - Test with invalid issuer formats + - Test with GovCloud regions + - _Requirements: 1.3, 4.3_ + + - [x] 6.3 Test constructor registers both issuers + - Verify both issuers are in the config map + - Verify single User Pool creates two entries + - Verify multiple User Pools create correct number of entries + - _Requirements: 1.1, 1.2_ + +- [x] 7. Add integration tests for verification flows + + - [x] 7.1 Test verify with standard issuer format + + - Create JWT with standard issuer + - Verify it is accepted + - Verify correct JWKS endpoint is called + - _Requirements: 1.1, 2.1_ + + - [x] 7.2 Test verify with multi-region issuer format + + - Create JWT with multi-region issuer + - Verify it is accepted + - Verify correct JWKS endpoint is called + - _Requirements: 1.2, 2.2_ + + - [x] 7.3 Test rejection of invalid issuer formats + + - Create JWT with invalid issuer + - Verify it is rejected with appropriate error + - _Requirements: 1.3, 4.1, 4.2, 4.3_ + + - [x] 7.4 Test multi-pool configuration + - Configure verifier with multiple User Pools + - Verify tokens from each pool with both formats + - _Requirements: 5.1, 5.2_ + +- [x] 8. Test hydrate() and caching behavior + + - [x] 8.1 Test hydrate() fetches from both endpoints + + - Call hydrate() and verify both JWKS URIs are fetched + - _Requirements: 2.1.3_ + + - [x] 8.2 Test cacheJwks() caches for both issuers + + - Call cacheJwks() with a User Pool ID + - Verify JWKS is cached for both issuer formats + - _Requirements: 2.1.1, 2.1.2_ + + - [x] 8.3 Test cache isolation + - Verify cache miss on one endpoint doesn't affect the other + - _Requirements: 2.1.4_ + +- [x] 9. Add property-based tests + + - [x] 9.1 Property test for issuer format acceptance + + - **Property 1: Both issuer formats are accepted for valid tokens** + - **Validates: Requirements 1.1, 1.2** + + - [x] 9.2 Property test for invalid issuer rejection + + - **Property 2: Invalid issuer formats are rejected** + - **Validates: Requirements 1.3, 4.3** + + - [x] 9.3 Property test for JWKS URI derivation + - **Property 3: JWKS URI is derived from token's issuer** + - **Validates: Requirements 2.1, 2.2** + +- [x] 10. Final checkpoint + - Ensure all tests pass + - Ensure no regressions in existing functionality + - Verify backward compatibility + +## Notes + +- All tests are required for comprehensive coverage +- The implementation leverages existing infrastructure to minimize changes +- Estimated total code changes: ~55 lines in `src/cognito-verifier.ts` +- All existing tests should continue to pass without modification diff --git a/package-lock.json b/package-lock.json index ad118e2..6b2d2dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@typescript-eslint/parser": "^8.22.0", "eslint": "^9.19.0", "eslint-plugin-security": "^3.0.1", + "fast-check": "^4.5.3", "globals": "^15.14.0", "jest": "^29.7.0", "jest-junit": "^16.0.0", @@ -2724,6 +2725,46 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/fast-check": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.5.3.tgz", + "integrity": "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^7.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/fast-check/node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/package.json b/package.json index b93207e..227686d 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "@typescript-eslint/parser": "^8.22.0", "eslint": "^9.19.0", "eslint-plugin-security": "^3.0.1", + "fast-check": "^4.5.3", "globals": "^15.14.0", "jest": "^29.7.0", "jest-junit": "^16.0.0", diff --git a/src/cognito-verifier.ts b/src/cognito-verifier.ts index f45c9dd..dabb835 100644 --- a/src/cognito-verifier.ts +++ b/src/cognito-verifier.ts @@ -223,33 +223,59 @@ export class CognitoJwtVerifier< > extends JwtVerifierBase { private static USER_POOL_ID_REGEX = /^(?[a-z]{2}-(gov-)?[a-z]+-\d)_[a-zA-Z0-9]+$/; + + // Regex for standard issuer format: https://cognito-idp.{region}.amazonaws.com/{userPoolId} + private static STANDARD_ISSUER_REGEX = + /^https:\/\/cognito-idp\.(?[a-z]{2}-(gov-)?[a-z]+-\d)\.amazonaws\.com\/(?[a-z]{2}-(gov-)?[a-z]+-\d_[a-zA-Z0-9]+)$/; + + // Regex for multi-region issuer format: https://issuer.cognito-idp.{region}.amazonaws.com/{userPoolId} + private static MULTI_REGION_ISSUER_REGEX = + /^https:\/\/issuer\.cognito-idp\.(?[a-z]{2}-(gov-)?[a-z]+-\d)\.amazonaws\.com\/(?[a-z]{2}-(gov-)?[a-z]+-\d_[a-zA-Z0-9]+)$/; private constructor( props: CognitoJwtVerifierProperties | CognitoJwtVerifierMultiProperties[], jwksCache?: JwksCache ) { - const issuerConfig = Array.isArray(props) - ? (props.map((p) => ({ - ...p, - ...CognitoJwtVerifier.parseUserPoolId(p.userPoolId), - audience: null, // checked instead by validateCognitoJwtFields - })) as IssuerConfig[]) - : ({ - ...props, - ...CognitoJwtVerifier.parseUserPoolId(props.userPoolId), - audience: null, // checked instead by validateCognitoJwtFields - } as IssuerConfig); - super(issuerConfig, jwksCache); + // Normalize to array for uniform processing + const propsArray = Array.isArray(props) ? props : [props]; + const issuerConfigs: IssuerConfig[] = []; + + for (const p of propsArray) { + const parsed = CognitoJwtVerifier.parseUserPoolId(p.userPoolId); + const baseConfig = { + ...p, + userPoolId: p.userPoolId, + audience: null, // checked instead by validateCognitoJwtFields + }; + + // Register standard issuer config + issuerConfigs.push({ + ...baseConfig, + issuer: parsed.issuer, + jwksUri: parsed.jwksUri, + } as IssuerConfig); + + // Register multi-region issuer config + issuerConfigs.push({ + ...baseConfig, + issuer: parsed.multiRegionIssuer, + jwksUri: parsed.multiRegionJwksUri, + } as IssuerConfig); + } + + super(issuerConfigs, jwksCache); } /** - * Parse a User Pool ID, to extract the issuer and JWKS URI + * Parse a User Pool ID, to extract the issuer and JWKS URI for both standard and multi-region formats * * @param userPoolId The User Pool ID - * @returns The issuer and JWKS URI for the User Pool + * @returns The issuer and JWKS URI for both standard and multi-region formats */ public static parseUserPoolId(userPoolId: string): { issuer: string; jwksUri: string; + multiRegionIssuer: string; + multiRegionJwksUri: string; } { const match = userPoolId.match(this.USER_POOL_ID_REGEX); if (!match) { @@ -258,13 +284,61 @@ export class CognitoJwtVerifier< ); } const region = match.groups!.region; - const issuer = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`; + const standardIssuer = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`; + const multiRegionIssuer = `https://issuer.cognito-idp.${region}.amazonaws.com/${userPoolId}`; return { - issuer, - jwksUri: `${issuer}/.well-known/jwks.json`, + issuer: standardIssuer, + jwksUri: `${standardIssuer}/.well-known/jwks.json`, + multiRegionIssuer, + multiRegionJwksUri: `${multiRegionIssuer}/.well-known/jwks.json`, }; } + /** + * Parse a Cognito issuer URL to extract the User Pool ID and determine the format. + * Supports both standard and multi-region issuer formats. + * + * @param issuer The issuer URL from a JWT's iss claim + * @returns An object containing the userPoolId, region, and format, or null if the issuer is invalid + */ + public static parseIssuer(issuer: string): { + userPoolId: string; + region: string; + format: "standard" | "multiRegion"; + } | null { + // Try standard format: https://cognito-idp.{region}.amazonaws.com/{userPoolId} + const standardMatch = issuer.match(this.STANDARD_ISSUER_REGEX); + if (standardMatch && standardMatch.groups) { + const region = standardMatch.groups.region; + const userPoolId = standardMatch.groups.userPoolId; + // Validate that the region in the issuer matches the region in the userPoolId + if (userPoolId.startsWith(`${region}_`)) { + return { + region, + userPoolId, + format: "standard", + }; + } + } + + // Try multi-region format: https://issuer.cognito-idp.{region}.amazonaws.com/{userPoolId} + const multiRegionMatch = issuer.match(this.MULTI_REGION_ISSUER_REGEX); + if (multiRegionMatch && multiRegionMatch.groups) { + const region = multiRegionMatch.groups.region; + const userPoolId = multiRegionMatch.groups.userPoolId; + // Validate that the region in the issuer matches the region in the userPoolId + if (userPoolId.startsWith(`${region}_`)) { + return { + region, + userPoolId, + format: "multiRegion", + }; + } + } + + return null; + } + /** * Create a Cognito JWT verifier for a single User Pool * @@ -378,13 +452,36 @@ export class CognitoJwtVerifier< ? [jwks: Jwks, userPoolId?: string] : [jwks: Jwks, userPoolId: string] ): void { - let issuer: string | undefined; + // Get unique User Pool IDs from the issuer configs + const uniqueUserPoolIds = new Set(); + for (const config of this.issuersConfig.values()) { + if ((config as IssuerConfig & { userPoolId: string }).userPoolId) { + uniqueUserPoolIds.add( + (config as IssuerConfig & { userPoolId: string }).userPoolId + ); + } + } + + // Determine which User Pool to cache for + let targetUserPoolId: string; if (userPoolId !== undefined) { - issuer = CognitoJwtVerifier.parseUserPoolId(userPoolId).issuer; - } else if (Array.from(this.issuersConfig).length > 1) { + targetUserPoolId = userPoolId; + } else if (uniqueUserPoolIds.size > 1) { throw new ParameterValidationError("userPoolId must be provided"); + } else if (uniqueUserPoolIds.size === 1) { + targetUserPoolId = uniqueUserPoolIds.values().next().value!; + } else { + throw new ParameterValidationError("No User Pool configured"); } - const issuerConfig = this.getIssuerConfig(issuer); - super.cacheJwks(jwks, issuerConfig.issuer); + + // Parse the User Pool ID to get both issuer formats + const parsed = CognitoJwtVerifier.parseUserPoolId(targetUserPoolId); + + // Cache JWKS for both standard and multi-region issuers + const standardConfig = this.getIssuerConfig(parsed.issuer); + const multiRegionConfig = this.getIssuerConfig(parsed.multiRegionIssuer); + + super.cacheJwks(jwks, standardConfig.issuer); + super.cacheJwks(jwks, multiRegionConfig.issuer); } } diff --git a/tests/unit/cognito-verifier.property.test.ts b/tests/unit/cognito-verifier.property.test.ts new file mode 100644 index 0000000..3464f65 --- /dev/null +++ b/tests/unit/cognito-verifier.property.test.ts @@ -0,0 +1,355 @@ +/** + * Property-based tests for Cognito Multi-Issuer Format Support + * + * These tests validate the correctness properties defined in the design document + * using fast-check for property-based testing. + */ + +import * as fc from "fast-check"; +import { + generateKeyPair, + signJwt, + disallowAllRealNetworkTraffic, + allowAllRealNetworkTraffic, +} from "./test-util"; +import { CognitoJwtVerifier } from "../../src/cognito-verifier"; +import { ParameterValidationError } from "../../src/error"; + +// Valid AWS regions for generating test data +const VALID_REGIONS = [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-central-1", + "ap-northeast-1", + "ap-northeast-2", + "ap-southeast-1", + "ap-southeast-2", + "sa-east-1", + "ca-central-1", + "us-gov-west-1", + "us-gov-east-1", +]; + +// Arbitrary for generating valid AWS regions +const regionArbitrary = fc.constantFrom(...VALID_REGIONS); + +// Arbitrary for generating valid User Pool ID suffixes (alphanumeric) +const poolSuffixArbitrary = fc.string({ + minLength: 1, + maxLength: 20, + unit: fc.constantFrom( + ..."ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + ), +}); + +// Arbitrary for generating valid User Pool IDs +const userPoolIdArbitrary = fc + .tuple(regionArbitrary, poolSuffixArbitrary) + .map(([region, suffix]) => `${region}_${suffix}`); + +// Arbitrary for issuer format selection +const issuerFormatArbitrary = fc.constantFrom( + "standard", + "multiRegion" +) as fc.Arbitrary<"standard" | "multiRegion">; + +// Arbitrary for token use +const tokenUseArbitrary = fc.constantFrom("access", "id") as fc.Arbitrary< + "access" | "id" +>; + +// Arbitrary for generating invalid issuer prefixes +const invalidPrefixArbitrary = fc.constantFrom( + "https://invalid.cognito-idp.", + "https://wrong.cognito-idp.", + "https://fake.cognito-idp.", + "https://other.cognito-idp.", + "http://cognito-idp.", // http instead of https + "https://example.com/", + "https://auth.example.com/", + "https://cognito.", + "https://idp." +); + +describe("unit tests property-based - cognito multi-issuer format", () => { + let keypair: ReturnType; + + beforeAll(() => { + keypair = generateKeyPair(); + disallowAllRealNetworkTraffic(); + }); + + afterAll(() => { + allowAllRealNetworkTraffic(); + }); + + /** + * Property 1: Both issuer formats are accepted for valid tokens + * **Validates: Requirements 1.1, 1.2** + * + * For any valid JWT signed by a configured User Pool, if the issuer matches + * either the standard format or the multi-region format, the CognitoJwtVerifier + * should accept the token. + */ + describe("Property 1: Both issuer formats are accepted for valid tokens", () => { + test("Feature: cognito-multi-issuer-format, Property 1: For any valid User Pool ID and either issuer format, the verifier accepts the token", () => { + fc.assert( + fc.property( + userPoolIdArbitrary, + issuerFormatArbitrary, + tokenUseArbitrary, + (userPoolId, issuerFormat, tokenUse) => { + // Parse the User Pool ID to get both issuer formats + const parsed = CognitoJwtVerifier.parseUserPoolId(userPoolId); + + // Select the issuer based on the format + const issuer = + issuerFormat === "standard" + ? parsed.issuer + : parsed.multiRegionIssuer; + + // Create a verifier for this User Pool + const verifier = CognitoJwtVerifier.create({ + userPoolId, + clientId: null, + tokenUse: null, + }); + + // Cache the JWKS + verifier.cacheJwks(keypair.jwks); + + // Create a valid JWT with the selected issuer format + const signedJwt = signJwt( + { kid: keypair.jwk.kid }, + { + iss: issuer, + token_use: tokenUse, + }, + keypair.privateKey + ); + + // Verify the token - should not throw + const result = verifier.verifySync(signedJwt); + + // Assert the token was accepted with the correct issuer + return result.iss === issuer; + } + ), + { numRuns: 100 } + ); + }); + }); + + /** + * Property 2: Invalid issuer formats are rejected + * **Validates: Requirements 1.3, 4.3** + * + * For any JWT with an issuer that does not match either the standard or + * multi-region Cognito issuer format for the configured User Pool ID, + * the CognitoJwtVerifier should reject the token with a ParameterValidationError. + */ + describe("Property 2: Invalid issuer formats are rejected", () => { + test("Feature: cognito-multi-issuer-format, Property 2: For any invalid issuer format, the verifier rejects the token", () => { + fc.assert( + fc.property( + userPoolIdArbitrary, + invalidPrefixArbitrary, + regionArbitrary, + tokenUseArbitrary, + (userPoolId, invalidPrefix, region, tokenUse) => { + // Create a verifier for this User Pool + const verifier = CognitoJwtVerifier.create({ + userPoolId, + clientId: null, + tokenUse: null, + }); + + // Cache the JWKS + verifier.cacheJwks(keypair.jwks); + + // Create an invalid issuer using the invalid prefix + const invalidIssuer = `${invalidPrefix}${region}.amazonaws.com/${userPoolId}`; + + // Create a JWT with the invalid issuer + const signedJwt = signJwt( + { kid: keypair.jwk.kid }, + { + iss: invalidIssuer, + token_use: tokenUse, + }, + keypair.privateKey + ); + + // Verify the token - should throw ParameterValidationError + try { + verifier.verifySync(signedJwt); + return false; // Should have thrown + } catch (error) { + return ( + error instanceof ParameterValidationError && + (error.message.includes("issuer not configured") || + error.message.includes("Invalid")) + ); + } + } + ), + { numRuns: 100 } + ); + }); + + test("Feature: cognito-multi-issuer-format, Property 2: For any mismatched User Pool ID in issuer, the verifier rejects the token", () => { + fc.assert( + fc.property( + userPoolIdArbitrary, + userPoolIdArbitrary, + issuerFormatArbitrary, + tokenUseArbitrary, + (configuredPoolId, differentPoolId, issuerFormat, tokenUse) => { + // Skip if the pool IDs happen to be the same + if (configuredPoolId === differentPoolId) { + return true; + } + + // Create a verifier for the configured User Pool + const verifier = CognitoJwtVerifier.create({ + userPoolId: configuredPoolId, + clientId: null, + tokenUse: null, + }); + + // Cache the JWKS + verifier.cacheJwks(keypair.jwks); + + // Parse the different pool ID to get its issuer + const parsed = CognitoJwtVerifier.parseUserPoolId(differentPoolId); + const issuer = + issuerFormat === "standard" + ? parsed.issuer + : parsed.multiRegionIssuer; + + // Create a JWT with the different pool's issuer + const signedJwt = signJwt( + { kid: keypair.jwk.kid }, + { + iss: issuer, + token_use: tokenUse, + }, + keypair.privateKey + ); + + // Verify the token - should throw ParameterValidationError + try { + verifier.verifySync(signedJwt); + return false; // Should have thrown + } catch (error) { + return ( + error instanceof ParameterValidationError && + error.message.includes("issuer not configured") + ); + } + } + ), + { numRuns: 100 } + ); + }); + }); + + /** + * Property 3: JWKS URI is derived from token's issuer + * **Validates: Requirements 2.1, 2.2** + * + * For any JWT being verified, the JWKS URI used for fetching keys should match + * the issuer's domain (standard or multi-region), ensuring the correct endpoint is used. + */ + describe("Property 3: JWKS URI is derived from token's issuer", () => { + test("Feature: cognito-multi-issuer-format, Property 3: For any User Pool ID, parseUserPoolId returns correct JWKS URIs for both formats", () => { + fc.assert( + fc.property(userPoolIdArbitrary, (userPoolId) => { + // Parse the User Pool ID + const parsed = CognitoJwtVerifier.parseUserPoolId(userPoolId); + + // Extract region from User Pool ID + const region = userPoolId.split("_")[0]; + + // Verify standard JWKS URI is derived correctly from standard issuer + const expectedStandardJwksUri = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}/.well-known/jwks.json`; + const standardJwksUriMatchesIssuer = + parsed.jwksUri === expectedStandardJwksUri && + parsed.jwksUri === `${parsed.issuer}/.well-known/jwks.json`; + + // Verify multi-region JWKS URI is derived correctly from multi-region issuer + const expectedMultiRegionJwksUri = `https://issuer.cognito-idp.${region}.amazonaws.com/${userPoolId}/.well-known/jwks.json`; + const multiRegionJwksUriMatchesIssuer = + parsed.multiRegionJwksUri === expectedMultiRegionJwksUri && + parsed.multiRegionJwksUri === + `${parsed.multiRegionIssuer}/.well-known/jwks.json`; + + return ( + standardJwksUriMatchesIssuer && multiRegionJwksUriMatchesIssuer + ); + }), + { numRuns: 100 } + ); + }); + + test("Feature: cognito-multi-issuer-format, Property 3: For any issuer format, the verifier uses the correct JWKS URI", () => { + fc.assert( + fc.property( + userPoolIdArbitrary, + issuerFormatArbitrary, + tokenUseArbitrary, + (userPoolId, issuerFormat, tokenUse) => { + // Parse the User Pool ID to get both issuer formats + const parsed = CognitoJwtVerifier.parseUserPoolId(userPoolId); + + // Select the issuer and expected JWKS URI based on the format + const issuer = + issuerFormat === "standard" + ? parsed.issuer + : parsed.multiRegionIssuer; + const expectedJwksUri = + issuerFormat === "standard" + ? parsed.jwksUri + : parsed.multiRegionJwksUri; + + // Verify the JWKS URI is derived from the issuer + const jwksUriDerivedFromIssuer = + expectedJwksUri === `${issuer}/.well-known/jwks.json`; + + // Create a verifier for this User Pool + const verifier = CognitoJwtVerifier.create({ + userPoolId, + clientId: null, + tokenUse: null, + }); + + // Cache the JWKS + verifier.cacheJwks(keypair.jwks); + + // Create a valid JWT with the selected issuer format + const signedJwt = signJwt( + { kid: keypair.jwk.kid }, + { + iss: issuer, + token_use: tokenUse, + }, + keypair.privateKey + ); + + // Verify the token - should succeed + const result = verifier.verifySync(signedJwt); + + // The token should be verified successfully, confirming the correct JWKS URI was used + return jwksUriDerivedFromIssuer && result.iss === issuer; + } + ), + { numRuns: 100 } + ); + }); + }); +}); diff --git a/tests/unit/cognito-verifier.test.ts b/tests/unit/cognito-verifier.test.ts index a06378b..5bd7b89 100644 --- a/tests/unit/cognito-verifier.test.ts +++ b/tests/unit/cognito-verifier.test.ts @@ -3,6 +3,7 @@ import { signJwt, allowAllRealNetworkTraffic, disallowAllRealNetworkTraffic, + mockHttpsUri, } from "./test-util"; import { decomposeUnverifiedJwt } from "../../src/jwt"; import { JwksCache, Jwks } from "../../src/jwk"; @@ -414,6 +415,262 @@ describe("unit tests cognito verifier", () => { ).toThrow("Invalid Cognito User Pool ID"); }); }); + + describe("parseUserPoolId", () => { + test("returns both standard and multi-region issuer formats", () => { + const userPoolId = "us-east-1_abc123"; + const result = CognitoJwtVerifier.parseUserPoolId(userPoolId); + + expect(result.issuer).toBe( + "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_abc123" + ); + expect(result.jwksUri).toBe( + "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_abc123/.well-known/jwks.json" + ); + expect(result.multiRegionIssuer).toBe( + "https://issuer.cognito-idp.us-east-1.amazonaws.com/us-east-1_abc123" + ); + expect(result.multiRegionJwksUri).toBe( + "https://issuer.cognito-idp.us-east-1.amazonaws.com/us-east-1_abc123/.well-known/jwks.json" + ); + }); + + test("handles various valid User Pool IDs", () => { + const testCases = [ + { + userPoolId: "us-west-2_XYZ789", + expectedRegion: "us-west-2", + }, + { + userPoolId: "eu-west-1_TestPool", + expectedRegion: "eu-west-1", + }, + { + userPoolId: "ap-southeast-1_Pool123", + expectedRegion: "ap-southeast-1", + }, + ]; + + for (const { userPoolId, expectedRegion } of testCases) { + const result = CognitoJwtVerifier.parseUserPoolId(userPoolId); + + expect(result.issuer).toBe( + `https://cognito-idp.${expectedRegion}.amazonaws.com/${userPoolId}` + ); + expect(result.multiRegionIssuer).toBe( + `https://issuer.cognito-idp.${expectedRegion}.amazonaws.com/${userPoolId}` + ); + } + }); + + test("handles GovCloud regions", () => { + const userPoolId = "us-gov-west-1_GovPool123"; + const result = CognitoJwtVerifier.parseUserPoolId(userPoolId); + + expect(result.issuer).toBe( + "https://cognito-idp.us-gov-west-1.amazonaws.com/us-gov-west-1_GovPool123" + ); + expect(result.jwksUri).toBe( + "https://cognito-idp.us-gov-west-1.amazonaws.com/us-gov-west-1_GovPool123/.well-known/jwks.json" + ); + expect(result.multiRegionIssuer).toBe( + "https://issuer.cognito-idp.us-gov-west-1.amazonaws.com/us-gov-west-1_GovPool123" + ); + expect(result.multiRegionJwksUri).toBe( + "https://issuer.cognito-idp.us-gov-west-1.amazonaws.com/us-gov-west-1_GovPool123/.well-known/jwks.json" + ); + }); + }); + + describe("parseIssuer", () => { + test("parses valid standard issuer format", () => { + const issuer = + "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_abc123"; + const result = CognitoJwtVerifier.parseIssuer(issuer); + + expect(result).not.toBeNull(); + expect(result!.userPoolId).toBe("us-east-1_abc123"); + expect(result!.region).toBe("us-east-1"); + expect(result!.format).toBe("standard"); + }); + + test("parses valid multi-region issuer format", () => { + const issuer = + "https://issuer.cognito-idp.us-east-1.amazonaws.com/us-east-1_abc123"; + const result = CognitoJwtVerifier.parseIssuer(issuer); + + expect(result).not.toBeNull(); + expect(result!.userPoolId).toBe("us-east-1_abc123"); + expect(result!.region).toBe("us-east-1"); + expect(result!.format).toBe("multiRegion"); + }); + + test("returns null for invalid issuer formats", () => { + const invalidIssuers = [ + "https://invalid.cognito-idp.us-east-1.amazonaws.com/us-east-1_abc123", + "https://cognito-idp.invalid-region.amazonaws.com/us-east-1_abc123", + "https://cognito-idp.us-east-1.amazonaws.com/invalid_pool", + "http://cognito-idp.us-east-1.amazonaws.com/us-east-1_abc123", // http instead of https + "https://cognito-idp.us-east-1.amazonaws.com", // missing user pool id + "https://example.com/us-east-1_abc123", // wrong domain + "", // empty string + "not-a-url", // not a URL + ]; + + for (const issuer of invalidIssuers) { + expect(CognitoJwtVerifier.parseIssuer(issuer)).toBeNull(); + } + }); + + test("returns null when region in issuer does not match region in userPoolId", () => { + // Region mismatch: issuer says us-east-1 but userPoolId says us-west-2 + const issuer = + "https://cognito-idp.us-east-1.amazonaws.com/us-west-2_abc123"; + const result = CognitoJwtVerifier.parseIssuer(issuer); + + expect(result).toBeNull(); + }); + + test("parses GovCloud standard issuer format", () => { + const issuer = + "https://cognito-idp.us-gov-west-1.amazonaws.com/us-gov-west-1_GovPool123"; + const result = CognitoJwtVerifier.parseIssuer(issuer); + + expect(result).not.toBeNull(); + expect(result!.userPoolId).toBe("us-gov-west-1_GovPool123"); + expect(result!.region).toBe("us-gov-west-1"); + expect(result!.format).toBe("standard"); + }); + + test("parses GovCloud multi-region issuer format", () => { + const issuer = + "https://issuer.cognito-idp.us-gov-west-1.amazonaws.com/us-gov-west-1_GovPool123"; + const result = CognitoJwtVerifier.parseIssuer(issuer); + + expect(result).not.toBeNull(); + expect(result!.userPoolId).toBe("us-gov-west-1_GovPool123"); + expect(result!.region).toBe("us-gov-west-1"); + expect(result!.format).toBe("multiRegion"); + }); + }); + + describe("constructor registers both issuers", () => { + test("single User Pool creates entries for both issuer formats", () => { + const userPoolId = "us-east-1_abc123"; + const verifier = CognitoJwtVerifier.create({ + userPoolId, + clientId: null, + tokenUse: null, + }); + + // Verify both issuers work by caching JWKS and checking that + // tokens with either issuer format can be verified + verifier.cacheJwks(keypair.jwks); + + // Create and verify token with standard issuer + const standardIssuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const standardJwt = signJwt( + { kid: keypair.jwk.kid }, + { + iss: standardIssuer, + token_use: "access", + }, + keypair.privateKey + ); + expect(verifier.verifySync(standardJwt)).toMatchObject({ + iss: standardIssuer, + }); + + // Create and verify token with multi-region issuer + const multiRegionIssuer = `https://issuer.cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const multiRegionJwt = signJwt( + { kid: keypair.jwk.kid }, + { + iss: multiRegionIssuer, + token_use: "access", + }, + keypair.privateKey + ); + expect(verifier.verifySync(multiRegionJwt)).toMatchObject({ + iss: multiRegionIssuer, + }); + }); + + test("multiple User Pools create entries for all issuer formats", () => { + const userPool1 = "us-east-1_pool1"; + const userPool2 = "us-west-2_pool2"; + + const verifier = CognitoJwtVerifier.create([ + { userPoolId: userPool1, clientId: null, tokenUse: null }, + { userPoolId: userPool2, clientId: null, tokenUse: null }, + ]); + + // Cache JWKS for both pools + verifier.cacheJwks(keypair.jwks, userPool1); + verifier.cacheJwks(keypair.jwks, userPool2); + + // Test all four issuer combinations (2 pools × 2 formats) + const testCases = [ + { + userPoolId: userPool1, + issuer: `https://cognito-idp.us-east-1.amazonaws.com/${userPool1}`, + }, + { + userPoolId: userPool1, + issuer: `https://issuer.cognito-idp.us-east-1.amazonaws.com/${userPool1}`, + }, + { + userPoolId: userPool2, + issuer: `https://cognito-idp.us-west-2.amazonaws.com/${userPool2}`, + }, + { + userPoolId: userPool2, + issuer: `https://issuer.cognito-idp.us-west-2.amazonaws.com/${userPool2}`, + }, + ]; + + for (const { issuer } of testCases) { + const jwt = signJwt( + { kid: keypair.jwk.kid }, + { + iss: issuer, + token_use: "access", + }, + keypair.privateKey + ); + expect(verifier.verifySync(jwt)).toMatchObject({ iss: issuer }); + } + }); + + test("rejects token with issuer not matching any configured User Pool", () => { + const userPoolId = "us-east-1_abc123"; + const verifier = CognitoJwtVerifier.create({ + userPoolId, + clientId: null, + tokenUse: null, + }); + verifier.cacheJwks(keypair.jwks); + + // Create token with a different User Pool's issuer + const wrongIssuer = + "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_different"; + const jwt = signJwt( + { kid: keypair.jwk.kid }, + { + iss: wrongIssuer, + token_use: "access", + }, + keypair.privateKey + ); + + expect(() => verifier.verifySync(jwt)).toThrow( + ParameterValidationError + ); + expect(() => verifier.verifySync(jwt)).toThrow( + `issuer not configured: ${wrongIssuer}` + ); + }); + }); }); describe("CognitoJwtVerifier with multiple user pools", () => { @@ -534,3 +791,909 @@ describe("unit tests cognito verifier", () => { }); }); }); + +describe("integration tests - verification flows", () => { + let keypair: ReturnType; + + beforeAll(() => { + keypair = generateKeyPair(); + disallowAllRealNetworkTraffic(); + }); + + afterAll(() => { + allowAllRealNetworkTraffic(); + }); + + afterEach(() => { + // Clean up any pending mocks + jest.clearAllMocks(); + }); + + describe("verify with standard issuer format", () => { + test("accepts JWT with standard issuer and fetches from correct JWKS endpoint", async () => { + const userPoolId = "us-east-1_testPool"; + const { issuer, jwksUri } = + CognitoJwtVerifier.parseUserPoolId(userPoolId); + + // Mock the standard JWKS endpoint + mockHttpsUri(jwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(keypair.jwks), + }); + + const verifier = CognitoJwtVerifier.create({ + userPoolId, + clientId: "testClient", + tokenUse: "access", + }); + + const signedJwt = signJwt( + { kid: keypair.jwk.kid }, + { + iss: issuer, + token_use: "access", + client_id: "testClient", + }, + keypair.privateKey + ); + + const result = await verifier.verify(signedJwt); + + expect(result).toMatchObject({ + iss: issuer, + token_use: "access", + client_id: "testClient", + }); + }); + + test("accepts ID token with standard issuer format", async () => { + const userPoolId = "us-east-1_idTokenPool"; + const { issuer, jwksUri } = + CognitoJwtVerifier.parseUserPoolId(userPoolId); + + mockHttpsUri(jwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(keypair.jwks), + }); + + const verifier = CognitoJwtVerifier.create({ + userPoolId, + clientId: "idClient", + tokenUse: "id", + }); + + const signedJwt = signJwt( + { kid: keypair.jwk.kid }, + { + iss: issuer, + token_use: "id", + aud: "idClient", + }, + keypair.privateKey + ); + + const result = await verifier.verify(signedJwt); + + expect(result).toMatchObject({ + iss: issuer, + token_use: "id", + aud: "idClient", + }); + }); + }); + + describe("verify with multi-region issuer format", () => { + test("accepts JWT with multi-region issuer and fetches from correct JWKS endpoint", async () => { + const userPoolId = "us-east-1_multiRegion"; + const { multiRegionIssuer, multiRegionJwksUri } = + CognitoJwtVerifier.parseUserPoolId(userPoolId); + + // Mock the multi-region JWKS endpoint + mockHttpsUri(multiRegionJwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(keypair.jwks), + }); + + const verifier = CognitoJwtVerifier.create({ + userPoolId, + clientId: "multiRegionClient", + tokenUse: "access", + }); + + const signedJwt = signJwt( + { kid: keypair.jwk.kid }, + { + iss: multiRegionIssuer, + token_use: "access", + client_id: "multiRegionClient", + }, + keypair.privateKey + ); + + const result = await verifier.verify(signedJwt); + + expect(result).toMatchObject({ + iss: multiRegionIssuer, + token_use: "access", + client_id: "multiRegionClient", + }); + }); + + test("accepts ID token with multi-region issuer format", async () => { + const userPoolId = "eu-west-1_multiRegionId"; + const { multiRegionIssuer, multiRegionJwksUri } = + CognitoJwtVerifier.parseUserPoolId(userPoolId); + + mockHttpsUri(multiRegionJwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(keypair.jwks), + }); + + const verifier = CognitoJwtVerifier.create({ + userPoolId, + clientId: "multiIdClient", + tokenUse: "id", + }); + + const signedJwt = signJwt( + { kid: keypair.jwk.kid }, + { + iss: multiRegionIssuer, + token_use: "id", + aud: "multiIdClient", + }, + keypair.privateKey + ); + + const result = await verifier.verify(signedJwt); + + expect(result).toMatchObject({ + iss: multiRegionIssuer, + token_use: "id", + aud: "multiIdClient", + }); + }); + }); + + describe("rejection of invalid issuer formats", () => { + test("rejects JWT with invalid issuer domain", async () => { + const userPoolId = "us-east-1_validPool"; + const verifier = CognitoJwtVerifier.create({ + userPoolId, + clientId: null, + tokenUse: null, + }); + verifier.cacheJwks(keypair.jwks); + + // Create JWT with invalid issuer domain + const invalidIssuer = + "https://invalid.cognito-idp.us-east-1.amazonaws.com/us-east-1_validPool"; + const signedJwt = signJwt( + { kid: keypair.jwk.kid }, + { + iss: invalidIssuer, + token_use: "access", + }, + keypair.privateKey + ); + + await expect(verifier.verify(signedJwt)).rejects.toThrow( + ParameterValidationError + ); + await expect(verifier.verify(signedJwt)).rejects.toThrow( + `issuer not configured: ${invalidIssuer}` + ); + }); + + test("rejects JWT with mismatched User Pool ID in issuer", async () => { + const userPoolId = "us-east-1_configuredPool"; + const verifier = CognitoJwtVerifier.create({ + userPoolId, + clientId: null, + tokenUse: null, + }); + verifier.cacheJwks(keypair.jwks); + + // Create JWT with different User Pool ID in issuer + const wrongPoolIssuer = + "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_differentPool"; + const signedJwt = signJwt( + { kid: keypair.jwk.kid }, + { + iss: wrongPoolIssuer, + token_use: "access", + }, + keypair.privateKey + ); + + await expect(verifier.verify(signedJwt)).rejects.toThrow( + ParameterValidationError + ); + await expect(verifier.verify(signedJwt)).rejects.toThrow( + `issuer not configured: ${wrongPoolIssuer}` + ); + }); + + test("rejects JWT with mismatched region in issuer", async () => { + const userPoolId = "us-east-1_regionPool"; + const verifier = CognitoJwtVerifier.create({ + userPoolId, + clientId: null, + tokenUse: null, + }); + verifier.cacheJwks(keypair.jwks); + + // Create JWT with different region in issuer + const wrongRegionIssuer = + "https://cognito-idp.us-west-2.amazonaws.com/us-east-1_regionPool"; + const signedJwt = signJwt( + { kid: keypair.jwk.kid }, + { + iss: wrongRegionIssuer, + token_use: "access", + }, + keypair.privateKey + ); + + await expect(verifier.verify(signedJwt)).rejects.toThrow( + ParameterValidationError + ); + await expect(verifier.verify(signedJwt)).rejects.toThrow( + `issuer not configured: ${wrongRegionIssuer}` + ); + }); + + test("rejects JWT with non-Cognito issuer", async () => { + const userPoolId = "us-east-1_cognitoPool"; + const verifier = CognitoJwtVerifier.create({ + userPoolId, + clientId: null, + tokenUse: null, + }); + verifier.cacheJwks(keypair.jwks); + + // Create JWT with completely different issuer + const nonCognitoIssuer = "https://example.com/us-east-1_cognitoPool"; + const signedJwt = signJwt( + { kid: keypair.jwk.kid }, + { + iss: nonCognitoIssuer, + token_use: "access", + }, + keypair.privateKey + ); + + await expect(verifier.verify(signedJwt)).rejects.toThrow( + ParameterValidationError + ); + await expect(verifier.verify(signedJwt)).rejects.toThrow( + `issuer not configured: ${nonCognitoIssuer}` + ); + }); + }); + + describe("multi-pool configuration", () => { + test("verifies tokens from multiple User Pools with standard issuer format", async () => { + const pool1 = "us-east-1_pool1"; + const pool2 = "us-west-2_pool2"; + + const keypair1 = generateKeyPair({ kty: "RSA", kid: "key1" }); + const keypair2 = generateKeyPair({ kty: "RSA", kid: "key2" }); + + const parsed1 = CognitoJwtVerifier.parseUserPoolId(pool1); + const parsed2 = CognitoJwtVerifier.parseUserPoolId(pool2); + + // Mock JWKS endpoints for both pools (standard format) + mockHttpsUri(parsed1.jwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(keypair1.jwks), + }); + mockHttpsUri(parsed2.jwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(keypair2.jwks), + }); + + const verifier = CognitoJwtVerifier.create([ + { userPoolId: pool1, clientId: "client1", tokenUse: "access" }, + { userPoolId: pool2, clientId: "client2", tokenUse: "access" }, + ]); + + // Verify token from pool1 + const jwt1 = signJwt( + { kid: keypair1.jwk.kid }, + { + iss: parsed1.issuer, + token_use: "access", + client_id: "client1", + }, + keypair1.privateKey + ); + const result1 = await verifier.verify(jwt1); + expect(result1).toMatchObject({ iss: parsed1.issuer }); + + // Verify token from pool2 + const jwt2 = signJwt( + { kid: keypair2.jwk.kid }, + { + iss: parsed2.issuer, + token_use: "access", + client_id: "client2", + }, + keypair2.privateKey + ); + const result2 = await verifier.verify(jwt2); + expect(result2).toMatchObject({ iss: parsed2.issuer }); + }); + + test("verifies tokens from multiple User Pools with multi-region issuer format", async () => { + const pool1 = "us-east-1_multiPool1"; + const pool2 = "eu-west-1_multiPool2"; + + const keypair1 = generateKeyPair({ kty: "RSA", kid: "multiKey1" }); + const keypair2 = generateKeyPair({ kty: "RSA", kid: "multiKey2" }); + + const parsed1 = CognitoJwtVerifier.parseUserPoolId(pool1); + const parsed2 = CognitoJwtVerifier.parseUserPoolId(pool2); + + // Mock JWKS endpoints for both pools (multi-region format) + mockHttpsUri(parsed1.multiRegionJwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(keypair1.jwks), + }); + mockHttpsUri(parsed2.multiRegionJwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(keypair2.jwks), + }); + + const verifier = CognitoJwtVerifier.create([ + { userPoolId: pool1, clientId: "multiClient1", tokenUse: "access" }, + { userPoolId: pool2, clientId: "multiClient2", tokenUse: "access" }, + ]); + + // Verify token from pool1 with multi-region issuer + const jwt1 = signJwt( + { kid: keypair1.jwk.kid }, + { + iss: parsed1.multiRegionIssuer, + token_use: "access", + client_id: "multiClient1", + }, + keypair1.privateKey + ); + const result1 = await verifier.verify(jwt1); + expect(result1).toMatchObject({ iss: parsed1.multiRegionIssuer }); + + // Verify token from pool2 with multi-region issuer + const jwt2 = signJwt( + { kid: keypair2.jwk.kid }, + { + iss: parsed2.multiRegionIssuer, + token_use: "access", + client_id: "multiClient2", + }, + keypair2.privateKey + ); + const result2 = await verifier.verify(jwt2); + expect(result2).toMatchObject({ iss: parsed2.multiRegionIssuer }); + }); + + test("verifies tokens with mixed issuer formats from multiple pools", async () => { + const pool1 = "us-east-1_mixedPool1"; + const pool2 = "ap-southeast-1_mixedPool2"; + + const keypair1 = generateKeyPair({ kty: "RSA", kid: "mixedKey1" }); + const keypair2 = generateKeyPair({ kty: "RSA", kid: "mixedKey2" }); + + const parsed1 = CognitoJwtVerifier.parseUserPoolId(pool1); + const parsed2 = CognitoJwtVerifier.parseUserPoolId(pool2); + + // Mock JWKS endpoints - pool1 uses standard, pool2 uses multi-region + mockHttpsUri(parsed1.jwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(keypair1.jwks), + }); + mockHttpsUri(parsed2.multiRegionJwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(keypair2.jwks), + }); + + const verifier = CognitoJwtVerifier.create([ + { userPoolId: pool1, clientId: "mixedClient1", tokenUse: "access" }, + { userPoolId: pool2, clientId: "mixedClient2", tokenUse: "access" }, + ]); + + // Verify token from pool1 with standard issuer + const jwt1 = signJwt( + { kid: keypair1.jwk.kid }, + { + iss: parsed1.issuer, + token_use: "access", + client_id: "mixedClient1", + }, + keypair1.privateKey + ); + const result1 = await verifier.verify(jwt1); + expect(result1).toMatchObject({ iss: parsed1.issuer }); + + // Verify token from pool2 with multi-region issuer + const jwt2 = signJwt( + { kid: keypair2.jwk.kid }, + { + iss: parsed2.multiRegionIssuer, + token_use: "access", + client_id: "mixedClient2", + }, + keypair2.privateKey + ); + const result2 = await verifier.verify(jwt2); + expect(result2).toMatchObject({ iss: parsed2.multiRegionIssuer }); + }); + + test("rejects token from unconfigured pool in multi-pool setup", async () => { + const pool1 = "us-east-1_configPool1"; + const pool2 = "us-west-2_configPool2"; + + const verifier = CognitoJwtVerifier.create([ + { userPoolId: pool1, clientId: null, tokenUse: null }, + { userPoolId: pool2, clientId: null, tokenUse: null }, + ]); + + // Cache JWKS for configured pools + verifier.cacheJwks(keypair.jwks, pool1); + verifier.cacheJwks(keypair.jwks, pool2); + + // Try to verify token from unconfigured pool + const unconfiguredIssuer = + "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_unconfigured"; + const jwt = signJwt( + { kid: keypair.jwk.kid }, + { + iss: unconfiguredIssuer, + token_use: "access", + }, + keypair.privateKey + ); + + await expect(verifier.verify(jwt)).rejects.toThrow( + ParameterValidationError + ); + await expect(verifier.verify(jwt)).rejects.toThrow( + `issuer not configured: ${unconfiguredIssuer}` + ); + }); + }); +}); + +describe("hydrate() and caching behavior", () => { + let keypair: ReturnType; + + beforeAll(() => { + keypair = generateKeyPair(); + disallowAllRealNetworkTraffic(); + }); + + afterAll(() => { + allowAllRealNetworkTraffic(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("hydrate() fetches from both endpoints", () => { + test("hydrate() fetches JWKS from both standard and multi-region endpoints for single User Pool", async () => { + const userPoolId = "us-east-1_hydratePool"; + const { issuer, jwksUri, multiRegionIssuer, multiRegionJwksUri } = + CognitoJwtVerifier.parseUserPoolId(userPoolId); + + // Mock both JWKS endpoints + mockHttpsUri(jwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(keypair.jwks), + }); + mockHttpsUri(multiRegionJwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(keypair.jwks), + }); + + const verifier = CognitoJwtVerifier.create({ + userPoolId, + clientId: null, + tokenUse: null, + }); + + await verifier.hydrate(); + + // Verify both endpoints were called by checking that tokens can be verified synchronously + // (which requires the JWKS to be cached) + const standardJwt = signJwt( + { kid: keypair.jwk.kid }, + { iss: issuer, token_use: "access" }, + keypair.privateKey + ); + expect(verifier.verifySync(standardJwt)).toMatchObject({ iss: issuer }); + + const multiRegionJwt = signJwt( + { kid: keypair.jwk.kid }, + { iss: multiRegionIssuer, token_use: "access" }, + keypair.privateKey + ); + expect(verifier.verifySync(multiRegionJwt)).toMatchObject({ + iss: multiRegionIssuer, + }); + }); + + test("hydrate() fetches JWKS from all endpoints for multiple User Pools", async () => { + const pool1 = "us-east-1_hydratePool1"; + const pool2 = "eu-west-1_hydratePool2"; + + const parsed1 = CognitoJwtVerifier.parseUserPoolId(pool1); + const parsed2 = CognitoJwtVerifier.parseUserPoolId(pool2); + + // Mock all four JWKS endpoints (2 pools × 2 formats) + mockHttpsUri(parsed1.jwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(keypair.jwks), + }); + mockHttpsUri(parsed1.multiRegionJwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(keypair.jwks), + }); + mockHttpsUri(parsed2.jwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(keypair.jwks), + }); + mockHttpsUri(parsed2.multiRegionJwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(keypair.jwks), + }); + + const verifier = CognitoJwtVerifier.create([ + { userPoolId: pool1, clientId: null, tokenUse: null }, + { userPoolId: pool2, clientId: null, tokenUse: null }, + ]); + + await verifier.hydrate(); + + // Verify all endpoints were called by checking that tokens can be verified synchronously + const testCases = [ + { issuer: parsed1.issuer }, + { issuer: parsed1.multiRegionIssuer }, + { issuer: parsed2.issuer }, + { issuer: parsed2.multiRegionIssuer }, + ]; + + for (const { issuer } of testCases) { + const jwt = signJwt( + { kid: keypair.jwk.kid }, + { iss: issuer, token_use: "access" }, + keypair.privateKey + ); + expect(verifier.verifySync(jwt)).toMatchObject({ iss: issuer }); + } + }); + + test("after hydrate(), tokens with either issuer format can be verified synchronously", async () => { + const userPoolId = "us-east-1_hydrateVerify"; + const { issuer, jwksUri, multiRegionIssuer, multiRegionJwksUri } = + CognitoJwtVerifier.parseUserPoolId(userPoolId); + + // Mock both JWKS endpoints + mockHttpsUri(jwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(keypair.jwks), + }); + mockHttpsUri(multiRegionJwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(keypair.jwks), + }); + + const verifier = CognitoJwtVerifier.create({ + userPoolId, + clientId: null, + tokenUse: null, + }); + + await verifier.hydrate(); + + // Verify token with standard issuer synchronously + const standardJwt = signJwt( + { kid: keypair.jwk.kid }, + { iss: issuer, token_use: "access" }, + keypair.privateKey + ); + expect(verifier.verifySync(standardJwt)).toMatchObject({ iss: issuer }); + + // Verify token with multi-region issuer synchronously + const multiRegionJwt = signJwt( + { kid: keypair.jwk.kid }, + { iss: multiRegionIssuer, token_use: "access" }, + keypair.privateKey + ); + expect(verifier.verifySync(multiRegionJwt)).toMatchObject({ + iss: multiRegionIssuer, + }); + }); + }); + + describe("cacheJwks() caches for both issuers", () => { + test("cacheJwks() with User Pool ID caches JWKS for both issuer formats", () => { + const userPoolId = "us-east-1_cachePool"; + const { issuer, multiRegionIssuer } = + CognitoJwtVerifier.parseUserPoolId(userPoolId); + + const verifier = CognitoJwtVerifier.create({ + userPoolId, + clientId: null, + tokenUse: null, + }); + + // Cache JWKS once + verifier.cacheJwks(keypair.jwks); + + // Verify token with standard issuer can be verified synchronously + const standardJwt = signJwt( + { kid: keypair.jwk.kid }, + { iss: issuer, token_use: "access" }, + keypair.privateKey + ); + expect(verifier.verifySync(standardJwt)).toMatchObject({ iss: issuer }); + + // Verify token with multi-region issuer can be verified synchronously + const multiRegionJwt = signJwt( + { kid: keypair.jwk.kid }, + { iss: multiRegionIssuer, token_use: "access" }, + keypair.privateKey + ); + expect(verifier.verifySync(multiRegionJwt)).toMatchObject({ + iss: multiRegionIssuer, + }); + }); + + test("cacheJwks() with explicit User Pool ID in multi-pool setup caches for both formats", () => { + const pool1 = "us-east-1_cacheMulti1"; + const pool2 = "us-west-2_cacheMulti2"; + + const parsed1 = CognitoJwtVerifier.parseUserPoolId(pool1); + const parsed2 = CognitoJwtVerifier.parseUserPoolId(pool2); + + const verifier = CognitoJwtVerifier.create([ + { userPoolId: pool1, clientId: null, tokenUse: null }, + { userPoolId: pool2, clientId: null, tokenUse: null }, + ]); + + // Cache JWKS for pool1 + verifier.cacheJwks(keypair.jwks, pool1); + + // Verify tokens from pool1 with both formats work synchronously + const standardJwt1 = signJwt( + { kid: keypair.jwk.kid }, + { iss: parsed1.issuer, token_use: "access" }, + keypair.privateKey + ); + expect(verifier.verifySync(standardJwt1)).toMatchObject({ + iss: parsed1.issuer, + }); + + const multiRegionJwt1 = signJwt( + { kid: keypair.jwk.kid }, + { iss: parsed1.multiRegionIssuer, token_use: "access" }, + keypair.privateKey + ); + expect(verifier.verifySync(multiRegionJwt1)).toMatchObject({ + iss: parsed1.multiRegionIssuer, + }); + + // Cache JWKS for pool2 + verifier.cacheJwks(keypair.jwks, pool2); + + // Verify tokens from pool2 with both formats work synchronously + const standardJwt2 = signJwt( + { kid: keypair.jwk.kid }, + { iss: parsed2.issuer, token_use: "access" }, + keypair.privateKey + ); + expect(verifier.verifySync(standardJwt2)).toMatchObject({ + iss: parsed2.issuer, + }); + + const multiRegionJwt2 = signJwt( + { kid: keypair.jwk.kid }, + { iss: parsed2.multiRegionIssuer, token_use: "access" }, + keypair.privateKey + ); + expect(verifier.verifySync(multiRegionJwt2)).toMatchObject({ + iss: parsed2.multiRegionIssuer, + }); + }); + }); + + describe("cache isolation", () => { + test("cache miss on standard endpoint does not affect multi-region cache", async () => { + const userPoolId = "us-east-1_isolationPool"; + const { issuer, jwksUri, multiRegionIssuer, multiRegionJwksUri } = + CognitoJwtVerifier.parseUserPoolId(userPoolId); + + // Create a keypair for multi-region and a different one for standard + const multiRegionKeypair = generateKeyPair({ + kty: "RSA", + kid: "multiKey", + }); + const standardKeypair = generateKeyPair({ + kty: "RSA", + kid: "standardKey", + }); + + // Mock multi-region endpoint with its keypair + mockHttpsUri(multiRegionJwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(multiRegionKeypair.jwks), + }); + + const verifier = CognitoJwtVerifier.create({ + userPoolId, + clientId: null, + tokenUse: null, + }); + + // First, verify a token with multi-region issuer (this will fetch and cache multi-region JWKS) + const multiRegionJwt = signJwt( + { kid: multiRegionKeypair.jwk.kid }, + { iss: multiRegionIssuer, token_use: "access" }, + multiRegionKeypair.privateKey + ); + const result1 = await verifier.verify(multiRegionJwt); + expect(result1).toMatchObject({ iss: multiRegionIssuer }); + + // Now mock the standard endpoint with a different keypair + mockHttpsUri(jwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(standardKeypair.jwks), + }); + + // Verify a token with standard issuer (this will fetch standard JWKS separately) + const standardJwt = signJwt( + { kid: standardKeypair.jwk.kid }, + { iss: issuer, token_use: "access" }, + standardKeypair.privateKey + ); + const result2 = await verifier.verify(standardJwt); + expect(result2).toMatchObject({ iss: issuer }); + + // Verify that multi-region cache is still intact by verifying another multi-region token synchronously + const anotherMultiRegionJwt = signJwt( + { kid: multiRegionKeypair.jwk.kid }, + { iss: multiRegionIssuer, token_use: "access", sub: "user2" }, + multiRegionKeypair.privateKey + ); + expect(verifier.verifySync(anotherMultiRegionJwt)).toMatchObject({ + iss: multiRegionIssuer, + sub: "user2", + }); + }); + + test("cache miss on multi-region endpoint does not affect standard cache", async () => { + const userPoolId = "us-east-1_isolationPool2"; + const { issuer, jwksUri, multiRegionIssuer, multiRegionJwksUri } = + CognitoJwtVerifier.parseUserPoolId(userPoolId); + + // Create different keypairs for each endpoint + const standardKeypair = generateKeyPair({ kty: "RSA", kid: "stdKey" }); + const multiRegionKeypair = generateKeyPair({ kty: "RSA", kid: "mrKey" }); + + // Mock standard endpoint first + mockHttpsUri(jwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(standardKeypair.jwks), + }); + + const verifier = CognitoJwtVerifier.create({ + userPoolId, + clientId: null, + tokenUse: null, + }); + + // First, verify a token with standard issuer (this will fetch and cache standard JWKS) + const standardJwt = signJwt( + { kid: standardKeypair.jwk.kid }, + { iss: issuer, token_use: "access" }, + standardKeypair.privateKey + ); + const result1 = await verifier.verify(standardJwt); + expect(result1).toMatchObject({ iss: issuer }); + + // Now mock the multi-region endpoint + mockHttpsUri(multiRegionJwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(multiRegionKeypair.jwks), + }); + + // Verify a token with multi-region issuer (this will fetch multi-region JWKS separately) + const multiRegionJwt = signJwt( + { kid: multiRegionKeypair.jwk.kid }, + { iss: multiRegionIssuer, token_use: "access" }, + multiRegionKeypair.privateKey + ); + const result2 = await verifier.verify(multiRegionJwt); + expect(result2).toMatchObject({ iss: multiRegionIssuer }); + + // Verify that standard cache is still intact by verifying another standard token synchronously + const anotherStandardJwt = signJwt( + { kid: standardKeypair.jwk.kid }, + { iss: issuer, token_use: "access", sub: "user3" }, + standardKeypair.privateKey + ); + expect(verifier.verifySync(anotherStandardJwt)).toMatchObject({ + iss: issuer, + sub: "user3", + }); + }); + + test("each endpoint maintains independent cache entries", async () => { + const userPoolId = "us-east-1_independentCache"; + const { issuer, jwksUri, multiRegionIssuer, multiRegionJwksUri } = + CognitoJwtVerifier.parseUserPoolId(userPoolId); + + // Create different keypairs for each endpoint + const standardKeypair = generateKeyPair({ kty: "RSA", kid: "indStdKey" }); + const multiRegionKeypair = generateKeyPair({ + kty: "RSA", + kid: "indMrKey", + }); + + // Mock both endpoints with different keypairs + mockHttpsUri(jwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(standardKeypair.jwks), + }); + mockHttpsUri(multiRegionJwksUri, { + responseStatus: 200, + responsePayload: JSON.stringify(multiRegionKeypair.jwks), + }); + + const verifier = CognitoJwtVerifier.create({ + userPoolId, + clientId: null, + tokenUse: null, + }); + + // Hydrate to fetch both JWKS + await verifier.hydrate(); + + // Verify standard token works with standard keypair + const standardJwt = signJwt( + { kid: standardKeypair.jwk.kid }, + { iss: issuer, token_use: "access" }, + standardKeypair.privateKey + ); + expect(verifier.verifySync(standardJwt)).toMatchObject({ iss: issuer }); + + // Verify multi-region token works with multi-region keypair + const multiRegionJwt = signJwt( + { kid: multiRegionKeypair.jwk.kid }, + { iss: multiRegionIssuer, token_use: "access" }, + multiRegionKeypair.privateKey + ); + expect(verifier.verifySync(multiRegionJwt)).toMatchObject({ + iss: multiRegionIssuer, + }); + + // Verify that using wrong keypair for wrong issuer fails + // Standard token signed with multi-region keypair should fail + const wrongStandardJwt = signJwt( + { kid: multiRegionKeypair.jwk.kid }, + { iss: issuer, token_use: "access" }, + multiRegionKeypair.privateKey + ); + expect(() => verifier.verifySync(wrongStandardJwt)).toThrow(); + + // Multi-region token signed with standard keypair should fail + const wrongMultiRegionJwt = signJwt( + { kid: standardKeypair.jwk.kid }, + { iss: multiRegionIssuer, token_use: "access" }, + standardKeypair.privateKey + ); + expect(() => verifier.verifySync(wrongMultiRegionJwt)).toThrow(); + }); + }); +}); From 5cce04690f470fbc78fa31785925f2d7f4922eb1 Mon Sep 17 00:00:00 2001 From: Edward Sun Date: Fri, 16 Jan 2026 21:38:50 -0500 Subject: [PATCH 2/2] optimize regex pattern and typo fix --- src/cognito-verifier.ts | 110 ++++++++++++++++++++-------------------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/src/cognito-verifier.ts b/src/cognito-verifier.ts index dabb835..9e5f1db 100644 --- a/src/cognito-verifier.ts +++ b/src/cognito-verifier.ts @@ -56,7 +56,7 @@ export interface CognitoVerifyProperties { graceSeconds?: number; /** * Your custom function with checks. It will be called, at the end of the verification, - * after standard verifcation checks have all passed. + * after standard verification checks have all passed. * Throw an error in this function if you want to reject the JWT for whatever reason you deem fit. * Your function will be called with a properties object that contains: * - the decoded JWT header @@ -210,6 +210,10 @@ export function validateCognitoJwtFields( } } +// Reusable regex pattern for AWS region format (e.g., us-east-1, eu-west-2, us-gov-west-1) +const AWS_REGION_PATTERN = "[a-z]{2}-(gov-)?[a-z]+-\\d"; +const USER_POOL_ID_PATTERN = `${AWS_REGION_PATTERN}_[a-zA-Z0-9]+`; + /** * Class representing a verifier for JWTs signed by Amazon Cognito */ @@ -221,16 +225,19 @@ export class CognitoJwtVerifier< }, MultiIssuer extends boolean, > extends JwtVerifierBase { - private static USER_POOL_ID_REGEX = - /^(?[a-z]{2}-(gov-)?[a-z]+-\d)_[a-zA-Z0-9]+$/; + private static USER_POOL_ID_REGEX = new RegExp( + `^(?${AWS_REGION_PATTERN})_[a-zA-Z0-9]+$` + ); // Regex for standard issuer format: https://cognito-idp.{region}.amazonaws.com/{userPoolId} - private static STANDARD_ISSUER_REGEX = - /^https:\/\/cognito-idp\.(?[a-z]{2}-(gov-)?[a-z]+-\d)\.amazonaws\.com\/(?[a-z]{2}-(gov-)?[a-z]+-\d_[a-zA-Z0-9]+)$/; + private static STANDARD_ISSUER_REGEX = new RegExp( + `^https://cognito-idp\\.(?${AWS_REGION_PATTERN})\\.amazonaws\\.com/(?${USER_POOL_ID_PATTERN})$` + ); // Regex for multi-region issuer format: https://issuer.cognito-idp.{region}.amazonaws.com/{userPoolId} - private static MULTI_REGION_ISSUER_REGEX = - /^https:\/\/issuer\.cognito-idp\.(?[a-z]{2}-(gov-)?[a-z]+-\d)\.amazonaws\.com\/(?[a-z]{2}-(gov-)?[a-z]+-\d_[a-zA-Z0-9]+)$/; + private static MULTI_REGION_ISSUER_REGEX = new RegExp( + `^https://issuer\\.cognito-idp\\.(?${AWS_REGION_PATTERN})\\.amazonaws\\.com/(?${USER_POOL_ID_PATTERN})$` + ); private constructor( props: CognitoJwtVerifierProperties | CognitoJwtVerifierMultiProperties[], jwksCache?: JwksCache @@ -306,33 +313,23 @@ export class CognitoJwtVerifier< region: string; format: "standard" | "multiRegion"; } | null { - // Try standard format: https://cognito-idp.{region}.amazonaws.com/{userPoolId} - const standardMatch = issuer.match(this.STANDARD_ISSUER_REGEX); - if (standardMatch && standardMatch.groups) { - const region = standardMatch.groups.region; - const userPoolId = standardMatch.groups.userPoolId; - // Validate that the region in the issuer matches the region in the userPoolId - if (userPoolId.startsWith(`${region}_`)) { - return { - region, - userPoolId, - format: "standard", - }; - } - } + // Try each issuer format pattern + const patterns: Array<{ + regex: RegExp; + format: "standard" | "multiRegion"; + }> = [ + { regex: this.STANDARD_ISSUER_REGEX, format: "standard" }, + { regex: this.MULTI_REGION_ISSUER_REGEX, format: "multiRegion" }, + ]; - // Try multi-region format: https://issuer.cognito-idp.{region}.amazonaws.com/{userPoolId} - const multiRegionMatch = issuer.match(this.MULTI_REGION_ISSUER_REGEX); - if (multiRegionMatch && multiRegionMatch.groups) { - const region = multiRegionMatch.groups.region; - const userPoolId = multiRegionMatch.groups.userPoolId; - // Validate that the region in the issuer matches the region in the userPoolId - if (userPoolId.startsWith(`${region}_`)) { - return { - region, - userPoolId, - format: "multiRegion", - }; + for (const { regex, format } of patterns) { + const match = issuer.match(regex); + if (match?.groups) { + const { region, userPoolId } = match.groups; + // Validate that the region in the issuer matches the region in the userPoolId + if (userPoolId.startsWith(`${region}_`)) { + return { region, userPoolId, format }; + } } } @@ -375,18 +372,15 @@ export class CognitoJwtVerifier< } /** - * Verify (synchronously) a JWT that is signed by Amazon Cognito. - * - * @param jwt The JWT, as string - * @param props Verification properties - * @returns The payload of the JWT––if the JWT is valid, otherwise an error is thrown + * Validates Cognito JWT fields and handles error wrapping. + * Shared logic between verify and verifySync methods. */ - public verifySync( - ...[jwt, properties]: CognitoVerifyParameters - ): CognitoIdOrAccessTokenPayload { - const { decomposedJwt, jwksUri, verifyProperties } = - this.getVerifyParameters(jwt, properties); - this.verifyDecomposedJwtSync(decomposedJwt, jwksUri, verifyProperties); + private validateAndWrapErrors( + decomposedJwt: { header: JwtHeader; payload: JwtPayload }, + verifyProperties: Partial & { + includeRawJwtInErrors?: boolean; + } + ): void { try { validateCognitoJwtFields(decomposedJwt.payload, verifyProperties); } catch (err) { @@ -398,6 +392,22 @@ export class CognitoJwtVerifier< } throw err; } + } + + /** + * Verify (synchronously) a JWT that is signed by Amazon Cognito. + * + * @param jwt The JWT, as string + * @param props Verification properties + * @returns The payload of the JWT––if the JWT is valid, otherwise an error is thrown + */ + public verifySync( + ...[jwt, properties]: CognitoVerifyParameters + ): CognitoIdOrAccessTokenPayload { + const { decomposedJwt, jwksUri, verifyProperties } = + this.getVerifyParameters(jwt, properties); + this.verifyDecomposedJwtSync(decomposedJwt, jwksUri, verifyProperties); + this.validateAndWrapErrors(decomposedJwt, verifyProperties); return decomposedJwt.payload as CognitoIdOrAccessTokenPayload< IssuerConfig, T @@ -419,17 +429,7 @@ export class CognitoJwtVerifier< const { decomposedJwt, jwksUri, verifyProperties } = this.getVerifyParameters(jwt, properties); await this.verifyDecomposedJwt(decomposedJwt, jwksUri, verifyProperties); - try { - validateCognitoJwtFields(decomposedJwt.payload, verifyProperties); - } catch (err) { - if ( - verifyProperties.includeRawJwtInErrors && - err instanceof JwtInvalidClaimError - ) { - throw err.withRawJwt(decomposedJwt); - } - throw err; - } + this.validateAndWrapErrors(decomposedJwt, verifyProperties); return decomposedJwt.payload as CognitoIdOrAccessTokenPayload< IssuerConfig, T