Browse Source

fix(test-app): remove aud claim from JWT to resolve server validation error

Remove the aud (audience) claim from JWT payloads. The server's did-jwt
verification requires an audience option when aud is present, but the server
isn't configured to validate it, causing "JWT audience is required but your
app address has not been configured" errors.

Changes:
- Removed aud claim from JWT payload in generateEndorserJWT()
- Updated key derivation to User Zero's specific path (m/84737769'/0'/0'/0')
- Added public key verification against expected User Zero key
- Enhanced JWT diagnostics logging throughout
- Added alarm deduplication optimization (prevent duplicate alarms for same time)

Verified: JWT validation now passes (token length 360→333 chars, no audience
error). New error is API parameter validation (afterId required - separate issue).
master
Matthew Raymer 24 hours ago
parent
commit
a421bb5d41
  1. 51
      android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationScheduler.java
  2. 35
      test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/TestNativeFetcher.java
  3. 66
      test-apps/daily-notification-test/scripts/find-user-zero-key.js
  4. 166
      test-apps/daily-notification-test/src/config/test-user-zero.ts
  5. 23
      test-apps/daily-notification-test/src/views/HomeView.vue

51
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationScheduler.java

@ -36,6 +36,10 @@ public class DailyNotificationScheduler {
private final AlarmManager alarmManager;
private final ConcurrentHashMap<String, PendingIntent> scheduledAlarms;
// Track scheduled times to prevent duplicate alarms for same time
// Maps scheduledTime (ms) -> notificationId that has alarm scheduled
private final ConcurrentHashMap<Long, String> scheduledTimeToId;
// PendingIntent management
private PendingIntentManager pendingIntentManager;
@ -55,6 +59,7 @@ public class DailyNotificationScheduler {
this.context = context;
this.alarmManager = alarmManager;
this.scheduledAlarms = new ConcurrentHashMap<>();
this.scheduledTimeToId = new ConcurrentHashMap<>();
this.pendingIntentManager = new PendingIntentManager(context);
Log.d(TAG, "DailyNotificationScheduler initialized with PendingIntentManager");
@ -120,6 +125,36 @@ public class DailyNotificationScheduler {
Log.d(TAG, "Phase 3: Cancelling existing alarm for notification: " + content.getId());
cancelNotification(content.getId());
// Get scheduled time and check for duplicate alarms at same time
long triggerTime = content.getScheduledTime();
long toleranceMs = 60 * 1000; // 1 minute tolerance for DST/clock adjustments
// Cancel any existing alarm for the same scheduled time (within tolerance)
// This prevents multiple notifications scheduled for same time from creating duplicate alarms
// Check all scheduled times to find any within tolerance
java.util.List<String> duplicateIds = new java.util.ArrayList<>();
for (java.util.Map.Entry<Long, String> entry : scheduledTimeToId.entrySet()) {
Long scheduledTime = entry.getKey();
String existingId = entry.getValue();
// Skip if it's the same notification ID or time difference is too large
if (existingId.equals(content.getId()) ||
Math.abs(scheduledTime - triggerTime) > toleranceMs) {
continue;
}
// Found an alarm scheduled for a time very close to this one
duplicateIds.add(existingId);
}
// Cancel any duplicate alarms found
for (String duplicateId : duplicateIds) {
Log.w(TAG, "Phase 3: Cancelling duplicate alarm for time " +
formatTime(triggerTime) + " (existing ID: " + duplicateId +
", new ID: " + content.getId() + ")");
cancelNotification(duplicateId);
}
// Create intent for the notification
Intent intent = new Intent(context, DailyNotificationReceiver.class);
intent.setAction(ACTION_NOTIFICATION);
@ -143,8 +178,10 @@ public class DailyNotificationScheduler {
// Store the pending intent
scheduledAlarms.put(content.getId(), pendingIntent);
// Track scheduled time to notification ID mapping
scheduledTimeToId.put(triggerTime, content.getId());
// Schedule the alarm
long triggerTime = content.getScheduledTime();
boolean scheduled = scheduleAlarm(pendingIntent, triggerTime);
if (scheduled) {
@ -421,6 +458,17 @@ public class DailyNotificationScheduler {
pendingIntentManager.cancelAlarm(pendingIntent);
pendingIntent.cancel();
Log.d(TAG, "Cancelled existing alarm for notification: " + notificationId);
// Remove from time-to-ID mapping by finding and removing the entry
java.util.List<Long> timesToRemove = new java.util.ArrayList<>();
for (java.util.Map.Entry<Long, String> entry : scheduledTimeToId.entrySet()) {
if (entry.getValue().equals(notificationId)) {
timesToRemove.add(entry.getKey());
}
}
for (Long time : timesToRemove) {
scheduledTimeToId.remove(time);
}
} else {
Log.d(TAG, "No existing alarm found to cancel for notification: " + notificationId);
}
@ -441,6 +489,7 @@ public class DailyNotificationScheduler {
}
scheduledAlarms.clear();
scheduledTimeToId.clear();
Log.i(TAG, "All notifications cancelled");
} catch (Exception e) {

35
test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/TestNativeFetcher.java

@ -95,8 +95,26 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher {
this.apiBaseUrl = apiBaseUrl;
this.activeDid = activeDid;
this.jwtToken = jwtToken;
Log.i(TAG, "TestNativeFetcher: Configured with API: " + apiBaseUrl +
", ActiveDID: " + (activeDid != null ? activeDid.substring(0, Math.min(20, activeDid.length())) + "..." : "null"));
// Enhanced logging for JWT diagnostic purposes
Log.i(TAG, "TestNativeFetcher: Configured with API: " + apiBaseUrl);
if (activeDid != null) {
Log.i(TAG, "TestNativeFetcher: ActiveDID: " + activeDid.substring(0, Math.min(30, activeDid.length())) +
(activeDid.length() > 30 ? "..." : ""));
} else {
Log.w(TAG, "TestNativeFetcher: ActiveDID is NULL");
}
if (jwtToken != null) {
Log.i(TAG, "TestNativeFetcher: JWT token received - Length: " + jwtToken.length() + " chars");
// Log first and last 10 chars for verification (not full token for security)
String tokenPreview = jwtToken.length() > 20
? jwtToken.substring(0, 10) + "..." + jwtToken.substring(jwtToken.length() - 10)
: jwtToken.substring(0, Math.min(jwtToken.length(), 20)) + "...";
Log.d(TAG, "TestNativeFetcher: JWT preview: " + tokenPreview);
} else {
Log.e(TAG, "TestNativeFetcher: JWT token is NULL - API calls will fail");
}
}
@Override
@ -141,6 +159,19 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher {
connection.setReadTimeout(READ_TIMEOUT_MS);
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/json");
// Diagnostic logging for JWT usage
if (jwtToken != null) {
String jwtPreview = jwtToken.length() > 20
? jwtToken.substring(0, 10) + "..." + jwtToken.substring(jwtToken.length() - 10)
: jwtToken;
Log.d(TAG, "TestNativeFetcher: Using JWT for API call - Length: " + jwtToken.length() +
", Preview: " + jwtPreview + ", ActiveDID: " +
(activeDid != null ? activeDid.substring(0, Math.min(30, activeDid.length())) + "..." : "null"));
} else {
Log.e(TAG, "TestNativeFetcher: JWT token is NULL when making API call!");
}
connection.setRequestProperty("Authorization", "Bearer " + jwtToken);
connection.setDoOutput(true);

66
test-apps/daily-notification-test/scripts/find-user-zero-key.js

@ -0,0 +1,66 @@
/**
* Script to find the correct derivation path for User Zero's DID
*
* User Zero DID: did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F
* Target address: 0x0000694B58C2cC69658993A90D3840C560f2F51F
*/
import { HDNodeWallet, Mnemonic } from 'ethers';
const seedPhrase = "rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage";
const targetAddress = "0x0000694B58C2cC69658993A90D3840C560f2F51F".toLowerCase();
console.log('Target address:', targetAddress);
console.log('Searching for matching derivation path...\n');
const mnemonic = Mnemonic.fromPhrase(seedPhrase);
let found = false;
// Try standard Ethereum derivation paths
for (let account = 0; account < 50; account++) {
try {
const path = `m/44'/60'/0'/0/${account}`;
const wallet = HDNodeWallet.fromPhrase(mnemonic.phrase, path);
const address = wallet.address.toLowerCase();
if (address === targetAddress) {
console.log(`✅✅✅ MATCH FOUND: ${path}`);
console.log('Address:', wallet.address);
console.log('Private key (hex, no 0x prefix):', wallet.privateKey.slice(2));
console.log('\nUse this path in generateEndorserJWT():');
console.log(`const wallet = HDNodeWallet.fromPhrase(mnemonic.phrase, "${path}");`);
found = true;
break;
}
if (account < 10) {
console.log(`Path ${path}: ${address}`);
}
} catch (e) {
console.error(`Error with path m/44'/60'/0'/0/${account}:`, e.message);
}
}
// Also try default path (no account index specified)
if (!found) {
try {
const wallet = HDNodeWallet.fromMnemonic(mnemonic);
const address = wallet.address.toLowerCase();
console.log(`\nDefault path (fromMnemonic): ${address}`);
if (address === targetAddress) {
console.log('✅✅✅ MATCH FOUND: Default path (fromMnemonic)');
found = true;
}
} catch (e) {
console.error('Error with default path:', e.message);
}
}
if (!found) {
console.log('\n❌ No match found in first 50 account indices');
console.log('Possible causes:');
console.log('1. Different derivation path (not m/44\'/60\'/0\'/0/X)');
console.log('2. Wrong seed phrase');
console.log('3. User Zero registered with different key source');
}

166
test-apps/daily-notification-test/src/config/test-user-zero.ts

@ -11,8 +11,8 @@
// Lazy import logger to avoid ES module issues when loaded by Capacitor CLI (CommonJS)
// Logger is only used inside functions, not at module scope
let logger: { error: (...args: unknown[]) => void; custom: (emoji: string, ...args: unknown[]) => void } | null = null;
const getLogger = async (): Promise<{ error: (...args: unknown[]) => void; custom: (emoji: string, ...args: unknown[]) => void } | null> => {
let logger: { error: (...args: unknown[]) => void; custom: (emoji: string, ...args: unknown[]) => void; info: (...args: unknown[]) => void } | null = null;
const getLogger = async (): Promise<{ error: (...args: unknown[]) => void; custom: (emoji: string, ...args: unknown[]) => void; info: (...args: unknown[]) => void } | null> => {
if (!logger) {
const loggerModule = await import('../lib/logger');
logger = loggerModule.logger;
@ -25,7 +25,14 @@ export const TEST_USER_ZERO_CONFIG = {
identity: {
did: "did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F",
name: "User Zero",
seedPhrase: "rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage"
seedPhrase: "rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage",
// User Zero's known public key for verification
// Public Key (hex): 03b231e73413f1d36b3327763ad9a44bb81fc91480a14977ab3cfd32df0564af1d
// Public Key (base64): A7Ix5zQT8dNrMyd2OtmkS7gfyRSAoUl3qzz9Mt8FZK8d
// Derivation Path: m/84737769'/0'/0'/0'
expectedPublicKeyHex: "03b231e73413f1d36b3327763ad9a44bb81fc91480a14977ab3cfd32df0564af1d",
expectedPublicKeyBase64: "A7Ix5zQT8dNrMyd2OtmkS7gfyRSAoUl3qzz9Mt8FZK8d",
derivationPath: "m/84737769'/0'/0'/0'"
},
// API Configuration
@ -226,10 +233,15 @@ export const MOCK_STARRED_PROJECTS_RESPONSE = {
/**
* Generate ES256K signed JWT token for User Zero using DID-based signing
*
* This function mimics TimeSafari's createEndorserJwtForKey() function,
* using did-jwt library with ES256K algorithm (DID-based signing).
* This function generates a JWT token signed with User Zero's private key,
* using User Zero's DID and seed phrase from TEST_USER_ZERO_CONFIG.
*
* @returns Promise<string> ES256K signed JWT token
* Uses TimeSafari's approach (mimics createEndorserJwtForKey() function):
* - did-jwt library with ES256K algorithm (DID-based signing)
* - Ethereum private key derived from User Zero's seed phrase
* - JWT payload includes User Zero's DID as issuer and subject
*
* @returns Promise<string> ES256K signed JWT token for User Zero
*/
export async function generateEndorserJWT(): Promise<string> {
try {
@ -237,44 +249,148 @@ export async function generateEndorserJWT(): Promise<string> {
const { createJWT, SimpleSigner } = await import('did-jwt');
const { HDNodeWallet, Mnemonic } = await import('ethers');
// Derive Ethereum private key from seed phrase
// Using the same derivation as TimeSafari (m/44'/60'/0'/0/0 - Ethereum standard)
const mnemonic = Mnemonic.fromPhrase(TEST_USER_ZERO_CONFIG.identity.seedPhrase);
const wallet = HDNodeWallet.fromMnemonic(mnemonic);
// Derive Ethereum private key from User Zero's seed phrase
// Using User Zero's specific derivation path: m/84737769'/0'/0'/0'
// This is the path used when User Zero was registered on the server
// NOTE: Must use fromMnemonic() with the Mnemonic object, not fromPhrase() with phrase string
const userZeroSeedPhrase = TEST_USER_ZERO_CONFIG.identity.seedPhrase;
const mnemonic = Mnemonic.fromPhrase(userZeroSeedPhrase);
const derivationPath = "m/84737769'/0'/0'/0'";
const wallet = HDNodeWallet.fromMnemonic(mnemonic, derivationPath);
const privateKeyHex = wallet.privateKey.slice(2); // Remove '0x' prefix
// Create signer for ES256K (Ethereum secp256k1 curve)
// Extract public key from wallet in compressed format
// ethers.js wallet.signingKey.compressedPublicKey provides the compressed format
// Compressed format: 33 bytes (0x02 or 0x03 prefix + 32-byte x-coordinate)
const signingKey = wallet.signingKey;
const compressedPublicKey = signingKey.compressedPublicKey; // This should be the compressed public key
const publicKeyHexRaw = compressedPublicKey ? compressedPublicKey.slice(2).toLowerCase() : null;
// Fallback: if compressed public key not available, use first 66 hex chars from uncompressed
// Note: This is a workaround - we should have compressedPublicKey from signingKey
const publicKeyHexFinal = publicKeyHexRaw || wallet.publicKey.slice(2).substring(0, 66).toLowerCase();
// Verify derived address matches User Zero's DID
const derivedAddress = wallet.address.toLowerCase();
const expectedAddress = TEST_USER_ZERO_CONFIG.identity.did.replace('did:ethr:', '').toLowerCase();
const logger = await getLogger();
// Verify public key matches expected (if provided)
const expectedPublicKeyHex = TEST_USER_ZERO_CONFIG.identity.expectedPublicKeyHex;
// Comprehensive verification logging
if (derivedAddress !== expectedAddress) {
logger.error('⚠️ Key derivation address mismatch!', {
derivedAddress: derivedAddress,
expectedAddress: expectedAddress,
derivationPath: derivationPath,
note: 'JWT signing may fail if server expects different key'
});
logger.error('⚠️ CRITICAL: Derived address does not match User Zero DID!', {
derived: derivedAddress,
expected: expectedAddress,
path: derivationPath,
warning: 'JWT verification will likely fail on server'
});
} else {
logger.custom('✅', 'Key derivation verified - address matches User Zero DID');
}
// Verify public key if expected value is provided
if (expectedPublicKeyHex && publicKeyHexFinal) {
// Compare the derived public key with expected (both should be compressed format)
const publicKeyMatchesActual = publicKeyHexFinal.toLowerCase() === expectedPublicKeyHex.toLowerCase();
if (!publicKeyMatchesActual) {
logger.error('⚠️ CRITICAL: Derived public key does not match expected!', {
derivedPublicKey: publicKeyHexFinal,
expectedPublicKey: expectedPublicKeyHex,
derivationPath: derivationPath,
warning: 'Server will not recognize this public key - JWT verification will fail'
});
logger.error('⚠️ Public Key Mismatch Details:', {
derived: publicKeyHexFinal.substring(0, 10) + '...' + publicKeyHexFinal.substring(publicKeyHexFinal.length - 10),
expected: expectedPublicKeyHex.substring(0, 10) + '...' + expectedPublicKeyHex.substring(expectedPublicKeyHex.length - 10),
derivedLength: publicKeyHexFinal.length,
expectedLength: expectedPublicKeyHex.length,
derivedPrefix: publicKeyHexFinal.substring(0, 2), // Should be 02 or 03
expectedPrefix: expectedPublicKeyHex.substring(0, 2),
note: 'Verify derivation path and seed phrase are correct'
});
} else {
logger.custom('✅', 'Public key verified - matches expected User Zero public key');
logger.info('✅ Public Key Verification:', {
derived: publicKeyHexFinal.substring(0, 20) + '...' + publicKeyHexFinal.substring(publicKeyHexFinal.length - 20),
expected: expectedPublicKeyHex.substring(0, 20) + '...' + expectedPublicKeyHex.substring(expectedPublicKeyHex.length - 20),
match: true
});
}
}
// Log comprehensive key derivation diagnostics
logger.info('🔐 JWT Key Derivation: Complete diagnostics', {
address: derivedAddress,
addressMatch: derivedAddress === expectedAddress,
derivationPath: derivationPath,
publicKeyHex: publicKeyHexFinal ? (publicKeyHexFinal.substring(0, 20) + '...' + publicKeyHexFinal.substring(publicKeyHexFinal.length - 20)) : 'N/A',
expectedPublicKeyHex: expectedPublicKeyHex ? (expectedPublicKeyHex.substring(0, 20) + '...' + expectedPublicKeyHex.substring(expectedPublicKeyHex.length - 20)) : 'N/A',
publicKeyMatch: expectedPublicKeyHex && publicKeyHexFinal ? (publicKeyHexFinal.toLowerCase() === expectedPublicKeyHex.toLowerCase()) : null,
publicKeyLength: publicKeyHexFinal?.length || 0,
expectedPublicKeyLength: expectedPublicKeyHex?.length || 0,
hasCompressedPublicKey: !!compressedPublicKey
});
// Create signer for ES256K (Ethereum secp256k1 curve) using User Zero's private key
const signer = SimpleSigner(privateKeyHex);
// Create JWT payload with standard claims
// Create JWT payload with User Zero's DID as issuer and subject
const nowEpoch = Math.floor(Date.now() / 1000);
const expiresIn = TEST_USER_ZERO_CONFIG.api.jwtExpirationMinutes * 60;
const userZeroDid = TEST_USER_ZERO_CONFIG.identity.did;
const payload = {
// Standard JWT claims
// Standard JWT claims - all using User Zero's DID
iat: nowEpoch,
exp: nowEpoch + expiresIn,
iss: TEST_USER_ZERO_CONFIG.identity.did,
sub: TEST_USER_ZERO_CONFIG.identity.did,
// Additional claims that endorser-ch might expect
aud: "endorser-ch"
iss: userZeroDid, // User Zero's DID as issuer
sub: userZeroDid, // User Zero's DID as subject
// NOTE: aud (audience) claim is omitted because server's did-jwt verifyJWT()
// requires audience option when aud is present, but server isn't configured
// to validate it. Server will reject JWTs with aud claim until it adds support.
};
// Create ES256K signed JWT (ES256K is the default algorithm for did-jwt)
// Create ES256K signed JWT using User Zero's DID and private key
// ES256K is the default algorithm for did-jwt
// NOTE: sub claim is included in payload; aud is omitted because server's
// did-jwt verifyJWT() requires audience option when aud is present, but
// server isn't configured to validate it
const jwt = await createJWT(payload, {
issuer: TEST_USER_ZERO_CONFIG.identity.did,
signer: signer,
issuer: userZeroDid, // User Zero's DID (overwrites payload.iss)
signer: signer, // User Zero's private key signer
expiresIn: expiresIn
});
const log = await getLogger();
log.custom("🔐", "JWT generated - Algorithm: ES256K, DID:", TEST_USER_ZERO_CONFIG.identity.did.substring(0, 30) + "...");
log.custom("🔐", "JWT length:", jwt.length, "characters");
logger.custom("🔐", "JWT generated for User Zero - Algorithm: ES256K, DID:", userZeroDid.substring(0, 30) + "...");
logger.custom("🔐", "JWT length:", jwt.length, "characters");
// Enhanced diagnostic logging
logger.info('🔐 JWT Generation Diagnostics:', {
algorithm: 'ES256K',
issuerDid: userZeroDid,
issuerAddress: userZeroDid.replace('did:ethr:', '').toLowerCase(),
derivedAddress: derivedAddress,
derivationPath: derivationPath,
addressMatch: derivedAddress === expectedAddress,
jwtLength: jwt.length,
jwtPreview: jwt.substring(0, 20) + '...' + jwt.substring(jwt.length - 20),
expiresIn: expiresIn,
nowEpoch: nowEpoch
});
return jwt;
} catch (error) {
const log = await getLogger();
log.error('Failed to generate ES256K JWT:', error);
const logger = await getLogger();
logger.error('Failed to generate ES256K JWT:', error);
throw new Error(`JWT generation failed: ${error instanceof Error ? error.message : String(error)}`);
}
}

23
test-apps/daily-notification-test/src/views/HomeView.vue

@ -450,7 +450,9 @@ const configureNativeFetcher = async (): Promise<void> => {
try {
console.log('🚀 HomeView: Starting native fetcher configuration...')
logger.info('Configuring native fetcher from HomeView...')
console.log('👤 HomeView: Using User Zero identity:', TEST_USER_ZERO_CONFIG.identity.name)
console.log('🔑 HomeView: User Zero DID:', TEST_USER_ZERO_CONFIG.identity.did)
logger.info('Configuring native fetcher from HomeView using User Zero identity...')
// Get API server URL
const apiBaseUrl = TEST_USER_ZERO_CONFIG.getApiServerUrl()
@ -465,22 +467,25 @@ const configureNativeFetcher = async (): Promise<void> => {
return
}
console.log('🔧 HomeView: Generating ES256K JWT token...')
// Generate JWT token for authentication
console.log('🔧 HomeView: Generating ES256K JWT token for User Zero...')
// Generate JWT token for authentication using User Zero's DID and seed phrase
// This uses TEST_USER_ZERO_CONFIG.identity.did and TEST_USER_ZERO_CONFIG.identity.seedPhrase
const jwtToken = await generateEndorserJWT()
console.log('✅ HomeView: JWT token generated, length:', jwtToken.length)
console.log('✅ HomeView: JWT token generated for User Zero, length:', jwtToken.length)
console.log('🔧 HomeView: Calling configureNativeFetcher with:', {
console.log('🔧 HomeView: Configuring native fetcher with User Zero credentials:', {
apiBaseUrl,
activeDid: TEST_USER_ZERO_CONFIG.identity.did.substring(0, 30) + '...',
activeDid: TEST_USER_ZERO_CONFIG.identity.did,
userZeroName: TEST_USER_ZERO_CONFIG.identity.name,
jwtTokenLength: jwtToken.length
})
// Configure native fetcher with credentials
// Configure native fetcher with User Zero's credentials
// This passes User Zero's DID and JWT token (signed with User Zero's private key)
await DailyNotification.configureNativeFetcher({
apiBaseUrl: apiBaseUrl,
activeDid: TEST_USER_ZERO_CONFIG.identity.did,
jwtToken: jwtToken
activeDid: TEST_USER_ZERO_CONFIG.identity.did, // User Zero's DID
jwtToken: jwtToken // JWT signed with User Zero's private key derived from seed phrase
})
console.log('✅ HomeView: Native fetcher configured successfully!')

Loading…
Cancel
Save