This document provides comprehensive security guidelines for deploying and operating the Wallet Service in production environments.
The Wallet Service handles sensitive member data and integrates with multiple external services. Proper security configuration is critical for:
| Component | Current Method | Security Level |
|---|---|---|
| Apple Wallet API | Bearer token in Authorization header | ✅ Good |
| Pass Send Endpoint | API key in query parameter | ⚠️ Needs improvement |
| Salesforce | Username + Password + Security Token | ⚠️ Needs improvement |
| Google Wallet | Service Account (key.json) | ✅ Good |
| SMTP | Username + App Password | ✅ Acceptable |
Never commit secrets to version control. Use environment variables for all sensitive data.
# Required secrets (never hardcode these)
AUTH_TOKEN=<secure-random-uuid>
CERT_PASSPHRASE=<certificate-passphrase>
SF_PASSWD=<salesforce-password>
SF_SECURITY_TOKEN=<salesforce-token>
SF_CONSUMER_SECRET=<oauth-secret>
SMTP_PASSWD=<smtp-password>
Best Practices:
The AUTH_TOKEN authenticates Apple Wallet device requests.
Requirements:
Generate a secure token:
# Using Node.js
node -e "console.log(require('crypto').randomUUID())"
# Using OpenSSL
openssl rand -hex 32
# Using uuidgen
uuidgen
Current Implementation:
AUTH_TOKEN=ebee936c-46fe-42e6-9d1c-8d619cf1c53a
Always use HTTPS in production.
HTTPS=true
WEBSERVICE_URL=passes.usvjena.de
TLS Requirements:
Railway/Platform Configuration: Most platforms (Railway, Heroku, Fly.io) provide automatic TLS termination. Ensure:
Production rate limiting is automatically enabled:
// Current configuration
await this._instance.register(rateLimit, {
max: 100, // requests
timeWindow: 2000, // per 2 seconds
whitelist: [env.HOST, 'localhost', '0.0.0.0', '127.0.0.1', env.WEBSERVICE_URL],
});
Recommendations:
The application uses Fastify Helmet for security headers:
await this._instance.register(helmet, {
contentSecurityPolicy: {
directives: {
defaultSrc: [`'self'`],
styleSrc: [`*`, 'https://fonts.googleapis.com/*', `'unsafe-inline'`],
imgSrc: [`'self'`, 'data:', 'validator.swagger.io'],
scriptSrc: [`'self'`, `https: 'unsafe-inline'`, `http: 'unsafe-inline'`],
'form-action': ["'self'"],
},
},
});
Headers Enabled:
X-Content-Type-Options: nosniffX-Frame-Options: DENYX-XSS-Protection: 1; mode=blockStrict-Transport-Security (HSTS)Content-Security-Policyawait this._instance.register(cors, {
origin: '*', // ⚠️ Should be restricted in production
methods: ['GET', 'POST', 'DELETE', 'HEAD'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
});
Production Recommendation:
origin: ['https://usvjena.lightning.force.com', 'https://passes.usvjena.de'],
All endpoints use JSON Schema validation via Fastify:
// Example schema
const updatePassSchema = {
params: {
type: 'object',
required: ['serialNumber'],
properties: {
serialNumber: { type: 'string', minLength: 1 }
}
},
querystring: {
type: 'object',
required: ['type'],
properties: {
type: { type: 'string', enum: ['apple', 'google'] }
}
}
};
The current implementation uses direct username/password authentication:
SF_USERNAME=c.simon@dotsource.de.usvjena
SF_PASSWD=AyvTa99Pka4KARHJ!
SF_SECURITY_TOKEN=htqruSUO0vVyBroqkWIGnA8HO
⚠️ Security Concerns:
Why JWT Bearer Flow?
1. Create a Connected App in Salesforce
Navigate to: Setup → App Manager → New Connected App
Configure:
https://passes.usvjena.de/oauth/callback (not used for JWT, but required)api (Access and manage your data)refresh_token, offline_access (Perform requests at any time)2. Generate X.509 Certificate
# Generate private key
openssl genrsa -out salesforce.key 2048
# Generate certificate (valid for 1 year)
openssl req -new -x509 -key salesforce.key -out salesforce.crt -days 365 \
-subj "/CN=WalletService/O=USV Jena/C=DE"
# Upload salesforce.crt to Connected App in Salesforce
3. Pre-authorize the Connected App
In Salesforce Setup:
4. Update Environment Configuration
# Remove these (no longer needed)
# SF_PASSWD=...
# SF_SECURITY_TOKEN=...
# Add these
SF_CONSUMER_KEY=<connected-app-consumer-key>
SF_PRIVATE_KEY_PATH=/app/certs/salesforce.key
SF_USERNAME=integration@usvjena.org # Service account or integration user
SF_LOGIN_URL=https://login.salesforce.com
SF_AUDIENCE=https://login.salesforce.com # or https://test.salesforce.com for sandbox
5. Implement JWT Bearer Flow
// src/lib/modules/salesforce-oauth.ts
import * as jwt from 'jsonwebtoken';
import { readFileSync } from 'node:fs';
import { environment as env } from '../../app';
interface SalesforceTokenResponse {
access_token: string;
instance_url: string;
token_type: string;
}
export class SalesforceAuth {
private accessToken: string | null = null;
private instanceUrl: string | null = null;
private tokenExpiry: number = 0;
async getAccessToken(): Promise<string> {
// Return cached token if still valid (with 5 min buffer)
if (this.accessToken && Date.now() < this.tokenExpiry - 300000) {
return this.accessToken;
}
return this.refreshToken();
}
private async refreshToken(): Promise<string> {
const privateKey = readFileSync(env.SF_PRIVATE_KEY_PATH, 'utf8');
const now = Math.floor(Date.now() / 1000);
const payload = {
iss: env.SF_CONSUMER_KEY,
sub: env.SF_USERNAME,
aud: env.SF_AUDIENCE,
exp: now + 300, // 5 minutes
};
const assertion = jwt.sign(payload, privateKey, { algorithm: 'RS256' });
const response = await fetch(`${env.SF_LOGIN_URL}/services/oauth2/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Salesforce OAuth failed: ${error}`);
}
const data: SalesforceTokenResponse = await response.json();
this.accessToken = data.access_token;
this.instanceUrl = data.instance_url;
this.tokenExpiry = Date.now() + 7200000; // 2 hours (SF default)
return this.accessToken;
}
getInstanceUrl(): string {
if (!this.instanceUrl) {
throw new Error('Not authenticated. Call getAccessToken first.');
}
return this.instanceUrl;
}
}
// Singleton instance
export const salesforceAuth = new SalesforceAuth();
6. Update Salesforce API Calls
// Before (username-password flow)
const connection = new jsforce.Connection({
loginUrl: env.SF_LOGIN_URL,
});
await connection.login(env.SF_USERNAME, env.SF_PASSWD + env.SF_SECURITY_TOKEN);
// After (JWT Bearer flow)
import { salesforceAuth } from './salesforce-oauth';
const accessToken = await salesforceAuth.getAccessToken();
const instanceUrl = salesforceAuth.getInstanceUrl();
const connection = new jsforce.Connection({
instanceUrl,
accessToken,
});
For Salesforce orgs with API version 52.0+ (Winter '22):
SF_CONSUMER_KEY=<connected-app-consumer-key>
SF_CONSUMER_SECRET=<connected-app-consumer-secret>
SF_TOKEN_URL=https://login.salesforce.com/services/oauth2/token
const response = await fetch(env.SF_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: env.SF_CONSUMER_KEY,
client_secret: env.SF_CONSUMER_SECRET,
}),
});
Note: Client Credentials flow requires additional Salesforce configuration and may not be available in all orgs.
Store certificates securely:
src/lib/certs/
├── pass.pem # Pass Type ID certificate
├── pass.key # Private key (NEVER commit)
└── wwdr.pem # Apple WWDR certificate
Best Practices:
chmod 600 *.keyCERT_PASSPHRASE in environment variables onlysrc/lib/assets/
└── key.json # Service account credentials (NEVER commit)
Best Practices:
.gitignore1. /passes/send uses query parameter for API key:
GET /passes/send?salesforceId=xxx&key=g4Rf1elD1337&type=apple
Issues:
Recommended Fix:
// Use Authorization header instead
@GET('/send')
async send(request: FastifyRequest<SendMailRequest>, reply: FastifyReply) {
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return reply.status(401).send({ code: 401, message: 'Unauthorized' });
}
const apiKey = authHeader.slice(7);
if (apiKey !== env.API_KEY) {
return reply.status(401).send({ code: 401, message: 'Unauthorized' });
}
// ... rest of handler
}
2. Add API Key to Environment:
API_KEY=<secure-random-key>
SENTRY_DSN=https://xxx@sentry.io/xxx
Configuration:
LOGGER=trueLOG_LEVEL=infoLog these events for security monitoring:
.gitignore includes all secret filesAUTH_TOKEN every 90 days