• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

agentic-dev-library / thumbcode / 21329811829

25 Jan 2026 08:41AM UTC coverage: 23.051% (+0.02%) from 23.03%
21329811829

Pull #96

github

web-flow
Merge 4e792c9be into 057514a46
Pull Request #96: âš¡ Parallelize credential storage checks

278 of 1858 branches covered (14.96%)

Branch coverage included in aggregate %.

0 of 3 new or added lines in 1 file covered. (0.0%)

14 existing lines in 1 file now uncovered.

742 of 2567 relevant lines covered (28.91%)

1.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

4.9
/packages/core/src/credentials/CredentialService.ts
1
/**
2
 * Credential Service
3
 *
4
 * Provides secure credential management using Expo SecureStore with
5
 * hardware-backed encryption. This is a low-level service that handles
6
 * only secure storage and validation - no state management.
7
 *
8
 * Security Features:
9
 * - Hardware-backed secure enclave storage (SecureStore)
10
 * - Biometric authentication support
11
 * - Automatic token validation
12
 * - Secure deletion
13
 */
14

15
import * as LocalAuthentication from 'expo-local-authentication';
16
import * as SecureStore from 'expo-secure-store';
17
import { secureFetch } from '../api/api';
18
import { validate } from './validation';
19

20
import type {
21
  BiometricResult,
22
  CredentialType,
23
  RetrieveOptions,
24
  RetrieveResult,
25
  SecureCredential,
26
  StoreOptions,
27
  ValidationResult,
28
} from './types';
29

30
// SecureStore key prefixes for different credential types
31
const SECURE_STORE_KEYS: Record<CredentialType, string> = {
2✔
32
  github: 'thumbcode_cred_github',
33
  anthropic: 'thumbcode_cred_anthropic',
34
  openai: 'thumbcode_cred_openai',
35
  mcp_server: 'thumbcode_cred_mcp',
36
  gitlab: 'thumbcode_cred_gitlab',
37
  bitbucket: 'thumbcode_cred_bitbucket',
38
  mcp_signing_secret: 'thumbcode_cred_mcp_signing_secret',
39
};
40

41
// Validation API endpoints
42
const VALIDATION_ENDPOINTS = {
2✔
43
  github: 'https://api.github.com/user',
44
  anthropic: 'https://api.anthropic.com/v1/messages',
45
  openai: 'https://api.openai.com/v1/models',
46
} as const;
47

48
/**
49
 * Credential Service for secure credential management
50
 */
51
class CredentialServiceClass {
52
  /**
53
   * Check if biometric authentication is available on the device
54
   */
55
  async isBiometricAvailable(): Promise<boolean> {
56
    const hasHardware = await LocalAuthentication.hasHardwareAsync();
×
57
    const isEnrolled = await LocalAuthentication.isEnrolledAsync();
×
58
    return hasHardware && isEnrolled;
×
59
  }
60

61
  /**
62
   * Get the available biometric authentication types
63
   */
64
  async getBiometricTypes(): Promise<LocalAuthentication.AuthenticationType[]> {
65
    return LocalAuthentication.supportedAuthenticationTypesAsync();
×
66
  }
67

68
  /**
69
   * Perform biometric authentication
70
   */
71
  async authenticateWithBiometrics(
72
    promptMessage = 'Authenticate to access your credentials'
×
73
  ): Promise<BiometricResult> {
74
    try {
×
75
      const result = await LocalAuthentication.authenticateAsync({
×
76
        promptMessage,
77
        cancelLabel: 'Cancel',
78
        disableDeviceFallback: false,
79
        fallbackLabel: 'Use passcode',
80
      });
81

82
      if (result.success) {
×
83
        return { success: true };
×
84
      }
85
      return {
×
86
        success: false,
87
        error: result.error,
88
      };
89
    } catch (error) {
90
      return {
×
91
        success: false,
92
        error: error instanceof Error ? error.message : 'Authentication failed',
×
93
      };
94
    }
95
  }
96

97
  /**
98
   * Store a credential securely
99
   *
100
   * @param type - The type of credential
101
   * @param secret - The secret value to store
102
   * @param options - Storage options
103
   * @returns Validation result
104
   */
105
  async store(
106
    type: CredentialType,
107
    secret: string,
108
    options: StoreOptions = {}
3✔
109
  ): Promise<ValidationResult> {
110
    const { requireBiometric = false, skipValidation = false } = options;
3✔
111

112
    if (!validate(type, secret)) {
3!
113
      return { isValid: false, message: 'Invalid credential format' };
3✔
114
    }
115

116
    // Biometric check if required
117
    if (requireBiometric) {
×
118
      const biometricResult = await this.authenticateWithBiometrics();
×
119
      if (!biometricResult.success) {
×
120
        return { isValid: false, message: 'Biometric authentication failed' };
×
121
      }
122
    }
123

124
    // Validate the credential before storing (unless skipped)
125
    if (!skipValidation) {
×
126
      const validation = await this.validateCredential(type, secret);
×
127
      if (!validation.isValid) {
×
128
        return validation;
×
129
      }
130
    }
131

132
    // Store the secret in SecureStore
133
    const key = SECURE_STORE_KEYS[type];
×
134
    try {
×
135
      const payload: SecureCredential = {
×
136
        secret,
137
        storedAt: new Date().toISOString(),
138
        type,
139
      };
140

141
      await SecureStore.setItemAsync(key, JSON.stringify(payload), {
×
142
        keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
143
      });
144

145
      return { isValid: true, message: 'Credential stored successfully' };
×
146
    } catch (error) {
147
      return {
×
148
        isValid: false,
149
        message: error instanceof Error ? error.message : 'Failed to store credential',
×
150
      };
151
    }
152
  }
153

154
  /**
155
   * Retrieve a credential securely
156
   *
157
   * @param type - The type of credential to retrieve
158
   * @param options - Retrieval options
159
   */
160
  async retrieve(type: CredentialType, options: RetrieveOptions = {}): Promise<RetrieveResult> {
×
161
    const { requireBiometric = false } = options;
×
162

163
    // Biometric check if required
164
    if (requireBiometric) {
×
165
      const biometricResult = await this.authenticateWithBiometrics();
×
166
      if (!biometricResult.success) {
×
167
        return { secret: null };
×
168
      }
169
    }
170

171
    try {
×
172
      const key = SECURE_STORE_KEYS[type];
×
173
      const payload = await SecureStore.getItemAsync(key);
×
174

175
      if (!payload) {
×
176
        return { secret: null };
×
177
      }
178

179
      const data: SecureCredential = JSON.parse(payload);
×
180

181
      return {
×
182
        secret: data.secret,
183
        metadata: {
184
          storedAt: data.storedAt,
185
          type: data.type,
186
        },
187
      };
188
    } catch (error) {
189
      console.error('Failed to retrieve credential:', error);
×
190
      return { secret: null };
×
191
    }
192
  }
193

194
  /**
195
   * Validate a credential against its respective API
196
   *
197
   * @param type - The credential type
198
   * @param secret - The secret to validate
199
   */
200
  async validateCredential(type: CredentialType, secret: string): Promise<ValidationResult> {
201
    if (!validate(type, secret)) {
×
202
      return { isValid: false, message: 'Invalid credential format' };
×
203
    }
204

205
    try {
×
206
      switch (type) {
×
207
        case 'github':
208
          return this.validateGitHubToken(secret);
×
209
        case 'anthropic':
210
          return this.validateAnthropicKey(secret);
×
211
        case 'openai':
212
          return this.validateOpenAIKey(secret);
×
213
        case 'mcp_server':
214
          // MCP servers require specific validation per server
215
          return { isValid: true, message: 'MCP server credentials accepted' };
×
216
        default:
217
          return { isValid: true, message: 'Credential accepted without validation' };
×
218
      }
219
    } catch (error) {
220
      return {
×
221
        isValid: false,
222
        message: error instanceof Error ? error.message : 'Validation failed',
×
223
      };
224
    }
225
  }
226

227
  /**
228
   * Validate a GitHub personal access token
229
   */
230
  private async validateGitHubToken(token: string): Promise<ValidationResult> {
231
    try {
×
232
      const response = await secureFetch(VALIDATION_ENDPOINTS.github, {
×
233
        headers: {
234
          Authorization: `Bearer ${token}`,
235
          Accept: 'application/vnd.github.v3+json',
236
        },
237
      });
238

239
      if (!response.ok) {
×
240
        if (response.status === 401) {
×
241
          return { isValid: false, message: 'Invalid GitHub token' };
×
242
        }
243
        return { isValid: false, message: `GitHub API error: ${response.status}` };
×
244
      }
245

246
      const user = await response.json();
×
247

248
      // Check for expiration header if present
249
      const expiresAt = response.headers.get('github-authentication-token-expiration');
×
250

251
      return {
×
252
        isValid: true,
253
        message: `Authenticated as ${user.login}`,
254
        expiresAt: expiresAt ? new Date(expiresAt) : undefined,
×
255
        metadata: {
256
          username: user.login,
257
          avatarUrl: user.avatar_url,
258
          name: user.name,
259
          scopes: response.headers.get('x-oauth-scopes')?.split(', ') || [],
×
260
          rateLimit: parseInt(response.headers.get('x-ratelimit-remaining') || '0', 10),
×
261
        },
262
      };
263
    } catch (error) {
264
      return {
×
265
        isValid: false,
266
        message: error instanceof Error ? error.message : 'GitHub validation failed',
×
267
      };
268
    }
269
  }
270

271
  /**
272
   * Validate an Anthropic API key
273
   */
274
  private async validateAnthropicKey(apiKey: string): Promise<ValidationResult> {
275
    try {
×
276
      // For Anthropic, we make a minimal request to check the key
277
      const response = await secureFetch(VALIDATION_ENDPOINTS.anthropic, {
×
278
        method: 'POST',
279
        headers: {
280
          'x-api-key': apiKey,
281
          'anthropic-version': '2023-06-01',
282
          'Content-Type': 'application/json',
283
        },
284
        body: JSON.stringify({
285
          model: 'claude-3-haiku-20240307',
286
          max_tokens: 1,
287
          messages: [{ role: 'user', content: 'Hi' }],
288
        }),
289
      });
290

291
      // We expect the request to work, indicating valid key
292
      if (response.ok || response.status === 200) {
×
293
        return {
×
294
          isValid: true,
295
          message: 'Anthropic API key is valid',
296
          metadata: {
297
            rateLimit: parseInt(
298
              response.headers.get('anthropic-ratelimit-requests-remaining') || '0',
×
299
              10
300
            ),
301
          },
302
        };
303
      }
304

305
      // 401 means invalid key
306
      if (response.status === 401) {
×
307
        return { isValid: false, message: 'Invalid Anthropic API key' };
×
308
      }
309

310
      // 429 means rate limited but key is valid
311
      if (response.status === 429) {
×
312
        return {
×
313
          isValid: true,
314
          message: 'Anthropic API key valid but rate limited',
315
        };
316
      }
317

318
      return { isValid: false, message: `Anthropic API error: ${response.status}` };
×
319
    } catch (error) {
320
      return {
×
321
        isValid: false,
322
        message: error instanceof Error ? error.message : 'Anthropic validation failed',
×
323
      };
324
    }
325
  }
326

327
  /**
328
   * Validate an OpenAI API key
329
   */
330
  private async validateOpenAIKey(apiKey: string): Promise<ValidationResult> {
331
    try {
×
332
      const response = await secureFetch(VALIDATION_ENDPOINTS.openai, {
×
333
        headers: {
334
          Authorization: `Bearer ${apiKey}`,
335
        },
336
      });
337

338
      if (response.ok) {
×
339
        return {
×
340
          isValid: true,
341
          message: 'OpenAI API key is valid',
342
          metadata: {
343
            rateLimit: parseInt(response.headers.get('x-ratelimit-remaining-requests') || '0', 10),
×
344
          },
345
        };
346
      }
347

348
      if (response.status === 401) {
×
349
        return { isValid: false, message: 'Invalid OpenAI API key' };
×
350
      }
351

352
      return { isValid: false, message: `OpenAI API error: ${response.status}` };
×
353
    } catch (error) {
354
      return {
×
355
        isValid: false,
356
        message: error instanceof Error ? error.message : 'OpenAI validation failed',
×
357
      };
358
    }
359
  }
360

361
  /**
362
   * Delete a credential securely
363
   *
364
   * @param type - The credential type to delete
365
   */
366
  async delete(type: CredentialType): Promise<boolean> {
367
    try {
×
368
      const key = SECURE_STORE_KEYS[type];
×
369
      await SecureStore.deleteItemAsync(key);
×
370
      return true;
×
371
    } catch (error) {
372
      console.error('Failed to delete credential:', error);
×
373
      return false;
×
374
    }
375
  }
376

377
  /**
378
   * Check if a credential exists
379
   *
380
   * @param type - The credential type to check
381
   */
382
  async exists(type: CredentialType): Promise<boolean> {
383
    try {
×
384
      const key = SECURE_STORE_KEYS[type];
×
385
      const value = await SecureStore.getItemAsync(key);
×
386
      return value !== null;
×
387
    } catch {
388
      return false;
×
389
    }
390
  }
391

392
  /**
393
   * Get all stored credential types
394
   */
395
  async getStoredCredentialTypes(): Promise<CredentialType[]> {
NEW
396
    const types = Object.keys(SECURE_STORE_KEYS) as CredentialType[];
×
NEW
397
    const results = await Promise.all(types.map((type) => this.exists(type)));
×
NEW
398
    return types.filter((_, index) => results[index]);
×
399
  }
400

401
  /**
402
   * Mask a secret for display purposes
403
   */
404
  maskSecret(secret: string, type: CredentialType): string {
UNCOV
405
    if (!secret) return '';
×
406

UNCOV
407
    switch (type) {
×
408
      case 'github':
409
        // GitHub tokens: ghp_xxxx...
UNCOV
410
        if (
×
411
          secret.startsWith('ghp_') ||
×
412
          secret.startsWith('gho_') ||
413
          secret.startsWith('ghs_')
414
        ) {
UNCOV
415
          return `${secret.slice(0, 7)}...${secret.slice(-4)}`;
×
416
        }
UNCOV
417
        return `${secret.slice(0, 4)}...${secret.slice(-4)}`;
×
418

419
      case 'anthropic':
420
        // Anthropic keys: sk-ant-...
421
        if (secret.startsWith('sk-ant-')) {
×
UNCOV
422
          return `sk-ant-...${secret.slice(-4)}`;
×
423
        }
UNCOV
424
        return `${secret.slice(0, 4)}...${secret.slice(-4)}`;
×
425

426
      case 'openai':
427
        // OpenAI keys: sk-...
428
        if (secret.startsWith('sk-')) {
×
UNCOV
429
          return `sk-...${secret.slice(-4)}`;
×
430
        }
UNCOV
431
        return `${secret.slice(0, 4)}...${secret.slice(-4)}`;
×
432

433
      default:
UNCOV
434
        return `${secret.slice(0, 4)}...${secret.slice(-4)}`;
×
435
    }
436
  }
437

438
  /**
439
   * Validate all stored credentials and return results
440
   */
441
  async validateAllStored(): Promise<Map<CredentialType, ValidationResult>> {
UNCOV
442
    const results = new Map<CredentialType, ValidationResult>();
×
UNCOV
443
    const storedTypes = await this.getStoredCredentialTypes();
×
444

UNCOV
445
    for (const type of storedTypes) {
×
446
      const { secret } = await this.retrieve(type);
×
447
      if (secret) {
×
UNCOV
448
        const result = await this.validateCredential(type, secret);
×
449
        results.set(type, result);
×
450
      }
451
    }
452

453
    return results;
×
454
  }
455
}
456

457
// Export singleton instance
458
export const CredentialService = new CredentialServiceClass();
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc