@hellocoop/email-verification
Version:
Functions for generating and verifying JWT tokens used in the Email Verification Protocol
684 lines (501 loc) β’ 20.1 kB
Markdown
# @hellocoop/email-verification
π§ͺ **Experimental** - This package implements the [Email Verification Protocol](https://github.com/dickhardt/email-verification-protocol) and is currently in experimental status.
> **Development Note**: This package was collaboratively developed using spec-driven development with AI assistance. You can view the complete [requirements, design, and implementation specifications](./specs) that guided the development process.
TypeScript functions for generating and verifying JWT tokens used in the Email Verification Protocol. This package provides complete implementations for RequestToken, IssuanceToken (SD-JWT), and PresentationToken (SD-JWT+KB) as defined in the [Email Verification Protocol specification](https://github.com/dickhardt/email-verification-protocol).
## π Try It Live with HellΕ
Want to see the Email Verification Protocol in action? You can test it right now with hello.coop!
### Quick Test Setup
1. **Add DNS Record**: Add this TXT record to your domain:
```
_email-verification.yourdomain.com TXT "iss=hello.coop"
```
2. **Get User Hint Cookie**: Visit [HellΕ Wallet](https://wallet.hello.coop), create a wallet if needed by logging in, and verify an email address at your domain. In your browser, inspect the page, select the application tab, and find the `user-hint` cookie for wallet.hello.coop, and get its value.
3. **Run the Test**: Use npx to test the complete flow:
```bash
npx @hellocoop/email-verification your-email@yourdomain.com user-hint <cookie-value>
```
### Example
```bash
# After setting up DNS and getting your user-hint cookie
npx @hellocoop/email-verification john@example.com user-hint eyJhbGciOiJFZERTQSJ9...
```
This will:
- β
Discover the issuer (hello.coop) via DNS
- β
Fetch hello.coop email-verification metadata and JWKS
- β
Generate a request token with a browser key pair
- β
Send the request to hello.coop issuance endpoint with required `Sec-Fetch-Dest: email-verification` header
- β
Verify and display the returned SD-JWT token
Perfect for testing your DNS setup and seeing the protocol in action! π―
## Package Installation
```bash
npm install @hellocoop/email-verification
```
## Quick Start
### ESM (ECMAScript Modules)
```typescript
import {
// Most commonly used by RPs
verifyPresentationToken,
// Other token functions
generateRequestToken,
verifyRequestToken,
generateIssuanceToken,
verifyIssuanceToken,
generatePresentationToken,
// DNS discovery functions
discoverIssuer,
fetchEmailVerificationMetadata,
fetchJWKS,
clearCaches,
// Types
type KeyResolver,
type EmailVerificationMetadata,
type JWKSResponse,
type RequestOptions,
} from '@hellocoop/email-verification'
```
### CommonJS
```javascript
const {
// Most commonly used by RPs
verifyPresentationToken,
// Other token functions
generateRequestToken,
verifyRequestToken,
generateIssuanceToken,
verifyIssuanceToken,
generatePresentationToken,
// Discovery functions
discoverIssuer,
fetchEmailVerificationMetadata,
fetchJWKS,
clearCaches,
} = require('@hellocoop/email-verification')
```
**Note**: The package provides dual ESM/CommonJS support with automatic module resolution based on your project configuration.
## API Reference
### PresentationToken Verification (Most Common for RPs)
#### `verifyPresentationToken(token, expectedAudience, expectedNonce, keyResolver?)`
**π₯ Most commonly used function by Relying Parties** - Verifies a PresentationToken (SD-JWT+KB) from browsers with automatic DNS-based key discovery.
**Parameters:**
- `token: string` - SD-JWT+KB string to verify
- `expectedAudience: string` - Expected audience (RP's origin)
- `expectedNonce: string` - Expected nonce from RP's session
- `keyResolver?: KeyResolver` - Optional function to resolve issuer's public key. If not provided, uses automatic DNS discovery
**Returns:** `Promise<PresentationTokenPayload>` - Object containing both SD-JWT and KB-JWT payloads
**Example (Automatic DNS Discovery - Recommended):**
```typescript
// Simplest usage - automatic key discovery via DNS
const verified = await verifyPresentationToken(
presentationToken,
'https://rp.example',
'session-nonce-123',
)
console.log(verified.sdJwt.email) // 'user@example.com'
console.log(verified.kbJwt.aud) // 'https://rp.example'
```
**Example (Custom Key Resolver):**
```typescript
// Custom key resolver for advanced use cases
const keyResolver = async (kid, issuer) => {
// Your custom logic to resolve keys
return await getPublicKeyFromSomewhere(kid, issuer)
}
const verified = await verifyPresentationToken(
presentationToken,
'https://rp.example',
'session-nonce-123',
keyResolver,
)
```
### RequestToken Functions
RequestTokens are used by browsers to request verified email tokens from issuers (step 3.4 & 4.1).
#### `generateRequestToken(payload, jwk, options?)`
Generates a RequestToken (JWT) with an embedded public key.
**Parameters:**
- `payload: RequestTokenPayload` - Token payload
- `aud: string` - Audience (issuer domain)
- `nonce: string` - Nonce provided by the RP
- `email: string` - Email address to be verified
- `iat?: number` - Optional issued at time (defaults to current time)
- `jti?: string` - Optional unique identifier for the token
- `jwk: JWK` - JSON Web Key containing private key, algorithm, and key ID
- `options?: TokenGenerationOptions` - Optional generation options
**Returns:** `Promise<string>` - Signed JWT string
**Example:**
```typescript
const requestToken = await generateRequestToken(
{
aud: 'issuer.example',
nonce: '259c5eae-486d-4b0f-b666-2a5b5ce1c925',
email: 'user@example.com',
},
browserPrivateKey,
)
```
#### `verifyRequestToken(token)`
Verifies a RequestToken using the embedded public key.
**Parameters:**
- `token: string` - JWT string to verify
**Returns:** `Promise<RequestTokenPayload>` - Verified payload
**Example:**
```typescript
const verified = await verifyRequestToken(requestToken)
console.log(verified.email) // 'user@example.com'
```
**Note:** RequestTokens contain the public key embedded in the JWT header, so no external key resolver is needed.
### IssuanceToken Functions
IssuanceTokens (SD-JWTs) are used by issuers to provide verified email tokens to browsers (step 4.2 & 5.1).
#### `generateIssuanceToken(payload, jwk, options?)`
Generates an IssuanceToken (SD-JWT) for verified email addresses.
**Parameters:**
- `payload: IssuanceTokenPayload` - Token payload
- `iss: string` - Issuer identifier
- `cnf: { jwk: JWK }` - Confirmation claim with browser's public key
- `email: string` - Verified email address
- `email_verified: boolean` - Must be `true`
- `iat?: number` - Optional issued at time
- `jwk: JWK` - Issuer's private key
- `options?: TokenGenerationOptions` - Optional generation options
**Returns:** `Promise<string>` - Signed SD-JWT string
**Example:**
```typescript
const issuanceToken = await generateIssuanceToken(
{
iss: 'issuer.example',
cnf: { jwk: browserPublicKey },
email: 'user@example.com',
email_verified: true,
},
issuerPrivateKey,
)
```
#### `verifyIssuanceToken(token, keyResolver)`
Verifies an IssuanceToken (SD-JWT) from issuers.
**Parameters:**
- `token: string` - SD-JWT string to verify
- `keyResolver: KeyResolver` - Function to resolve issuer's public key
**Returns:** `Promise<IssuanceTokenPayload>` - Verified payload
**Example:**
```typescript
const keyResolver: KeyResolver = async (kid, issuer) => {
// Return the appropriate public key for verification
return await getIssuerPublicKey(kid, issuer)
}
const verified = await verifyIssuanceToken(issuanceToken, keyResolver)
```
### PresentationToken Functions
PresentationTokens (SD-JWT+KB) are used by browsers to present verified email tokens to relying parties (step 5.2 & 6.2-6.4).
#### `generatePresentationToken(sdJwt, audience, nonce, jwk, options?)`
Generates a PresentationToken (SD-JWT+KB) with key binding.
**Parameters:**
- `sdJwt: string` - SD-JWT from issuer
- `audience: string` - RP's origin
- `nonce: string` - Nonce from RP's session
- `jwk: JWK` - Browser's private key
- `options?: TokenGenerationOptions` - Optional generation options
**Returns:** `Promise<string>` - SD-JWT+KB string (format: `{SD-JWT}~{KB-JWT}`)
**Example:**
```typescript
const presentationToken = await generatePresentationToken(
issuanceToken,
'https://rp.example',
'session-nonce-123',
browserPrivateKey,
)
```
### DNS Discovery Functions
The package provides DNS-based discovery functions to automatically find issuers and fetch their metadata according to the Email Verification Protocol specification.
#### `discoverIssuer(emailOrDomain)`
Discovers the email-verification issuer for an email address or domain via DNS TXT record lookup.
**Parameters:**
- `emailOrDomain: string` - Email address (e.g., `user@example.com`) or domain (e.g., `example.com`)
**Returns:** `Promise<string>` - Issuer identifier (domain)
**DNS Record Format:** The function looks for TXT records at `_email-verification.$EMAIL_DOMAIN` with format `iss=issuer.example`
**Example:**
```typescript
// Discover issuer from email address
const issuer = await discoverIssuer('user@example.com')
console.log(issuer) // 'issuer.example'
// Or from domain directly
const issuer = await discoverIssuer('example.com')
```
**DNS Setup Example:**
```
_email-verification.example.com TXT iss=issuer.example
```
#### `fetchEmailVerificationMetadata(issuerIdentifier, options?)`
Fetches email-verification metadata from an issuer domain's well-known endpoint.
**Parameters:**
- `issuerIdentifier: string` - Issuer domain (e.g., `issuer.example`)
- `options?: RequestOptions` - Optional request configuration
- `timeout?: number` - Request timeout in milliseconds (default: 10000)
- `cacheTimeout?: number` - Cache timeout in milliseconds (default: 300000)
**Returns:** `Promise<EmailVerificationMetadata>` - Metadata containing endpoints and supported algorithms
**Example:**
```typescript
const metadata = await fetchEmailVerificationMetadata('issuer.example', {
timeout: 5000, // 5 second timeout
cacheTimeout: 60000, // 1 minute cache
})
console.log(metadata.issuance_endpoint) // 'https://accounts.issuer.example/email-verification/issuance'
console.log(metadata.jwks_uri) // 'https://accounts.issuer.example/email-verification/jwks'
console.log(metadata.signing_alg_values_supported) // ['EdDSA', 'RS256']
```
#### `fetchJWKS(jwksUri, options?)`
Fetches JWKS (JSON Web Key Set) from a JWKS URI.
**Parameters:**
- `jwksUri: string` - JWKS URI from email-verification metadata
- `options?: RequestOptions` - Optional request configuration
- `timeout?: number` - Request timeout in milliseconds (default: 10000)
- `cacheTimeout?: number` - Cache timeout in milliseconds (default: 300000)
**Returns:** `Promise<JWKSResponse>` - JWKS containing public keys
**Example:**
```typescript
const jwks = await fetchJWKS(metadata.jwks_uri, {
timeout: 5000,
cacheTimeout: 300000, // 5 minute cache
})
console.log(jwks.keys.length) // Number of keys available
```
#### `clearCaches()`
Clears the in-memory caches for metadata and JWKS. Useful for testing or forcing fresh fetches.
**Example:**
```typescript
// Clear all caches to force fresh fetches
clearCaches()
```
#### Complete DNS Discovery Example
```typescript
import {
discoverIssuer,
fetchEmailVerificationMetadata,
fetchJWKS,
verifyIssuanceToken,
} from '@hellocoop/email-verification'
// 1. Discover issuer from email domain
const issuer = await discoverIssuer('user@example.com')
// 2. Fetch issuer metadata
const metadata = await fetchEmailVerificationMetadata(issuer)
// 3. Fetch issuer's public keys
const jwks = await fetchJWKS(metadata.jwks_uri)
// 4. Create key resolver using fetched JWKS
const keyResolver = async (kid?: string, issuer?: string) => {
const key = jwks.keys.find((k) => k.kid === kid)
if (!key) {
throw new Error(`Key with ID '${kid}' not found`)
}
return key
}
// 5. Use with token verification
const verified = await verifyIssuanceToken(issuanceToken, keyResolver)
```
## Types
### KeyResolver
```typescript
type KeyResolver = (kid?: string, issuer?: string) => Promise<JWK | KeyLike>
```
Function to resolve public keys for verification. Called with the key ID (`kid`) from the JWT header and optionally the issuer identifier.
### Token Payloads
```typescript
interface RequestTokenPayload {
aud: string
iat?: number
jti?: string
nonce: string
email: string
}
interface IssuanceTokenPayload {
iss: string
iat?: number
cnf: { jwk: JWK }
email: string
email_verified: boolean
}
interface PresentationTokenPayload {
sdJwt: IssuanceTokenPayload
kbJwt: {
aud: string
nonce: string
iat?: number
sd_hash?: string
}
}
```
### Generation Options
```typescript
interface TokenGenerationOptions {
algorithm?: string // Override algorithm from JWK
expiresIn?: number // Token expiration (default: 60 seconds)
}
```
### DNS Discovery Types
```typescript
interface EmailVerificationMetadata {
issuance_endpoint: string
jwks_uri: string
signing_alg_values_supported?: string[]
}
interface JWKSResponse {
keys: JWK[]
}
interface RequestOptions {
timeout?: number // Request timeout in milliseconds (default: 10000)
cacheTimeout?: number // Cache timeout in milliseconds (default: 300000)
}
```
## Error Handling
The package provides specific error classes for different failure scenarios:
```typescript
import {
EmailVerificationError, // Base error class
MissingClaimError, // Required claim missing
InvalidSignatureError, // Signature verification failed
TimeValidationError, // Time-based validation failed
TokenFormatError, // Invalid token format
JWKValidationError, // JWK validation failed
EmailValidationError, // Email validation failed
DNSDiscoveryError, // DNS discovery failed
JWKSFetchError, // JWKS/metadata fetch failed
} from '@hellocoop/email-verification'
try {
const issuer = await discoverIssuer('user@example.com')
const metadata = await fetchEmailVerificationMetadata(issuer)
const token = await generateRequestToken(payload, key)
} catch (error) {
if (error instanceof DNSDiscoveryError) {
console.log(`DNS discovery failed: ${error.message}`)
} else if (error instanceof JWKSFetchError) {
console.log(`JWKS fetch failed: ${error.message}`)
} else if (error instanceof MissingClaimError) {
console.log(`Missing claim: ${error.message}`)
} else if (error instanceof EmailValidationError) {
console.log(`Invalid email: ${error.message}`)
}
}
```
## Utility Functions
The package also exports utility functions that may be useful:
```typescript
import {
// Cryptographic utilities
validateJWK, // Validate JWK structure
extractPublicKeyParameters, // Extract public key from JWK
calculateSHA256Hash, // Calculate SHA-256 hash
isValidEmail, // Validate email format
// Time utilities
getCurrentTimestamp, // Get current Unix timestamp
validateIatClaim, // Validate iat claim
TIME_VALIDATION_WINDOW, // Default time window (60 seconds)
// DNS discovery utilities
discoverIssuer, // Discover issuer from email/domain
fetchEmailVerificationMetadata, // Fetch issuer metadata
fetchJWKS, // Fetch JWKS from URI
clearCaches, // Clear in-memory caches
} from '@hellocoop/email-verification'
```
## Module Compatibility
This package supports both **ESM (ECMAScript Modules)** and **CommonJS** environments:
- β
**ESM**: `import` statements (Node.js with `"type": "module"`, modern bundlers)
- β
**CommonJS**: `require()` statements (traditional Node.js projects)
- β
**TypeScript**: Full type definitions included for both module systems
- β
**Bundlers**: Works with Webpack, Rollup, Vite, and other modern bundlers
The package automatically provides the correct module format based on your project configuration.
## Algorithm Support
The package supports the following cryptographic algorithms:
- **RSA**: RS256, RS384, RS512
- **EdDSA**: Ed25519 (recommended for new implementations)
- **ECDSA**: ES256, ES384, ES512
## Security Features
- β
**Automatic private key stripping** - Private key material is automatically removed from `cnf` claims
- β
**Time-based validation** - All tokens validated within 60-second windows
- β
**Email format validation** - Comprehensive email validation with security checks
- β
**JWK validation** - Thorough validation of key parameters
- β
**Cross-algorithm support** - Mix and match RSA and EdDSA keys
- β
**Independent verification** - Separate test suite validates token generation
- β
**Required headers validation** - Validates `Sec-Fetch-Dest: email-verification` header
## Security Considerations
- Libraries do not confirm issuer domain is eTLD+1. Browsers will enforce in actual deployments.
- The `Sec-Fetch-Dest: email-verification` header is required for all issuance endpoint requests to prevent CSRF attacks.
## Performance
Typical performance benchmarks on modern hardware:
- RequestToken generation: ~1.3ms average
- RequestToken verification: ~0.15ms average
- EdDSA operations: ~0.3ms round-trip
- Memory usage: <7MB for 1000 operations
## Complete Example
### ESM (ECMAScript Modules)
```typescript
import {
generateRequestToken,
verifyRequestToken,
generateIssuanceToken,
verifyIssuanceToken,
generatePresentationToken,
verifyPresentationToken,
type KeyResolver,
} from '@hellocoop/email-verification'
// 1. Browser generates RequestToken
const requestToken = await generateRequestToken(
{
aud: 'issuer.example',
nonce: 'rp-nonce-123',
email: 'user@example.com',
},
browserPrivateKey,
)
// 2. Issuer verifies RequestToken
const requestPayload = await verifyRequestToken(requestToken)
// 3. Issuer generates IssuanceToken
const issuanceToken = await generateIssuanceToken(
{
iss: 'issuer.example',
cnf: { jwk: extractedBrowserPublicKey },
email: requestPayload.email,
email_verified: true,
},
issuerPrivateKey,
)
// 4. Browser verifies IssuanceToken
const keyResolver: KeyResolver = async (kid, issuer) => {
return await getIssuerPublicKey(kid, issuer)
}
const issuancePayload = await verifyIssuanceToken(issuanceToken, keyResolver)
// 5. Browser generates PresentationToken
const presentationToken = await generatePresentationToken(
issuanceToken,
'https://rp.example',
'rp-nonce-123',
browserPrivateKey,
)
// 6. Relying Party verifies PresentationToken (automatic DNS discovery)
const presentationPayload = await verifyPresentationToken(
presentationToken,
'https://rp.example',
'rp-nonce-123',
)
console.log('Verified email:', presentationPayload.sdJwt.email)
```
### CommonJS
```javascript
const {
generateRequestToken,
verifyRequestToken,
generateIssuanceToken,
verifyIssuanceToken,
generatePresentationToken,
verifyPresentationToken,
} = require('@hellocoop/email-verification')
// Key resolver function
const keyResolver = async (kid, issuer) => {
return await getIssuerPublicKey(kid, issuer)
}
// Same usage as ESM example above...
// (The rest of the implementation is identical)
```
## Specification
This package implements the [Email Verification Protocol](https://github.com/dickhardt/email-verification-protocol). For detailed protocol information, please refer to the specification.
## License
MIT - See LICENSE file for details.
## Contributing
This package is part of the Hello Identity Co-op packages monorepo. Issues and contributions are welcome.