@canon-protocol/sdk 8.0.1 → 8.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/AuthenticatedFetch.d.ts +11 -0
- package/dist/auth/AuthenticatedFetch.js +58 -0
- package/dist/auth/CredentialBackend.d.ts +31 -0
- package/dist/auth/CredentialBackend.js +21 -0
- package/dist/auth/CredentialHelperBackend.d.ts +17 -0
- package/dist/auth/CredentialHelperBackend.js +93 -0
- package/dist/auth/CredentialStore.d.ts +29 -0
- package/dist/auth/CredentialStore.js +122 -0
- package/dist/auth/DPoP.d.ts +28 -0
- package/dist/auth/DPoP.js +87 -0
- package/dist/auth/EncryptedFileBackend.d.ts +18 -0
- package/dist/auth/EncryptedFileBackend.js +94 -0
- package/dist/auth/KeychainBackend.d.ts +11 -0
- package/dist/auth/KeychainBackend.js +104 -0
- package/dist/auth/SecretServiceBackend.d.ts +23 -0
- package/dist/auth/SecretServiceBackend.js +103 -0
- package/dist/auth/WinCredBackend.d.ts +19 -0
- package/dist/auth/WinCredBackend.js +172 -0
- package/dist/auth/index.d.ts +11 -0
- package/dist/auth/index.js +9 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/repositories/HttpCanonDocumentRepository.d.ts +3 -0
- package/dist/repositories/HttpCanonDocumentRepository.js +9 -2
- package/dist/repositories/PublisherConfig.d.ts +1 -0
- package/dist/repositories/PublisherConfig.js +3 -0
- package/dist/repositories/PublisherIndex.d.ts +6 -1
- package/dist/repositories/PublisherIndex.js +7 -3
- package/package.json +2 -2
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CredentialStore } from './CredentialStore.js';
|
|
2
|
+
/**
|
|
3
|
+
* A fetch function that adds authentication headers for a publisher.
|
|
4
|
+
* Accepts an optional method parameter for correct DPoP proof generation.
|
|
5
|
+
*/
|
|
6
|
+
export type AuthenticatedFetchFn = (url: string, publisher: string, method?: string) => Promise<Response>;
|
|
7
|
+
/**
|
|
8
|
+
* Create an authenticated fetch function that adds Bearer or DPoP
|
|
9
|
+
* authorization headers based on stored credentials.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createAuthenticatedFetch(credentialStore?: CredentialStore): AuthenticatedFetchFn;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { isExpired } from './CredentialBackend.js';
|
|
2
|
+
import { createDPoPProof } from './DPoP.js';
|
|
3
|
+
/**
|
|
4
|
+
* Create an authenticated fetch function that adds Bearer or DPoP
|
|
5
|
+
* authorization headers based on stored credentials.
|
|
6
|
+
*/
|
|
7
|
+
export function createAuthenticatedFetch(credentialStore) {
|
|
8
|
+
const dpopNonces = new Map();
|
|
9
|
+
return async (url, publisher, method = 'GET') => {
|
|
10
|
+
if (!credentialStore) {
|
|
11
|
+
return fetch(url, { method });
|
|
12
|
+
}
|
|
13
|
+
const credential = await credentialStore.getCredential(publisher);
|
|
14
|
+
if (!credential?.accessToken) {
|
|
15
|
+
return fetch(url, { method });
|
|
16
|
+
}
|
|
17
|
+
if (isExpired(credential)) {
|
|
18
|
+
console.warn(` Warning: Access token for '${publisher}' is expired. Run 'canon login ${publisher}' to re-authenticate.`);
|
|
19
|
+
// Still attempt the request — the server will return 401 which is more informative than failing silently
|
|
20
|
+
}
|
|
21
|
+
const headers = {};
|
|
22
|
+
if (credential.dpopKeyPair) {
|
|
23
|
+
const nonce = dpopNonces.get(publisher);
|
|
24
|
+
try {
|
|
25
|
+
const proof = createDPoPProof(credential.dpopKeyPair.privateKey, credential.dpopKeyPair.publicKey, method, url, credential.accessToken, nonce);
|
|
26
|
+
headers['Authorization'] = `DPoP ${credential.accessToken}`;
|
|
27
|
+
headers['DPoP'] = proof;
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
31
|
+
console.error(` Error: Failed to create DPoP proof for '${publisher}': ${msg}\n` +
|
|
32
|
+
` The stored key pair may be corrupted. Run 'canon login ${publisher}' to re-authenticate.`);
|
|
33
|
+
// Fall back to Bearer as a best effort
|
|
34
|
+
headers['Authorization'] = `Bearer ${credential.accessToken}`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
headers['Authorization'] = `Bearer ${credential.accessToken}`;
|
|
39
|
+
}
|
|
40
|
+
let response = await fetch(url, { method, headers });
|
|
41
|
+
// Handle DPoP nonce requirement (RFC 9449 Section 7.1)
|
|
42
|
+
if (response.status === 401 && credential.dpopKeyPair) {
|
|
43
|
+
const newNonce = response.headers.get('DPoP-Nonce');
|
|
44
|
+
if (newNonce) {
|
|
45
|
+
dpopNonces.set(publisher, newNonce);
|
|
46
|
+
try {
|
|
47
|
+
const retryProof = createDPoPProof(credential.dpopKeyPair.privateKey, credential.dpopKeyPair.publicKey, method, url, credential.accessToken, newNonce);
|
|
48
|
+
headers['DPoP'] = retryProof;
|
|
49
|
+
response = await fetch(url, { method, headers });
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// DPoP retry failed — return the original 401
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return response;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pluggable credential storage backend interface.
|
|
3
|
+
* Implementations store OAuth credentials per publisher domain
|
|
4
|
+
* in platform-specific secure stores.
|
|
5
|
+
*/
|
|
6
|
+
export interface CredentialBackend {
|
|
7
|
+
get(publisher: string): Promise<StoredCredential | null>;
|
|
8
|
+
store(publisher: string, credential: StoredCredential): Promise<void>;
|
|
9
|
+
remove(publisher: string): Promise<void>;
|
|
10
|
+
list(): Promise<string[]>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* OAuth credential stored per publisher host.
|
|
14
|
+
* Matches the OAuthCredentialStore types from @canon-protocol/types.
|
|
15
|
+
*/
|
|
16
|
+
export interface StoredCredential {
|
|
17
|
+
clientId?: string | null;
|
|
18
|
+
clientSecret?: string | null;
|
|
19
|
+
accessToken?: string | null;
|
|
20
|
+
refreshToken?: string | null;
|
|
21
|
+
expiresAt?: string | null;
|
|
22
|
+
tokenEndpoint?: string | null;
|
|
23
|
+
dpopKeyPair?: DPoPKeyPair | null;
|
|
24
|
+
}
|
|
25
|
+
export interface DPoPKeyPair {
|
|
26
|
+
publicKey: Record<string, unknown>;
|
|
27
|
+
privateKey: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
export declare function isExpired(credential: StoredCredential): boolean;
|
|
30
|
+
export declare function hasValidToken(credential: StoredCredential): boolean;
|
|
31
|
+
export declare function normalizeHost(host: string): string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function isExpired(credential) {
|
|
2
|
+
if (!credential.expiresAt)
|
|
3
|
+
return false;
|
|
4
|
+
const expiresAt = new Date(credential.expiresAt);
|
|
5
|
+
const bufferMs = 5 * 60 * 1000; // 5 minute buffer
|
|
6
|
+
return expiresAt.getTime() <= Date.now() + bufferMs;
|
|
7
|
+
}
|
|
8
|
+
export function hasValidToken(credential) {
|
|
9
|
+
return !!credential.accessToken && !isExpired(credential);
|
|
10
|
+
}
|
|
11
|
+
export function normalizeHost(host) {
|
|
12
|
+
const normalized = host
|
|
13
|
+
.replace(/^https?:\/\//, '')
|
|
14
|
+
.replace(/^git:\/\//, '')
|
|
15
|
+
.replace(/\/+$/, '')
|
|
16
|
+
.trim();
|
|
17
|
+
if (!normalized) {
|
|
18
|
+
throw new Error('Publisher host cannot be empty');
|
|
19
|
+
}
|
|
20
|
+
return normalized;
|
|
21
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CredentialBackend, StoredCredential } from './CredentialBackend.js';
|
|
2
|
+
/**
|
|
3
|
+
* External credential helper backend.
|
|
4
|
+
* Delegates to an enterprise-configured binary using a stdin/stdout JSON protocol.
|
|
5
|
+
*
|
|
6
|
+
* Configure in ~/.canon/config.json:
|
|
7
|
+
* { "credentialHelper": "/usr/local/bin/canon-credential-vault" }
|
|
8
|
+
*/
|
|
9
|
+
export declare class CredentialHelperBackend implements CredentialBackend {
|
|
10
|
+
private readonly helperPath;
|
|
11
|
+
constructor(helperPath: string);
|
|
12
|
+
get(publisher: string): Promise<StoredCredential | null>;
|
|
13
|
+
store(publisher: string, credential: StoredCredential): Promise<void>;
|
|
14
|
+
remove(publisher: string): Promise<void>;
|
|
15
|
+
list(): Promise<string[]>;
|
|
16
|
+
private runHelper;
|
|
17
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { normalizeHost } from './CredentialBackend.js';
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
const TIMEOUT_MS = 30_000;
|
|
6
|
+
/**
|
|
7
|
+
* External credential helper backend.
|
|
8
|
+
* Delegates to an enterprise-configured binary using a stdin/stdout JSON protocol.
|
|
9
|
+
*
|
|
10
|
+
* Configure in ~/.canon/config.json:
|
|
11
|
+
* { "credentialHelper": "/usr/local/bin/canon-credential-vault" }
|
|
12
|
+
*/
|
|
13
|
+
export class CredentialHelperBackend {
|
|
14
|
+
helperPath;
|
|
15
|
+
constructor(helperPath) {
|
|
16
|
+
this.helperPath = helperPath;
|
|
17
|
+
}
|
|
18
|
+
async get(publisher) {
|
|
19
|
+
const host = normalizeHost(publisher);
|
|
20
|
+
try {
|
|
21
|
+
const stdout = await this.runHelper('get', { publisher: host });
|
|
22
|
+
const trimmed = stdout.trim();
|
|
23
|
+
if (!trimmed)
|
|
24
|
+
return null;
|
|
25
|
+
return JSON.parse(trimmed);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
throw new Error(`Credential helper '${this.helperPath}' failed to read credential for '${host}': ${errorMsg(err)}\n` +
|
|
29
|
+
` Verify the helper binary exists, is executable, and implements the Canon credential helper protocol.\n` +
|
|
30
|
+
` Check the 'credentialHelper' path in ~/.canon/config.json.`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async store(publisher, credential) {
|
|
34
|
+
const host = normalizeHost(publisher);
|
|
35
|
+
try {
|
|
36
|
+
await this.runHelper('store', { publisher: host, credential });
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
throw new Error(`Credential helper '${this.helperPath}' failed to store credential for '${host}': ${errorMsg(err)}\n` +
|
|
40
|
+
` Verify the helper binary supports the 'store' action.`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async remove(publisher) {
|
|
44
|
+
const host = normalizeHost(publisher);
|
|
45
|
+
try {
|
|
46
|
+
await this.runHelper('erase', { publisher: host });
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
console.warn(` Warning: Credential helper '${this.helperPath}' failed to erase credential for '${host}': ${errorMsg(err)}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async list() {
|
|
53
|
+
try {
|
|
54
|
+
const stdout = await this.runHelper('list', undefined);
|
|
55
|
+
return JSON.parse(stdout.trim());
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
console.warn(` Warning: Credential helper '${this.helperPath}' failed to list credentials: ${errorMsg(err)}`);
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async runHelper(action, input) {
|
|
63
|
+
try {
|
|
64
|
+
const { stdout } = await execFileAsync(this.helperPath, [action], {
|
|
65
|
+
...(input && { input: JSON.stringify(input) }),
|
|
66
|
+
timeout: TIMEOUT_MS,
|
|
67
|
+
});
|
|
68
|
+
return stdout;
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
if (isEnoent(err)) {
|
|
72
|
+
throw new Error(`Credential helper not found at '${this.helperPath}'.\n` +
|
|
73
|
+
` Check the 'credentialHelper' path in ~/.canon/config.json.`);
|
|
74
|
+
}
|
|
75
|
+
if (isTimeout(err)) {
|
|
76
|
+
throw new Error(`Credential helper '${this.helperPath}' timed out after ${TIMEOUT_MS / 1000}s on '${action}'.\n` +
|
|
77
|
+
` The helper may be waiting for authentication to an external vault.`);
|
|
78
|
+
}
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function errorMsg(err) {
|
|
84
|
+
if (err instanceof Error)
|
|
85
|
+
return err.message;
|
|
86
|
+
return String(err);
|
|
87
|
+
}
|
|
88
|
+
function isEnoent(err) {
|
|
89
|
+
return err instanceof Error && 'code' in err && err.code === 'ENOENT';
|
|
90
|
+
}
|
|
91
|
+
function isTimeout(err) {
|
|
92
|
+
return err instanceof Error && 'killed' in err && err.killed;
|
|
93
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CredentialBackend, StoredCredential } from './CredentialBackend.js';
|
|
2
|
+
/**
|
|
3
|
+
* Credential store orchestrator.
|
|
4
|
+
* Selects the appropriate backend based on platform and configuration.
|
|
5
|
+
*
|
|
6
|
+
* Resolution order:
|
|
7
|
+
* 1. CANON_TOKEN_{PUBLISHER} environment variable (CI/CD)
|
|
8
|
+
* 2. Credential helper binary (enterprise vaults)
|
|
9
|
+
* 3. OS credential store (macOS Keychain / Windows CredMan / Linux Secret Service)
|
|
10
|
+
* 4. Encrypted file fallback (headless Linux / containers)
|
|
11
|
+
*/
|
|
12
|
+
export declare class CredentialStore {
|
|
13
|
+
private backend;
|
|
14
|
+
private backendReady;
|
|
15
|
+
getBackend(): Promise<CredentialBackend>;
|
|
16
|
+
/**
|
|
17
|
+
* Get an access token for a publisher.
|
|
18
|
+
* Checks env vars first, then the credential backend.
|
|
19
|
+
*/
|
|
20
|
+
getToken(publisher: string): Promise<string | null>;
|
|
21
|
+
/**
|
|
22
|
+
* Get the full stored credential (including DPoP key pair).
|
|
23
|
+
*/
|
|
24
|
+
getCredential(publisher: string): Promise<StoredCredential | null>;
|
|
25
|
+
store(publisher: string, credential: StoredCredential): Promise<void>;
|
|
26
|
+
remove(publisher: string): Promise<void>;
|
|
27
|
+
list(): Promise<string[]>;
|
|
28
|
+
private resolveBackend;
|
|
29
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { normalizeHost, hasValidToken } from './CredentialBackend.js';
|
|
5
|
+
import { KeychainBackend } from './KeychainBackend.js';
|
|
6
|
+
import { WinCredBackend } from './WinCredBackend.js';
|
|
7
|
+
import { SecretServiceBackend, hasSecretTool } from './SecretServiceBackend.js';
|
|
8
|
+
import { EncryptedFileBackend } from './EncryptedFileBackend.js';
|
|
9
|
+
import { CredentialHelperBackend } from './CredentialHelperBackend.js';
|
|
10
|
+
const CONFIG_FILE = join(homedir(), '.canon', 'config.json');
|
|
11
|
+
/**
|
|
12
|
+
* Credential store orchestrator.
|
|
13
|
+
* Selects the appropriate backend based on platform and configuration.
|
|
14
|
+
*
|
|
15
|
+
* Resolution order:
|
|
16
|
+
* 1. CANON_TOKEN_{PUBLISHER} environment variable (CI/CD)
|
|
17
|
+
* 2. Credential helper binary (enterprise vaults)
|
|
18
|
+
* 3. OS credential store (macOS Keychain / Windows CredMan / Linux Secret Service)
|
|
19
|
+
* 4. Encrypted file fallback (headless Linux / containers)
|
|
20
|
+
*/
|
|
21
|
+
export class CredentialStore {
|
|
22
|
+
backend = null;
|
|
23
|
+
backendReady = null;
|
|
24
|
+
async getBackend() {
|
|
25
|
+
if (this.backend)
|
|
26
|
+
return this.backend;
|
|
27
|
+
if (this.backendReady)
|
|
28
|
+
return this.backendReady;
|
|
29
|
+
this.backendReady = this.resolveBackend();
|
|
30
|
+
try {
|
|
31
|
+
this.backend = await this.backendReady;
|
|
32
|
+
return this.backend;
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
// Reset so next call retries instead of returning the rejected promise
|
|
36
|
+
this.backendReady = null;
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Get an access token for a publisher.
|
|
42
|
+
* Checks env vars first, then the credential backend.
|
|
43
|
+
*/
|
|
44
|
+
async getToken(publisher) {
|
|
45
|
+
// 1. Environment variable override
|
|
46
|
+
const envToken = getEnvToken(publisher);
|
|
47
|
+
if (envToken)
|
|
48
|
+
return envToken;
|
|
49
|
+
// 2. Credential backend
|
|
50
|
+
const backend = await this.getBackend();
|
|
51
|
+
const credential = await backend.get(publisher);
|
|
52
|
+
if (!credential)
|
|
53
|
+
return null;
|
|
54
|
+
if (!hasValidToken(credential))
|
|
55
|
+
return null;
|
|
56
|
+
return credential.accessToken ?? null;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Get the full stored credential (including DPoP key pair).
|
|
60
|
+
*/
|
|
61
|
+
async getCredential(publisher) {
|
|
62
|
+
const backend = await this.getBackend();
|
|
63
|
+
return backend.get(publisher);
|
|
64
|
+
}
|
|
65
|
+
async store(publisher, credential) {
|
|
66
|
+
const backend = await this.getBackend();
|
|
67
|
+
return backend.store(publisher, credential);
|
|
68
|
+
}
|
|
69
|
+
async remove(publisher) {
|
|
70
|
+
const backend = await this.getBackend();
|
|
71
|
+
return backend.remove(publisher);
|
|
72
|
+
}
|
|
73
|
+
async list() {
|
|
74
|
+
const backend = await this.getBackend();
|
|
75
|
+
return backend.list();
|
|
76
|
+
}
|
|
77
|
+
async resolveBackend() {
|
|
78
|
+
const config = loadConfig();
|
|
79
|
+
// 1. Credential helper (enterprise)
|
|
80
|
+
if (config.credentialHelper) {
|
|
81
|
+
return new CredentialHelperBackend(config.credentialHelper);
|
|
82
|
+
}
|
|
83
|
+
// 2. OS credential store
|
|
84
|
+
if (process.platform === 'darwin') {
|
|
85
|
+
return new KeychainBackend();
|
|
86
|
+
}
|
|
87
|
+
if (process.platform === 'win32') {
|
|
88
|
+
return new WinCredBackend();
|
|
89
|
+
}
|
|
90
|
+
// Linux: try Secret Service, fall back to encrypted file
|
|
91
|
+
if (await hasSecretTool()) {
|
|
92
|
+
return new SecretServiceBackend();
|
|
93
|
+
}
|
|
94
|
+
// 3. Encrypted file fallback
|
|
95
|
+
return new EncryptedFileBackend();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Check for a publisher-specific environment variable token.
|
|
100
|
+
* CANON_TOKEN_{NORMALIZED_PUBLISHER} — dots/hyphens become underscores, uppercased.
|
|
101
|
+
* e.g., canon.vpn.enterprise.com → CANON_TOKEN_CANON_VPN_ENTERPRISE_COM
|
|
102
|
+
*/
|
|
103
|
+
function getEnvToken(publisher) {
|
|
104
|
+
const host = normalizeHost(publisher);
|
|
105
|
+
const envKey = 'CANON_TOKEN_' + host
|
|
106
|
+
.replace(/[.\-]/g, '_')
|
|
107
|
+
.toUpperCase();
|
|
108
|
+
return process.env[envKey] ?? null;
|
|
109
|
+
}
|
|
110
|
+
function loadConfig() {
|
|
111
|
+
if (!existsSync(CONFIG_FILE))
|
|
112
|
+
return {};
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
118
|
+
console.warn(` Warning: Failed to parse ${CONFIG_FILE}: ${msg}\n` +
|
|
119
|
+
` Using default credential backend. Fix the JSON syntax or delete the file.`);
|
|
120
|
+
return {};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { DPoPKeyPair } from './CredentialBackend.js';
|
|
2
|
+
/**
|
|
3
|
+
* DPoP (Demonstrating Proof-of-Possession) implementation per RFC 9449.
|
|
4
|
+
* Binds access tokens to a cryptographic key pair so stolen tokens
|
|
5
|
+
* are unusable without the private key.
|
|
6
|
+
*
|
|
7
|
+
* All crypto uses Node.js built-in modules — no external dependencies.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Generate an ephemeral EC P-256 key pair for DPoP.
|
|
11
|
+
* Returns keys in JWK format for storage in the credential store.
|
|
12
|
+
*/
|
|
13
|
+
export declare function generateDPoPKeyPair(): DPoPKeyPair;
|
|
14
|
+
/**
|
|
15
|
+
* Create a DPoP proof JWT for an HTTP request.
|
|
16
|
+
*
|
|
17
|
+
* @param privateKeyJwk - The private key in JWK format
|
|
18
|
+
* @param publicKeyJwk - The public key in JWK format (included in JWT header)
|
|
19
|
+
* @param method - HTTP method (e.g., "GET", "POST")
|
|
20
|
+
* @param url - Target URL
|
|
21
|
+
* @param accessToken - If present, the access token hash is included (ath claim)
|
|
22
|
+
* @param nonce - Server-provided DPoP nonce (from DPoP-Nonce header)
|
|
23
|
+
*/
|
|
24
|
+
export declare function createDPoPProof(privateKeyJwk: Record<string, unknown>, publicKeyJwk: Record<string, unknown>, method: string, url: string, accessToken?: string, nonce?: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Check if an OAuth server supports DPoP by inspecting its metadata.
|
|
27
|
+
*/
|
|
28
|
+
export declare function serverSupportsDPoP(dpopSigningAlgValues?: string[] | null): boolean;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, generateKeyPairSync, randomUUID, sign } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* DPoP (Demonstrating Proof-of-Possession) implementation per RFC 9449.
|
|
4
|
+
* Binds access tokens to a cryptographic key pair so stolen tokens
|
|
5
|
+
* are unusable without the private key.
|
|
6
|
+
*
|
|
7
|
+
* All crypto uses Node.js built-in modules — no external dependencies.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Generate an ephemeral EC P-256 key pair for DPoP.
|
|
11
|
+
* Returns keys in JWK format for storage in the credential store.
|
|
12
|
+
*/
|
|
13
|
+
export function generateDPoPKeyPair() {
|
|
14
|
+
const { publicKey, privateKey } = generateKeyPairSync('ec', {
|
|
15
|
+
namedCurve: 'P-256',
|
|
16
|
+
});
|
|
17
|
+
return {
|
|
18
|
+
publicKey: publicKey.export({ format: 'jwk' }),
|
|
19
|
+
privateKey: privateKey.export({ format: 'jwk' }),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Create a DPoP proof JWT for an HTTP request.
|
|
24
|
+
*
|
|
25
|
+
* @param privateKeyJwk - The private key in JWK format
|
|
26
|
+
* @param publicKeyJwk - The public key in JWK format (included in JWT header)
|
|
27
|
+
* @param method - HTTP method (e.g., "GET", "POST")
|
|
28
|
+
* @param url - Target URL
|
|
29
|
+
* @param accessToken - If present, the access token hash is included (ath claim)
|
|
30
|
+
* @param nonce - Server-provided DPoP nonce (from DPoP-Nonce header)
|
|
31
|
+
*/
|
|
32
|
+
export function createDPoPProof(privateKeyJwk, publicKeyJwk, method, url, accessToken, nonce) {
|
|
33
|
+
const header = {
|
|
34
|
+
alg: 'ES256',
|
|
35
|
+
typ: 'dpop+jwt',
|
|
36
|
+
jwk: {
|
|
37
|
+
kty: publicKeyJwk.kty,
|
|
38
|
+
crv: publicKeyJwk.crv,
|
|
39
|
+
x: publicKeyJwk.x,
|
|
40
|
+
y: publicKeyJwk.y,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
const payload = {
|
|
44
|
+
jti: randomUUID(),
|
|
45
|
+
htm: method.toUpperCase(),
|
|
46
|
+
htu: url,
|
|
47
|
+
iat: Math.floor(Date.now() / 1000),
|
|
48
|
+
};
|
|
49
|
+
if (accessToken) {
|
|
50
|
+
payload.ath = createHash('sha256')
|
|
51
|
+
.update(accessToken)
|
|
52
|
+
.digest('base64url');
|
|
53
|
+
}
|
|
54
|
+
if (nonce) {
|
|
55
|
+
payload.nonce = nonce;
|
|
56
|
+
}
|
|
57
|
+
return signJwt(header, payload, privateKeyJwk);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Check if an OAuth server supports DPoP by inspecting its metadata.
|
|
61
|
+
*/
|
|
62
|
+
export function serverSupportsDPoP(dpopSigningAlgValues) {
|
|
63
|
+
if (!dpopSigningAlgValues || dpopSigningAlgValues.length === 0)
|
|
64
|
+
return false;
|
|
65
|
+
return dpopSigningAlgValues.some(alg => alg.toUpperCase() === 'ES256');
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Minimal JWT signing for ES256 (ECDSA with P-256 + SHA-256).
|
|
69
|
+
* No external JWT library needed.
|
|
70
|
+
*/
|
|
71
|
+
function signJwt(header, payload, privateKeyJwk) {
|
|
72
|
+
const headerB64 = base64url(JSON.stringify(header));
|
|
73
|
+
const payloadB64 = base64url(JSON.stringify(payload));
|
|
74
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
75
|
+
const key = createPrivateKey({ key: privateKeyJwk, format: 'jwk' });
|
|
76
|
+
// Node.js sign() returns DER-encoded ECDSA signature.
|
|
77
|
+
// JWT requires raw R||S format (64 bytes for P-256).
|
|
78
|
+
const derSignature = sign('SHA256', Buffer.from(signingInput), {
|
|
79
|
+
key,
|
|
80
|
+
dsaEncoding: 'ieee-p1363',
|
|
81
|
+
});
|
|
82
|
+
const signatureB64 = derSignature.toString('base64url');
|
|
83
|
+
return `${signingInput}.${signatureB64}`;
|
|
84
|
+
}
|
|
85
|
+
function base64url(str) {
|
|
86
|
+
return Buffer.from(str, 'utf-8').toString('base64url');
|
|
87
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CredentialBackend, StoredCredential } from './CredentialBackend.js';
|
|
2
|
+
/**
|
|
3
|
+
* Encrypted file credential backend.
|
|
4
|
+
* Fallback for headless Linux, containers, and CI environments
|
|
5
|
+
* where no OS keyring is available.
|
|
6
|
+
*
|
|
7
|
+
* - Key: ~/.config/canon/keyring.key (random 32 bytes, mode 0600)
|
|
8
|
+
* - Secrets: ~/.config/canon/credentials.enc (AES-256-GCM encrypted JSON)
|
|
9
|
+
*/
|
|
10
|
+
export declare class EncryptedFileBackend implements CredentialBackend {
|
|
11
|
+
get(publisher: string): Promise<StoredCredential | null>;
|
|
12
|
+
store(publisher: string, credential: StoredCredential): Promise<void>;
|
|
13
|
+
remove(publisher: string): Promise<void>;
|
|
14
|
+
list(): Promise<string[]>;
|
|
15
|
+
private loadStore;
|
|
16
|
+
private saveStore;
|
|
17
|
+
private getOrCreateKey;
|
|
18
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
import { normalizeHost } from './CredentialBackend.js';
|
|
6
|
+
const CONFIG_DIR = join(homedir(), '.config', 'canon');
|
|
7
|
+
const KEY_FILE = join(CONFIG_DIR, 'keyring.key');
|
|
8
|
+
const SECRETS_FILE = join(CONFIG_DIR, 'credentials.enc');
|
|
9
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
10
|
+
const KEY_LENGTH = 32;
|
|
11
|
+
const IV_LENGTH = 12;
|
|
12
|
+
const AUTH_TAG_LENGTH = 16;
|
|
13
|
+
/**
|
|
14
|
+
* Encrypted file credential backend.
|
|
15
|
+
* Fallback for headless Linux, containers, and CI environments
|
|
16
|
+
* where no OS keyring is available.
|
|
17
|
+
*
|
|
18
|
+
* - Key: ~/.config/canon/keyring.key (random 32 bytes, mode 0600)
|
|
19
|
+
* - Secrets: ~/.config/canon/credentials.enc (AES-256-GCM encrypted JSON)
|
|
20
|
+
*/
|
|
21
|
+
export class EncryptedFileBackend {
|
|
22
|
+
async get(publisher) {
|
|
23
|
+
const store = this.loadStore();
|
|
24
|
+
const host = normalizeHost(publisher);
|
|
25
|
+
return store[host] ?? null;
|
|
26
|
+
}
|
|
27
|
+
async store(publisher, credential) {
|
|
28
|
+
const store = this.loadStore();
|
|
29
|
+
const host = normalizeHost(publisher);
|
|
30
|
+
store[host] = credential;
|
|
31
|
+
this.saveStore(store);
|
|
32
|
+
}
|
|
33
|
+
async remove(publisher) {
|
|
34
|
+
const store = this.loadStore();
|
|
35
|
+
const host = normalizeHost(publisher);
|
|
36
|
+
delete store[host];
|
|
37
|
+
this.saveStore(store);
|
|
38
|
+
}
|
|
39
|
+
async list() {
|
|
40
|
+
const store = this.loadStore();
|
|
41
|
+
return Object.keys(store);
|
|
42
|
+
}
|
|
43
|
+
loadStore() {
|
|
44
|
+
if (!existsSync(SECRETS_FILE))
|
|
45
|
+
return {};
|
|
46
|
+
try {
|
|
47
|
+
const key = this.getOrCreateKey();
|
|
48
|
+
const encrypted = readFileSync(SECRETS_FILE);
|
|
49
|
+
if (encrypted.length < IV_LENGTH + AUTH_TAG_LENGTH)
|
|
50
|
+
return {};
|
|
51
|
+
const iv = encrypted.subarray(0, IV_LENGTH);
|
|
52
|
+
const authTag = encrypted.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
|
53
|
+
const ciphertext = encrypted.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
|
54
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
55
|
+
decipher.setAuthTag(authTag);
|
|
56
|
+
const decrypted = Buffer.concat([
|
|
57
|
+
decipher.update(ciphertext),
|
|
58
|
+
decipher.final(),
|
|
59
|
+
]);
|
|
60
|
+
return JSON.parse(decrypted.toString('utf-8'));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Decryption failed — key was regenerated, file is corrupted, etc.
|
|
64
|
+
// Return empty store; next save will overwrite with valid data.
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
saveStore(store) {
|
|
69
|
+
const key = this.getOrCreateKey();
|
|
70
|
+
const iv = randomBytes(IV_LENGTH);
|
|
71
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
72
|
+
const plaintext = Buffer.from(JSON.stringify(store), 'utf-8');
|
|
73
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
74
|
+
const authTag = cipher.getAuthTag();
|
|
75
|
+
// File format: [iv (12)] [authTag (16)] [ciphertext ...]
|
|
76
|
+
const output = Buffer.concat([iv, authTag, ciphertext]);
|
|
77
|
+
mkdirSync(dirname(SECRETS_FILE), { recursive: true });
|
|
78
|
+
writeFileSync(SECRETS_FILE, output, { mode: 0o600 });
|
|
79
|
+
}
|
|
80
|
+
getOrCreateKey() {
|
|
81
|
+
if (existsSync(KEY_FILE)) {
|
|
82
|
+
const key = readFileSync(KEY_FILE);
|
|
83
|
+
if (key.length !== KEY_LENGTH) {
|
|
84
|
+
throw new Error(`Credential keyring key is corrupted (expected ${KEY_LENGTH} bytes, got ${key.length}). ` +
|
|
85
|
+
`Delete ${KEY_FILE} and re-authenticate.`);
|
|
86
|
+
}
|
|
87
|
+
return key;
|
|
88
|
+
}
|
|
89
|
+
const key = randomBytes(KEY_LENGTH);
|
|
90
|
+
mkdirSync(dirname(KEY_FILE), { recursive: true });
|
|
91
|
+
writeFileSync(KEY_FILE, key, { mode: 0o600 });
|
|
92
|
+
return key;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CredentialBackend, StoredCredential } from './CredentialBackend.js';
|
|
2
|
+
/**
|
|
3
|
+
* macOS Keychain credential backend.
|
|
4
|
+
* Stores credentials via the `security` CLI tool (no native modules).
|
|
5
|
+
*/
|
|
6
|
+
export declare class KeychainBackend implements CredentialBackend {
|
|
7
|
+
get(publisher: string): Promise<StoredCredential | null>;
|
|
8
|
+
store(publisher: string, credential: StoredCredential): Promise<void>;
|
|
9
|
+
remove(publisher: string): Promise<void>;
|
|
10
|
+
list(): Promise<string[]>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { normalizeHost } from './CredentialBackend.js';
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
const SERVICE = 'canon';
|
|
6
|
+
const SECURITY_BIN = '/usr/bin/security';
|
|
7
|
+
/**
|
|
8
|
+
* macOS Keychain credential backend.
|
|
9
|
+
* Stores credentials via the `security` CLI tool (no native modules).
|
|
10
|
+
*/
|
|
11
|
+
export class KeychainBackend {
|
|
12
|
+
async get(publisher) {
|
|
13
|
+
const account = normalizeHost(publisher);
|
|
14
|
+
try {
|
|
15
|
+
const { stdout } = await execFileAsync(SECURITY_BIN, [
|
|
16
|
+
'find-generic-password',
|
|
17
|
+
'-s', SERVICE,
|
|
18
|
+
'-a', account,
|
|
19
|
+
'-w',
|
|
20
|
+
], { timeout: 10_000 });
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(stdout.trim());
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new Error(`Stored credential for '${account}' is corrupted (not valid JSON).\n` +
|
|
26
|
+
` Run 'canon logout ${account}' then 'canon login ${account}' to fix.`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
if (isExitCode(err, 44))
|
|
31
|
+
return null; // errSecItemNotFound
|
|
32
|
+
throw new Error(`macOS Keychain read failed for '${account}': ${errorMsg(err)}\n` +
|
|
33
|
+
` The Keychain may be locked or inaccessible.\n` +
|
|
34
|
+
` Try unlocking it via Keychain Access.app or running 'security unlock-keychain'.`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async store(publisher, credential) {
|
|
38
|
+
const account = normalizeHost(publisher);
|
|
39
|
+
const json = JSON.stringify(credential);
|
|
40
|
+
try {
|
|
41
|
+
await execFileAsync(SECURITY_BIN, [
|
|
42
|
+
'add-generic-password',
|
|
43
|
+
'-s', SERVICE,
|
|
44
|
+
'-a', account,
|
|
45
|
+
'-U',
|
|
46
|
+
'-w', json,
|
|
47
|
+
], { timeout: 10_000 });
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
throw new Error(`macOS Keychain write failed for '${account}': ${errorMsg(err)}\n` +
|
|
51
|
+
` Ensure the Keychain is unlocked and the CLI has write permission.\n` +
|
|
52
|
+
` On managed devices, your IT policy may block credential storage.`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async remove(publisher) {
|
|
56
|
+
const account = normalizeHost(publisher);
|
|
57
|
+
try {
|
|
58
|
+
await execFileAsync(SECURITY_BIN, [
|
|
59
|
+
'delete-generic-password',
|
|
60
|
+
'-s', SERVICE,
|
|
61
|
+
'-a', account,
|
|
62
|
+
], { timeout: 10_000 });
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
if (isExitCode(err, 44))
|
|
66
|
+
return; // already gone
|
|
67
|
+
console.warn(` Warning: macOS Keychain delete failed for '${account}': ${errorMsg(err)}\n` +
|
|
68
|
+
` The credential may not have been fully removed.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async list() {
|
|
72
|
+
try {
|
|
73
|
+
const { stdout } = await execFileAsync(SECURITY_BIN, [
|
|
74
|
+
'dump-keychain',
|
|
75
|
+
], { timeout: 10_000 });
|
|
76
|
+
const accounts = [];
|
|
77
|
+
let inCanonEntry = false;
|
|
78
|
+
for (const line of stdout.split('\n')) {
|
|
79
|
+
if (line.includes(`"svce"<blob>="${SERVICE}"`)) {
|
|
80
|
+
inCanonEntry = true;
|
|
81
|
+
}
|
|
82
|
+
if (inCanonEntry && line.includes('"acct"<blob>=')) {
|
|
83
|
+
const match = line.match(/"acct"<blob>="([^"]+)"/);
|
|
84
|
+
if (match)
|
|
85
|
+
accounts.push(match[1]);
|
|
86
|
+
inCanonEntry = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return accounts;
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
console.warn(` Warning: Could not enumerate Keychain entries: ${errorMsg(err)}`);
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function isExitCode(err, code) {
|
|
98
|
+
return typeof err === 'object' && err !== null && 'code' in err && err.code === code;
|
|
99
|
+
}
|
|
100
|
+
function errorMsg(err) {
|
|
101
|
+
if (err instanceof Error)
|
|
102
|
+
return err.message;
|
|
103
|
+
return String(err);
|
|
104
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CredentialBackend, StoredCredential } from './CredentialBackend.js';
|
|
2
|
+
/**
|
|
3
|
+
* Linux Secret Service backend (GNOME Keyring / KDE Wallet).
|
|
4
|
+
* Uses the `secret-tool` CLI from libsecret-tools.
|
|
5
|
+
*
|
|
6
|
+
* Each publisher's credential is stored with attributes:
|
|
7
|
+
* service = "canon"
|
|
8
|
+
* publisher = normalized publisher host
|
|
9
|
+
*
|
|
10
|
+
* All interactions use execFile (no shell) to prevent command injection.
|
|
11
|
+
* The store() method uses spawn with stdin piping since secret-tool
|
|
12
|
+
* reads the secret value from stdin.
|
|
13
|
+
*/
|
|
14
|
+
export declare class SecretServiceBackend implements CredentialBackend {
|
|
15
|
+
get(publisher: string): Promise<StoredCredential | null>;
|
|
16
|
+
store(publisher: string, credential: StoredCredential): Promise<void>;
|
|
17
|
+
remove(publisher: string): Promise<void>;
|
|
18
|
+
list(): Promise<string[]>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Check if secret-tool is available on the system.
|
|
22
|
+
*/
|
|
23
|
+
export declare function hasSecretTool(): Promise<boolean>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { normalizeHost } from './CredentialBackend.js';
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
const SERVICE_ATTR = 'canon';
|
|
6
|
+
/**
|
|
7
|
+
* Linux Secret Service backend (GNOME Keyring / KDE Wallet).
|
|
8
|
+
* Uses the `secret-tool` CLI from libsecret-tools.
|
|
9
|
+
*
|
|
10
|
+
* Each publisher's credential is stored with attributes:
|
|
11
|
+
* service = "canon"
|
|
12
|
+
* publisher = normalized publisher host
|
|
13
|
+
*
|
|
14
|
+
* All interactions use execFile (no shell) to prevent command injection.
|
|
15
|
+
* The store() method uses spawn with stdin piping since secret-tool
|
|
16
|
+
* reads the secret value from stdin.
|
|
17
|
+
*/
|
|
18
|
+
export class SecretServiceBackend {
|
|
19
|
+
async get(publisher) {
|
|
20
|
+
const host = normalizeHost(publisher);
|
|
21
|
+
try {
|
|
22
|
+
const { stdout } = await execFileAsync('secret-tool', [
|
|
23
|
+
'lookup',
|
|
24
|
+
'service', SERVICE_ATTR,
|
|
25
|
+
'publisher', host,
|
|
26
|
+
], { timeout: 10_000 });
|
|
27
|
+
const trimmed = stdout.trim();
|
|
28
|
+
if (!trimmed)
|
|
29
|
+
return null;
|
|
30
|
+
return JSON.parse(trimmed);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async store(publisher, credential) {
|
|
37
|
+
const host = normalizeHost(publisher);
|
|
38
|
+
const json = JSON.stringify(credential);
|
|
39
|
+
// Use spawn with stdin to avoid shell injection.
|
|
40
|
+
// secret-tool store reads the secret value from stdin.
|
|
41
|
+
await new Promise((resolve, reject) => {
|
|
42
|
+
const proc = spawn('secret-tool', [
|
|
43
|
+
'store',
|
|
44
|
+
'--label', `Canon: ${host}`,
|
|
45
|
+
'service', SERVICE_ATTR,
|
|
46
|
+
'publisher', host,
|
|
47
|
+
], { stdio: ['pipe', 'ignore', 'ignore'], timeout: 10_000 });
|
|
48
|
+
proc.stdin.write(json);
|
|
49
|
+
proc.stdin.end();
|
|
50
|
+
proc.on('close', (code) => {
|
|
51
|
+
if (code === 0)
|
|
52
|
+
resolve();
|
|
53
|
+
else
|
|
54
|
+
reject(new Error(`secret-tool store exited with code ${code}`));
|
|
55
|
+
});
|
|
56
|
+
proc.on('error', reject);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async remove(publisher) {
|
|
60
|
+
const host = normalizeHost(publisher);
|
|
61
|
+
try {
|
|
62
|
+
await execFileAsync('secret-tool', [
|
|
63
|
+
'clear',
|
|
64
|
+
'service', SERVICE_ATTR,
|
|
65
|
+
'publisher', host,
|
|
66
|
+
], { timeout: 10_000 });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Ignore errors (credential may not exist)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async list() {
|
|
73
|
+
try {
|
|
74
|
+
const { stdout } = await execFileAsync('secret-tool', [
|
|
75
|
+
'search',
|
|
76
|
+
'service', SERVICE_ATTR,
|
|
77
|
+
], { timeout: 10_000 });
|
|
78
|
+
const publishers = [];
|
|
79
|
+
for (const line of stdout.split('\n')) {
|
|
80
|
+
const match = line.match(/attribute\.publisher\s*=\s*(.+)/);
|
|
81
|
+
if (match)
|
|
82
|
+
publishers.push(match[1].trim());
|
|
83
|
+
}
|
|
84
|
+
return publishers;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Check if secret-tool is available on the system.
|
|
93
|
+
*/
|
|
94
|
+
export async function hasSecretTool() {
|
|
95
|
+
try {
|
|
96
|
+
// 'command -v' is POSIX-standard, unlike 'which'
|
|
97
|
+
await execFileAsync('sh', ['-c', 'command -v secret-tool'], { timeout: 5_000 });
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CredentialBackend, StoredCredential } from './CredentialBackend.js';
|
|
2
|
+
/**
|
|
3
|
+
* Windows Credential Manager backend.
|
|
4
|
+
* Uses PowerShell P/Invoke to Advapi32.dll for CredRead/CredWrite/CredDelete.
|
|
5
|
+
* cmdkey cannot read passwords back, so P/Invoke is required.
|
|
6
|
+
*
|
|
7
|
+
* Each publisher's credential is stored as a generic credential with:
|
|
8
|
+
* target = "canon:{normalized_publisher}"
|
|
9
|
+
* credential blob = JSON-serialized StoredCredential (UTF-16)
|
|
10
|
+
*
|
|
11
|
+
* Data is passed to PowerShell via stdin to prevent injection attacks.
|
|
12
|
+
* No user-supplied values are interpolated into PowerShell code.
|
|
13
|
+
*/
|
|
14
|
+
export declare class WinCredBackend implements CredentialBackend {
|
|
15
|
+
get(publisher: string): Promise<StoredCredential | null>;
|
|
16
|
+
store(publisher: string, credential: StoredCredential): Promise<void>;
|
|
17
|
+
remove(publisher: string): Promise<void>;
|
|
18
|
+
list(): Promise<string[]>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { normalizeHost } from './CredentialBackend.js';
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
const TARGET_PREFIX = 'canon:';
|
|
6
|
+
/**
|
|
7
|
+
* Windows Credential Manager backend.
|
|
8
|
+
* Uses PowerShell P/Invoke to Advapi32.dll for CredRead/CredWrite/CredDelete.
|
|
9
|
+
* cmdkey cannot read passwords back, so P/Invoke is required.
|
|
10
|
+
*
|
|
11
|
+
* Each publisher's credential is stored as a generic credential with:
|
|
12
|
+
* target = "canon:{normalized_publisher}"
|
|
13
|
+
* credential blob = JSON-serialized StoredCredential (UTF-16)
|
|
14
|
+
*
|
|
15
|
+
* Data is passed to PowerShell via stdin to prevent injection attacks.
|
|
16
|
+
* No user-supplied values are interpolated into PowerShell code.
|
|
17
|
+
*/
|
|
18
|
+
export class WinCredBackend {
|
|
19
|
+
async get(publisher) {
|
|
20
|
+
const target = TARGET_PREFIX + normalizeHost(publisher);
|
|
21
|
+
// Pass target via stdin, read it with [Console]::In.ReadLine()
|
|
22
|
+
const script = `
|
|
23
|
+
${PINVOKE_BOOTSTRAP}
|
|
24
|
+
$target = [Console]::In.ReadLine()
|
|
25
|
+
$ptr = [IntPtr]::Zero
|
|
26
|
+
$result = [CredMan]::CredRead($target, 1, 0, [ref]$ptr)
|
|
27
|
+
if (-not $result) { exit 1 }
|
|
28
|
+
try {
|
|
29
|
+
$cred = [System.Runtime.InteropServices.Marshal]::PtrToStructure($ptr, [Type][CredMan+CREDENTIAL])
|
|
30
|
+
$bytes = New-Object byte[] $cred.CredentialBlobSize
|
|
31
|
+
[System.Runtime.InteropServices.Marshal]::Copy($cred.CredentialBlob, $bytes, 0, $cred.CredentialBlobSize)
|
|
32
|
+
[System.Text.Encoding]::Unicode.GetString($bytes)
|
|
33
|
+
} finally {
|
|
34
|
+
[CredMan]::CredFree($ptr)
|
|
35
|
+
}`;
|
|
36
|
+
try {
|
|
37
|
+
const { stdout } = await runPowerShell(script, target);
|
|
38
|
+
const trimmed = stdout.trim();
|
|
39
|
+
if (!trimmed)
|
|
40
|
+
return null;
|
|
41
|
+
return JSON.parse(trimmed);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async store(publisher, credential) {
|
|
48
|
+
const target = TARGET_PREFIX + normalizeHost(publisher);
|
|
49
|
+
const json = JSON.stringify(credential);
|
|
50
|
+
// Pass target and JSON via stdin, one per line
|
|
51
|
+
const script = `
|
|
52
|
+
${PINVOKE_BOOTSTRAP}
|
|
53
|
+
$target = [Console]::In.ReadLine()
|
|
54
|
+
$json = [Console]::In.ReadLine()
|
|
55
|
+
$bytes = [System.Text.Encoding]::Unicode.GetBytes($json)
|
|
56
|
+
$cred = New-Object CredMan+CREDENTIAL
|
|
57
|
+
$cred.Type = 1
|
|
58
|
+
$cred.TargetName = $target
|
|
59
|
+
$cred.CredentialBlobSize = $bytes.Length
|
|
60
|
+
$cred.CredentialBlob = [System.Runtime.InteropServices.Marshal]::AllocHGlobal($bytes.Length)
|
|
61
|
+
try {
|
|
62
|
+
[System.Runtime.InteropServices.Marshal]::Copy($bytes, 0, $cred.CredentialBlob, $bytes.Length)
|
|
63
|
+
$cred.Persist = 2
|
|
64
|
+
$result = [CredMan]::CredWrite([ref]$cred, 0)
|
|
65
|
+
if (-not $result) { throw "CredWrite failed" }
|
|
66
|
+
} finally {
|
|
67
|
+
[System.Runtime.InteropServices.Marshal]::FreeHGlobal($cred.CredentialBlob)
|
|
68
|
+
}`;
|
|
69
|
+
try {
|
|
70
|
+
await runPowerShell(script, `${target}\n${json}`);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
throw new Error(`Windows Credential Manager write failed: ${String(err)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async remove(publisher) {
|
|
77
|
+
const target = TARGET_PREFIX + normalizeHost(publisher);
|
|
78
|
+
const script = `
|
|
79
|
+
${PINVOKE_BOOTSTRAP}
|
|
80
|
+
$target = [Console]::In.ReadLine()
|
|
81
|
+
[CredMan]::CredDelete($target, 1, 0) | Out-Null`;
|
|
82
|
+
try {
|
|
83
|
+
await runPowerShell(script, target);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Ignore errors (credential may not exist)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async list() {
|
|
90
|
+
// Enumerate credentials via P/Invoke CredEnumerate for reliable results
|
|
91
|
+
// across all Windows locales (cmdkey output is locale-dependent).
|
|
92
|
+
const script = `
|
|
93
|
+
${PINVOKE_BOOTSTRAP}
|
|
94
|
+
${CRED_ENUMERATE_BOOTSTRAP}
|
|
95
|
+
$prefix = [Console]::In.ReadLine()
|
|
96
|
+
$count = 0
|
|
97
|
+
$pCreds = [IntPtr]::Zero
|
|
98
|
+
if ([CredMan]::CredEnumerate($null, 0, [ref]$count, [ref]$pCreds)) {
|
|
99
|
+
$ptrSize = [System.Runtime.InteropServices.Marshal]::SizeOf([Type][IntPtr])
|
|
100
|
+
for ($i = 0; $i -lt $count; $i++) {
|
|
101
|
+
$credPtr = [System.Runtime.InteropServices.Marshal]::ReadIntPtr($pCreds, $i * $ptrSize)
|
|
102
|
+
$cred = [System.Runtime.InteropServices.Marshal]::PtrToStructure($credPtr, [Type][CredMan+CREDENTIAL])
|
|
103
|
+
if ($cred.TargetName -and $cred.TargetName.StartsWith($prefix)) {
|
|
104
|
+
$cred.TargetName.Substring($prefix.Length)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
[CredMan]::CredFree($pCreds)
|
|
108
|
+
}`;
|
|
109
|
+
try {
|
|
110
|
+
const { stdout } = await runPowerShell(script, TARGET_PREFIX);
|
|
111
|
+
return stdout.trim().split('\n').map(s => s.trim()).filter(Boolean);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function runPowerShell(script, stdin) {
|
|
119
|
+
// Try pwsh (PowerShell 7+) first, fall back to powershell (5.1)
|
|
120
|
+
for (const shell of ['pwsh', 'powershell']) {
|
|
121
|
+
try {
|
|
122
|
+
return await execFileAsync(shell, [
|
|
123
|
+
'-NoProfile',
|
|
124
|
+
'-NonInteractive',
|
|
125
|
+
'-Command',
|
|
126
|
+
script,
|
|
127
|
+
], {
|
|
128
|
+
timeout: 15_000,
|
|
129
|
+
...(stdin !== undefined && { input: stdin }),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
if (shell === 'powershell')
|
|
134
|
+
throw err;
|
|
135
|
+
// pwsh not found, try powershell
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
throw new Error('Neither pwsh nor powershell found');
|
|
139
|
+
}
|
|
140
|
+
const PINVOKE_BOOTSTRAP = `
|
|
141
|
+
Add-Type -TypeDefinition @"
|
|
142
|
+
using System;
|
|
143
|
+
using System.Runtime.InteropServices;
|
|
144
|
+
public class CredMan {
|
|
145
|
+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
146
|
+
public struct CREDENTIAL {
|
|
147
|
+
public int Flags;
|
|
148
|
+
public int Type;
|
|
149
|
+
public string TargetName;
|
|
150
|
+
public string Comment;
|
|
151
|
+
public long LastWritten;
|
|
152
|
+
public int CredentialBlobSize;
|
|
153
|
+
public IntPtr CredentialBlob;
|
|
154
|
+
public int Persist;
|
|
155
|
+
public int AttributeCount;
|
|
156
|
+
public IntPtr Attributes;
|
|
157
|
+
public string TargetAlias;
|
|
158
|
+
public string UserName;
|
|
159
|
+
}
|
|
160
|
+
[DllImport("Advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
161
|
+
public static extern bool CredRead(string target, int type, int flags, out IntPtr credential);
|
|
162
|
+
[DllImport("Advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
163
|
+
public static extern bool CredWrite(ref CREDENTIAL credential, int flags);
|
|
164
|
+
[DllImport("Advapi32.dll", SetLastError = true)]
|
|
165
|
+
public static extern bool CredDelete(string target, int type, int flags);
|
|
166
|
+
[DllImport("Advapi32.dll", SetLastError = true)]
|
|
167
|
+
public static extern void CredFree(IntPtr buffer);
|
|
168
|
+
[DllImport("Advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
169
|
+
public static extern bool CredEnumerate(string filter, int flags, out int count, out IntPtr credentials);
|
|
170
|
+
}
|
|
171
|
+
"@`;
|
|
172
|
+
const CRED_ENUMERATE_BOOTSTRAP = ''; // CredEnumerate is already in the P/Invoke bootstrap above
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type { CredentialBackend, StoredCredential, DPoPKeyPair } from './CredentialBackend.js';
|
|
2
|
+
export { isExpired, hasValidToken, normalizeHost } from './CredentialBackend.js';
|
|
3
|
+
export { CredentialStore } from './CredentialStore.js';
|
|
4
|
+
export type { AuthenticatedFetchFn } from './AuthenticatedFetch.js';
|
|
5
|
+
export { createAuthenticatedFetch } from './AuthenticatedFetch.js';
|
|
6
|
+
export { generateDPoPKeyPair, createDPoPProof, serverSupportsDPoP } from './DPoP.js';
|
|
7
|
+
export { KeychainBackend } from './KeychainBackend.js';
|
|
8
|
+
export { WinCredBackend } from './WinCredBackend.js';
|
|
9
|
+
export { SecretServiceBackend, hasSecretTool } from './SecretServiceBackend.js';
|
|
10
|
+
export { EncryptedFileBackend } from './EncryptedFileBackend.js';
|
|
11
|
+
export { CredentialHelperBackend } from './CredentialHelperBackend.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { isExpired, hasValidToken, normalizeHost } from './CredentialBackend.js';
|
|
2
|
+
export { CredentialStore } from './CredentialStore.js';
|
|
3
|
+
export { createAuthenticatedFetch } from './AuthenticatedFetch.js';
|
|
4
|
+
export { generateDPoPKeyPair, createDPoPProof, serverSupportsDPoP } from './DPoP.js';
|
|
5
|
+
export { KeychainBackend } from './KeychainBackend.js';
|
|
6
|
+
export { WinCredBackend } from './WinCredBackend.js';
|
|
7
|
+
export { SecretServiceBackend, hasSecretTool } from './SecretServiceBackend.js';
|
|
8
|
+
export { EncryptedFileBackend } from './EncryptedFileBackend.js';
|
|
9
|
+
export { CredentialHelperBackend } from './CredentialHelperBackend.js';
|
package/dist/index.d.ts
CHANGED
|
@@ -15,5 +15,7 @@ export type { ICanonDocumentRepository } from '@canon-protocol/types/document/mo
|
|
|
15
15
|
export type { ICanonParser } from '@canon-protocol/types/document/parsing';
|
|
16
16
|
export type { CanonDocument, CanonMetadata, Namespace, Import, Version, DocumentReference, ParseResult, ParseError } from '@canon-protocol/types/document/models/types';
|
|
17
17
|
export type { VersionOperator } from '@canon-protocol/types/document/models/enums';
|
|
18
|
+
export { CredentialStore, createAuthenticatedFetch, generateDPoPKeyPair, createDPoPProof, serverSupportsDPoP, isExpired, hasValidToken, normalizeHost, } from './auth/index.js';
|
|
19
|
+
export type { CredentialBackend, StoredCredential, DPoPKeyPair, AuthenticatedFetchFn, } from './auth/index.js';
|
|
18
20
|
export { CtlCanonUri, CtlParser, ResourceTypeClassifier, CtlValidator, CtlGraphResolver, CtlMarkdownRenderer, CtlValidationErrorType, CtlValidationSeverity, CtlCanonUriType, PathSegmentType, ResolvedResourceType } from './ctl/index.js';
|
|
19
21
|
export type { CtlDocument, CanonReference, CtlValidationResult, CtlValidationError, ResolvedResourceGraph, ResolvedResource, ResolvedProperty, UnresolvedResource } from './ctl/index.js';
|
package/dist/index.js
CHANGED
|
@@ -5,4 +5,5 @@ export { ResourceResolver, CanonUri, CanonUriBuilder, TypeResolver } from './res
|
|
|
5
5
|
export { Canon, DefinedCanon, SubjectCanon, EmbeddedCanon, ReferenceCanon } from './canons/index.js';
|
|
6
6
|
export { Statement, ScalarStatement, StringStatement, NumberStatement, BooleanStatement, ReferenceStatement, EmbeddedStatement, ListStatement } from './statements/index.js';
|
|
7
7
|
export { OntologyValidationResult, OntologyValidationError, ValidationSeverity, ValidationContext, CanonObjectValidator, NamespacePrefixRule, ResourceNamingRule, PropertyTypeSpecificityRule, SubjectCanonTypeRequiredRule, EmbeddedCanonNoExplicitTypeRule, ImportExistenceRule, UnresolvedReferenceRule, TypeAmbiguityRule, ClassHierarchyCycleRule, PropertyHierarchyCycleRule, PropertyRangeRequiredRule, SubClassOfReferenceRule, SubPropertyOfReferenceRule, NamespaceImportCycleRule, InstancePropertyReferenceRule, DefinitionPropertyReferenceRule, XsdImportRule, AmbiguousReferenceRule, PropertyRangeReferenceRule, ObjectPropertyImportRule, ObjectPropertyValueValidationRule, PropertyDomainRule, PropertyValueTypeRule, ClassDefinitionRule } from './validation/index.js';
|
|
8
|
+
export { CredentialStore, createAuthenticatedFetch, generateDPoPKeyPair, createDPoPProof, serverSupportsDPoP, isExpired, hasValidToken, normalizeHost, } from './auth/index.js';
|
|
8
9
|
export { CtlCanonUri, CtlParser, ResourceTypeClassifier, CtlValidator, CtlGraphResolver, CtlMarkdownRenderer, CtlValidationErrorType, CtlValidationSeverity, CtlCanonUriType, PathSegmentType, ResolvedResourceType } from './ctl/index.js';
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import type { ICanonDocumentRepository } from '@canon-protocol/types/document/models';
|
|
2
2
|
import type { CanonDocument, Import, DocumentReference } from '@canon-protocol/types/document/models/types';
|
|
3
|
+
import type { AuthenticatedFetchFn } from '../auth/index.js';
|
|
3
4
|
export declare class HttpCanonDocumentRepository implements ICanonDocumentRepository {
|
|
4
5
|
private readonly parser;
|
|
5
6
|
private readonly publisherIndex;
|
|
6
7
|
private readonly documents;
|
|
7
8
|
private readonly contentCache;
|
|
9
|
+
private readonly fetchFn;
|
|
8
10
|
private readonly onFetch;
|
|
9
11
|
private readonly getFromCache;
|
|
10
12
|
constructor(options?: {
|
|
11
13
|
onFetch?: (publisher: string, packageName: string, version: string, content: string) => void;
|
|
12
14
|
getFromCache?: (publisher: string, packageName: string, version: string) => string | null;
|
|
15
|
+
fetchFn?: AuthenticatedFetchFn;
|
|
13
16
|
});
|
|
14
17
|
getHighestCompatibleVersionAsync(publisher: string, import_: Import): Promise<CanonDocument | null>;
|
|
15
18
|
getAllDocumentsAsync(): Promise<CanonDocument[]>;
|
|
@@ -2,14 +2,19 @@ import { CanonParser } from '../parsing/CanonParser.js';
|
|
|
2
2
|
import { PublisherIndex } from './PublisherIndex.js';
|
|
3
3
|
export class HttpCanonDocumentRepository {
|
|
4
4
|
parser = new CanonParser();
|
|
5
|
-
publisherIndex
|
|
5
|
+
publisherIndex;
|
|
6
6
|
documents = new Map();
|
|
7
7
|
contentCache = new Map();
|
|
8
|
+
fetchFn;
|
|
8
9
|
onFetch;
|
|
9
10
|
getFromCache;
|
|
10
11
|
constructor(options) {
|
|
11
12
|
this.onFetch = options?.onFetch;
|
|
12
13
|
this.getFromCache = options?.getFromCache;
|
|
14
|
+
this.fetchFn = options?.fetchFn;
|
|
15
|
+
this.publisherIndex = options?.fetchFn
|
|
16
|
+
? new PublisherIndex({ fetchFn: options.fetchFn })
|
|
17
|
+
: new PublisherIndex();
|
|
13
18
|
}
|
|
14
19
|
async getHighestCompatibleVersionAsync(publisher, import_) {
|
|
15
20
|
const version = await this.publisherIndex.resolveVersion(publisher, import_);
|
|
@@ -29,7 +34,9 @@ export class HttpCanonDocumentRepository {
|
|
|
29
34
|
}
|
|
30
35
|
}
|
|
31
36
|
const url = await this.publisherIndex.getPackageUrl(publisher, import_.packageName, version);
|
|
32
|
-
const response =
|
|
37
|
+
const response = this.fetchFn
|
|
38
|
+
? await this.fetchFn(url, publisher)
|
|
39
|
+
: await fetch(url);
|
|
33
40
|
if (!response.ok) {
|
|
34
41
|
throw new Error(`Failed to fetch Canon package: ${url} (${response.status} ${response.statusText})`);
|
|
35
42
|
}
|
|
@@ -38,6 +38,9 @@ export class PublisherConfigResolver {
|
|
|
38
38
|
if (typeof json.package === 'string') {
|
|
39
39
|
config.package = json.package;
|
|
40
40
|
}
|
|
41
|
+
if (json.auth === 'none' || json.auth === 'oauth' || json.auth === 'bearer') {
|
|
42
|
+
config.auth = json.auth;
|
|
43
|
+
}
|
|
41
44
|
return config;
|
|
42
45
|
}
|
|
43
46
|
resolveIndexUrl(publisher, config) {
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import type { Import } from '@canon-protocol/types/document/models/types';
|
|
2
2
|
import { PublisherConfigResolver } from './PublisherConfig.js';
|
|
3
|
+
import type { AuthenticatedFetchFn } from '../auth/index.js';
|
|
3
4
|
export declare class PublisherIndex {
|
|
4
5
|
private readonly indexCache;
|
|
5
6
|
private readonly configResolver;
|
|
6
|
-
|
|
7
|
+
private readonly fetchFn;
|
|
8
|
+
constructor(options?: {
|
|
9
|
+
configResolver?: PublisherConfigResolver;
|
|
10
|
+
fetchFn?: AuthenticatedFetchFn;
|
|
11
|
+
});
|
|
7
12
|
resolveVersion(publisher: string, import_: Import): Promise<string | null>;
|
|
8
13
|
getHighestVersion(publisher: string, packageName: string): Promise<string | null>;
|
|
9
14
|
getPackageUrl(publisher: string, packageName: string, version: string): Promise<string>;
|
|
@@ -3,8 +3,10 @@ import { PublisherConfigResolver } from './PublisherConfig.js';
|
|
|
3
3
|
export class PublisherIndex {
|
|
4
4
|
indexCache = new Map();
|
|
5
5
|
configResolver;
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
fetchFn;
|
|
7
|
+
constructor(options) {
|
|
8
|
+
this.configResolver = options?.configResolver ?? new PublisherConfigResolver();
|
|
9
|
+
this.fetchFn = options?.fetchFn;
|
|
8
10
|
}
|
|
9
11
|
async resolveVersion(publisher, import_) {
|
|
10
12
|
const packageVersions = await this.getPackageVersions(publisher, import_.packageName);
|
|
@@ -40,7 +42,9 @@ export class PublisherIndex {
|
|
|
40
42
|
async fetchIndex(publisher) {
|
|
41
43
|
const config = await this.configResolver.getConfig(publisher);
|
|
42
44
|
const url = this.configResolver.resolveIndexUrl(publisher, config);
|
|
43
|
-
const response =
|
|
45
|
+
const response = this.fetchFn
|
|
46
|
+
? await this.fetchFn(url, publisher)
|
|
47
|
+
: await fetch(url);
|
|
44
48
|
if (!response.ok) {
|
|
45
49
|
throw new Error(`Failed to fetch publisher index: ${url} (${response.status} ${response.statusText})`);
|
|
46
50
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canon-protocol/sdk",
|
|
3
|
-
"version": "8.0
|
|
3
|
+
"version": "8.1.0",
|
|
4
4
|
"description": "Canon Protocol SDK - Document repository and parsing implementations for TypeScript",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"yaml-parser"
|
|
63
63
|
],
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@canon-protocol/types": "^8.0
|
|
65
|
+
"@canon-protocol/types": "^8.1.0",
|
|
66
66
|
"ignore": "^7.0.5",
|
|
67
67
|
"js-yaml": "^4.1.0"
|
|
68
68
|
},
|