@astrale-os/kernel-client 0.6.0-beta.74 → 0.6.0-beta.75
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/errors/exchange-error.d.ts +19 -0
- package/dist/errors/exchange-error.js +9 -0
- package/dist/errors/index.d.ts +1 -0
- package/dist/errors/index.js +1 -0
- package/dist/session/exchange/exchange.js +2 -2
- package/dist/session/exchange/issuer.js +64 -21
- package/dist/session/exchange/source.d.ts +2 -4
- package/dist/session/exchange/source.js +7 -7
- package/package.json +1 -1
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ClientError } from './client-error.js';
|
|
2
|
+
/**
|
|
3
|
+
* Why a Domain token exchange failed.
|
|
4
|
+
*
|
|
5
|
+
* - `discovery`: the Domain issuer's configuration could not be read or admitted.
|
|
6
|
+
* - `unsupported`: the Domain issuer advertises no token exchange endpoint.
|
|
7
|
+
* - `unavailable`: a Domain issuer endpoint could not be reached.
|
|
8
|
+
* - `rejected`: the Domain refused the delegation; its public error is the `cause`.
|
|
9
|
+
* - `invalid-response`: the exchange response or its credential failed admission.
|
|
10
|
+
* - `source-exhausted`: the source credential can no longer grant the lifetime the exchange needs.
|
|
11
|
+
*/
|
|
12
|
+
export type ExchangeFailure = 'discovery' | 'unsupported' | 'unavailable' | 'rejected' | 'invalid-response' | 'source-exhausted';
|
|
13
|
+
export interface ExchangeErrorOptions extends ErrorOptions {
|
|
14
|
+
readonly failure: ExchangeFailure;
|
|
15
|
+
}
|
|
16
|
+
export declare class ExchangeError extends ClientError {
|
|
17
|
+
readonly failure: ExchangeFailure;
|
|
18
|
+
constructor(message: string, options: ExchangeErrorOptions);
|
|
19
|
+
}
|
package/dist/errors/index.d.ts
CHANGED
package/dist/errors/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { identityApi } from '../../auth/identity.js';
|
|
|
3
3
|
import { delegationCoverageMs } from '../credential/index.js';
|
|
4
4
|
import { resolveAuth } from '../routing/index.js';
|
|
5
5
|
import { redeem, tokenEndpoint } from './issuer.js';
|
|
6
|
-
import { delegationTtl, isAuthority, renewing, sourceCaller,
|
|
6
|
+
import { delegationTtl, isAuthority, renewing, sourceCaller, sourceExhausted } from './source.js';
|
|
7
7
|
/** The attenuation that delegates the source caller's own identity. */
|
|
8
8
|
export const SELF_ATTENUATION = Object.freeze({ kind: 'identity', self: true });
|
|
9
9
|
/**
|
|
@@ -28,7 +28,7 @@ export async function exchangeDomainCredential(dependencies, source, request, op
|
|
|
28
28
|
// A Domain credential never outlives its source, so only a fresher source can lengthen it.
|
|
29
29
|
const bySource = ttlSeconds < request.ttlSeconds;
|
|
30
30
|
if (bySource && credential.expiresAt - Date.now() <= (request.coverageMs ?? 0)) {
|
|
31
|
-
throw
|
|
31
|
+
throw sourceExhausted();
|
|
32
32
|
}
|
|
33
33
|
return Object.freeze({ ...credential, delegation });
|
|
34
34
|
}
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import { inspect } from '@astrale-os/kernel-core/auth/credential';
|
|
2
2
|
import { codecs, issuer } from '@astrale-os/kernel-protocol';
|
|
3
3
|
import { ProtocolError } from '@astrale-os/kernel-protocol/errors';
|
|
4
|
-
import { ClientError } from '../../errors/index.js';
|
|
4
|
+
import { ClientError, ExchangeError, SessionError } from '../../errors/index.js';
|
|
5
5
|
import { allowEndpoint, runSessionOperation } from '../context/index.js';
|
|
6
6
|
import { boundedResponse, requireExactResponse } from '../discovery/index.js';
|
|
7
|
+
const DISCOVERY = Object.freeze({ name: 'discovery', invalid: 'discovery' });
|
|
8
|
+
const TOKEN_EXCHANGE = Object.freeze({ name: 'token exchange', invalid: 'invalid-response' });
|
|
7
9
|
/**
|
|
8
10
|
* The token exchange endpoint a Domain issuer advertises. Endpoint policy applies here, so a denied
|
|
9
11
|
* endpoint is never returned and no delegation is minted for it.
|
|
10
12
|
*/
|
|
11
13
|
export async function tokenEndpoint(dependencies, domain, operation) {
|
|
12
|
-
const configuration =
|
|
14
|
+
const configuration = await fetchAdmitted(dependencies, DISCOVERY, new URL(issuer.paths(domain).configuration, domain).href, operation, (body) => issuer.acceptConfiguration(body, domain));
|
|
13
15
|
const endpoint = configuration.token_exchange_endpoint;
|
|
14
16
|
if (endpoint === undefined) {
|
|
15
|
-
throw new
|
|
17
|
+
throw new ExchangeError('The installed Domain does not advertise token exchange.', {
|
|
18
|
+
failure: 'unsupported',
|
|
19
|
+
});
|
|
16
20
|
}
|
|
17
21
|
allowEndpoint(endpoint, dependencies.policy);
|
|
18
22
|
return endpoint;
|
|
@@ -23,28 +27,28 @@ export async function tokenEndpoint(dependencies, domain, operation) {
|
|
|
23
27
|
* the reported one.
|
|
24
28
|
*/
|
|
25
29
|
export async function redeem(dependencies, domain, endpoint, delegation, operation) {
|
|
26
|
-
const exchanged =
|
|
30
|
+
const exchanged = await fetchAdmitted(dependencies, TOKEN_EXCHANGE, endpoint, operation, (body) => issuer.exchange.acceptResponse(body), delegation);
|
|
27
31
|
let claims;
|
|
28
32
|
try {
|
|
29
33
|
claims = inspect(exchanged.token);
|
|
30
34
|
}
|
|
31
35
|
catch (cause) {
|
|
32
|
-
throw
|
|
36
|
+
throw invalidResponse('Domain token exchange returned an invalid credential.', cause);
|
|
33
37
|
}
|
|
34
38
|
if (claims.iss !== domain ||
|
|
35
39
|
claims.aud !== dependencies.kernel ||
|
|
36
40
|
claims.claims.exp !== exchanged.expiresAt) {
|
|
37
|
-
throw
|
|
41
|
+
throw invalidResponse('Domain token exchange returned an inconsistent credential.');
|
|
38
42
|
}
|
|
39
43
|
const expiresAt = exchanged.expiresAt * 1_000;
|
|
40
44
|
if (expiresAt <= Date.now()) {
|
|
41
|
-
throw
|
|
45
|
+
throw invalidResponse('Domain token exchange returned an expired credential.');
|
|
42
46
|
}
|
|
43
47
|
return Object.freeze({ credential: exchanged.token, expiresAt });
|
|
44
48
|
}
|
|
45
|
-
|
|
49
|
+
/** Fetch one issuer leg and admit its JSON body; every failure names the leg that caused it. */
|
|
50
|
+
async function fetchAdmitted(dependencies, leg, url, operation, admit, credential) {
|
|
46
51
|
allowEndpoint(url, dependencies.policy);
|
|
47
|
-
const stage = credential === undefined ? 'discovery' : 'token exchange';
|
|
48
52
|
return runSessionOperation(operation, async (signal) => {
|
|
49
53
|
const init = {
|
|
50
54
|
method: credential === undefined ? 'GET' : 'POST',
|
|
@@ -58,26 +62,43 @@ async function fetchJson(dependencies, url, operation, credential) {
|
|
|
58
62
|
redirect: 'manual',
|
|
59
63
|
signal,
|
|
60
64
|
};
|
|
61
|
-
|
|
65
|
+
let response;
|
|
66
|
+
try {
|
|
67
|
+
response = await dependencies.fetch(url, init);
|
|
68
|
+
}
|
|
69
|
+
catch (cause) {
|
|
70
|
+
if (signal.aborted)
|
|
71
|
+
throw cause;
|
|
72
|
+
throw new ExchangeError(`Domain ${leg.name} could not be reached.`, {
|
|
73
|
+
failure: 'unavailable',
|
|
74
|
+
cause,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
62
77
|
try {
|
|
63
78
|
requireExactResponse(response, url, 'Domain token exchange');
|
|
64
|
-
if (!response.ok)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
cause: failure,
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
return codecs.json.decode(await boundedResponse(response, dependencies.maximumResponseBytes, signal));
|
|
79
|
+
if (!response.ok)
|
|
80
|
+
throw await refusal(response, leg, signal);
|
|
81
|
+
return admit(codecs.json.decode(await boundedResponse(response, dependencies.maximumResponseBytes, signal)));
|
|
82
|
+
}
|
|
83
|
+
catch (cause) {
|
|
84
|
+
throw signal.aborted ? cause : unadmitted(cause, leg);
|
|
74
85
|
}
|
|
75
86
|
finally {
|
|
76
87
|
// Also release unread bodies rejected by URL, status, or declared-size validation.
|
|
77
88
|
void response.body?.cancel().catch(() => undefined);
|
|
78
89
|
}
|
|
79
90
|
});
|
|
80
|
-
async function
|
|
91
|
+
async function refusal(response, leg, signal) {
|
|
92
|
+
const status = `Domain ${leg.name} returned HTTP ${response.status}.`;
|
|
93
|
+
const refused = leg === TOKEN_EXCHANGE ? await domainError(response, signal) : undefined;
|
|
94
|
+
return refused === undefined
|
|
95
|
+
? new ExchangeError(status, { failure: leg.invalid })
|
|
96
|
+
: new ExchangeError(`${status} ${refused.payload.code}: ${refused.message}`, {
|
|
97
|
+
failure: 'rejected',
|
|
98
|
+
cause: refused,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
async function domainError(response, signal) {
|
|
81
102
|
try {
|
|
82
103
|
const body = await boundedResponse(response, dependencies.maximumResponseBytes, signal);
|
|
83
104
|
return new ProtocolError(issuer.exchange.acceptErrorResponse(codecs.json.decode(body)).error);
|
|
@@ -87,3 +108,25 @@ async function fetchJson(dependencies, url, operation, credential) {
|
|
|
87
108
|
}
|
|
88
109
|
}
|
|
89
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Report evidence the leg could not admit under that leg's failure. Validation failures of the
|
|
113
|
+
* representation (type, syntax, response shape) become exchange failures; anything else, including
|
|
114
|
+
* Session interruption and unknown defects, keeps its own identity.
|
|
115
|
+
*/
|
|
116
|
+
function unadmitted(cause, leg) {
|
|
117
|
+
if (cause instanceof ExchangeError || cause instanceof SessionError)
|
|
118
|
+
return cause;
|
|
119
|
+
if (cause instanceof ClientError || cause instanceof TypeError || cause instanceof SyntaxError) {
|
|
120
|
+
return new ExchangeError(`Domain ${leg.name} returned an invalid response.`, {
|
|
121
|
+
failure: leg.invalid,
|
|
122
|
+
cause,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return cause;
|
|
126
|
+
}
|
|
127
|
+
function invalidResponse(message, cause) {
|
|
128
|
+
return new ExchangeError(message, {
|
|
129
|
+
failure: 'invalid-response',
|
|
130
|
+
...(cause === undefined ? {} : { cause }),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -4,7 +4,7 @@ import type { Call, Result } from '../../client/index.js';
|
|
|
4
4
|
import type { SessionOperation } from '../context/index.js';
|
|
5
5
|
import type { EffectiveAuth, SessionAuth } from '../routing/index.js';
|
|
6
6
|
import type { ExchangeDependencies } from './exchange.js';
|
|
7
|
-
import {
|
|
7
|
+
import { ExchangeError } from '../../errors/index.js';
|
|
8
8
|
/** A resolved source that carries both a credential and its delegation. */
|
|
9
9
|
export type SourceAuthority = Required<EffectiveAuth>;
|
|
10
10
|
/** Source Kernel syscalls issued as exactly one admitted source caller within one operation. */
|
|
@@ -13,9 +13,7 @@ export interface SourceCaller {
|
|
|
13
13
|
readonly call: AuthCall;
|
|
14
14
|
}
|
|
15
15
|
/** The source credential can no longer grant a delegation that covers what its exchange carries. */
|
|
16
|
-
export declare
|
|
17
|
-
constructor();
|
|
18
|
-
}
|
|
16
|
+
export declare function sourceExhausted(): ExchangeError;
|
|
19
17
|
export declare function isAuthority(auth: EffectiveAuth): auth is SourceAuthority;
|
|
20
18
|
/** The source Kernel conversation of one exchange, acting as exactly the admitted source caller. */
|
|
21
19
|
export declare function sourceCaller(dependencies: Pick<ExchangeDependencies, 'source'>, auth: EffectiveAuth, operation: SessionOperation): SourceCaller;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { inspect } from '@astrale-os/kernel-core/auth/credential';
|
|
2
|
-
import {
|
|
2
|
+
import { ExchangeError } from '../../errors/index.js';
|
|
3
3
|
import { runSessionOperation, sessionTransportOptions } from '../context/index.js';
|
|
4
4
|
import { requireResultKind, resolveAuth } from '../routing/index.js';
|
|
5
5
|
import { ISSUANCE_MARGIN_SECONDS } from './limits.js';
|
|
6
6
|
/** The source credential can no longer grant a delegation that covers what its exchange carries. */
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
7
|
+
export function sourceExhausted() {
|
|
8
|
+
return new ExchangeError('The source credential expires too soon for a Domain token exchange.', {
|
|
9
|
+
failure: 'source-exhausted',
|
|
10
|
+
});
|
|
11
11
|
}
|
|
12
12
|
export function isAuthority(auth) {
|
|
13
13
|
return auth.credential !== undefined && auth.delegate !== undefined;
|
|
@@ -34,7 +34,7 @@ export function delegationTtl(credential, ttlSeconds, coverageMs = 0) {
|
|
|
34
34
|
if (remaining === undefined || remaining >= ttlSeconds)
|
|
35
35
|
return ttlSeconds;
|
|
36
36
|
if (remaining < 1 || remaining * 1_000 <= coverageMs)
|
|
37
|
-
throw
|
|
37
|
+
throw sourceExhausted();
|
|
38
38
|
return remaining;
|
|
39
39
|
}
|
|
40
40
|
/**
|
|
@@ -47,7 +47,7 @@ export async function renewing(auth, call, source, operation, attempt) {
|
|
|
47
47
|
return await attempt(source);
|
|
48
48
|
}
|
|
49
49
|
catch (cause) {
|
|
50
|
-
if (!(cause instanceof
|
|
50
|
+
if (!(cause instanceof ExchangeError) || cause.failure !== 'source-exhausted')
|
|
51
51
|
throw cause;
|
|
52
52
|
let renewed = await resolveAuth(auth, call, operation);
|
|
53
53
|
if (renewed.credential === source.credential && auth.refresh !== undefined) {
|